Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions static/js/MODULE_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ These are imported first and used across most features.
|---|---|---|
| **`ui.js`** | `showToast`, `showError`, `el`, `copyToClipboard`, `scrollHistory`, `setAutoScroll`, `autoResize`, `debounce`, `esc` | Shared UI helpers, toast notifications, scroll behavior, element accessor, text escaping. |
| **`storage.js`** | `default` storage wrapper | LocalStorage helpers and toggle state persistence. |
| **`clientState.js`** | `registerClientStateProvider`, `markClientStateView`, `getClientStateSnapshot` | Versioned, bounded logical Calendar/document state. `open` means visible, minimized is retained separately, and scoped snapshots report `chat` instead of promoting a background view. Fixed host schemas drop unknown fields and fail closed without DOM/content serialization. |
| **`markdown.js`** | `mdToHtml`, `processWithThinking`, `squashOutsideCode`, `normalizeThinkingMarkup`, `extractThinkingBlocks`, `hasUnclosedThinkTag`, `startsWithReasoningPrefix` | Markdown→HTML, thinking/reasoning block parsing, code-block normalization. |
| **`spinner.js`** | `create`, `createWhirlpool` | Loading/spinner factories for streaming and tool cards. |
| **`keyboard-shortcuts.js`** | `initKeyboardShortcuts` | Global keyboard shortcut wiring. |
Expand Down
24 changes: 23 additions & 1 deletion static/js/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { topPortalZ } from './toolWindowZOrder.js';
import { makeWindowDraggable } from './windowDrag.js';
import { attachColorPicker } from './colorPicker.js';
import { bindMenuDismiss } from './escMenuStack.js';
import {
CLIENT_STATE_VERSION,
markClientStateView,
registerClientStateProvider,
} from './clientState.js';
import {
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
Expand Down Expand Up @@ -682,6 +687,7 @@ function _getModal() {
document.body.appendChild(_modal);
_modal.querySelector('#cal-close').addEventListener('click', closeCalendar);
_modal.addEventListener('click', (e) => { if (e.target === _modal) closeCalendar(); });
_modal.addEventListener('pointerdown', () => markClientStateView('calendar'));
// Make draggable — replaced ~50 lines of inline drag/dock plumbing with
// a single call to the shared helper. Calendar doesn't support fullscreen
// snap so no fsClass / enter/exit callbacks here.
Expand Down Expand Up @@ -3483,9 +3489,11 @@ function openCalendar() {
if (Modals.isMinimized('calendar-modal')) {
Modals.restore('calendar-modal');
_open = true;
markClientStateView('calendar');
return;
}
_open = true;
markClientStateView('calendar');
if (_todayCount() > 0) { _markBadgeSeen(); _updateBadge(); }
_collapseSidebar();
const modal = _getModal();
Expand All @@ -3504,7 +3512,7 @@ function openCalendar() {
railBtnId: 'rail-calendar',
sidebarBtnId: 'tool-calendar-btn',
closeFn: () => _doCloseCalendar(),
restoreFn: () => {},
restoreFn: () => markClientStateView('calendar'),
});
_currentDate = new Date();
_selectedDay = _today(); // auto-show today's events on open
Expand Down Expand Up @@ -3590,6 +3598,7 @@ let _highlightEventUid = null;

function _doCloseCalendar() {
_open = false;
markClientStateView('calendar', false);
_restoreSidebar();
if (_modal) {
_modal.style.display = 'none';
Expand Down Expand Up @@ -3617,6 +3626,17 @@ function isCalendarOpen() {
return _open;
}

function _getCalendarClientState() {
const minimized = Modals.isMinimized('calendar-modal');
return {
version: CLIENT_STATE_VERSION,
open: _open && !minimized,
minimized,
view: _view,
date: _ds(_currentDate),
};
}

// ── Persistent cache (localStorage) ──
const LS_KEY = 'odysseus-calendar-cache';
const LS_TTL = 10 * 60 * 1000; // 10 min
Expand Down Expand Up @@ -3717,6 +3737,8 @@ window.addEventListener('focus', () => {
// Calendar reminders are stored as Notes. The Notes reminder loop owns
// notification dispatch so calendar reminders do not fire twice.

registerClientStateProvider('calendar', _getCalendarClientState);

const calendarModule = { openCalendar, closeCalendar, isCalendarOpen };
export { openCalendar, openCalendarTo, closeCalendar, isCalendarOpen };
export default calendarModule;
162 changes: 162 additions & 0 deletions static/js/clientState.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// static/js/clientState.js
/**
* Bounded, read-only client state for in-tree consumers.
*
* Providers expose logical state only. The host owns the fixed schemas below,
* drops unknown fields, and never serializes a provider object directly.
* `open` means visibly open; minimized state is reported separately. The
* foreground is resolved globally, so a scoped snapshot reports `chat` when
* the actual foreground slice was omitted rather than promoting a background.
*/

export const CLIENT_STATE_VERSION = 1;
export const CLIENT_STATE_MAX_BYTES = 1024;
export const CLIENT_STATE_SLICES = Object.freeze(['calendar', 'document']);

const _providers = new Map();
const _activeOrder = [];

const _closed = {
calendar: () => ({ version: CLIENT_STATE_VERSION, open: false, minimized: false, view: null, date: null }),
document: () => ({ version: CLIENT_STATE_VERSION, open: false, minimized: false, id: null }),
};

function _sliceName(name) {
if (typeof name !== 'string' || !CLIENT_STATE_SLICES.includes(name)) {
throw new TypeError('Unknown client-state slice');
}
return name;
}

function _ownValue(record, key) {
const descriptor = Object.getOwnPropertyDescriptor(record, key);
if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
throw new TypeError(`Invalid client-state field: ${key}`);
}
return descriptor.value;
}

function _record(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('Client-state provider must return an object');
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError('Client-state provider must return a plain object');
}
if (_ownValue(value, 'version') !== CLIENT_STATE_VERSION) {
throw new TypeError('Unsupported client-state version');
}
return value;
}

function _boolean(record, key) {
const value = _ownValue(record, key);
if (typeof value !== 'boolean') throw new TypeError(`Invalid client-state field: ${key}`);
return value;
}

function _nullableString(record, key, maxLength, pattern) {
const value = _ownValue(record, key);
if (value === null) return null;
if (typeof value !== 'string' || value.length < 1 || value.length > maxLength || !pattern.test(value)) {
throw new TypeError(`Invalid client-state field: ${key}`);
}
return value;
}

function _calendarState(raw) {
const record = _record(raw);
const open = _boolean(record, 'open');
const minimized = _boolean(record, 'minimized');
if (open && minimized) throw new TypeError('Client state cannot be open and minimized');

const view = _nullableString(record, 'view', 8, /^(?:month|week|year|agenda)$/);
const date = _nullableString(record, 'date', 10, /^\d{4}-\d{2}-\d{2}$/);
if (date) {
const [year, month, day] = date.split('-').map(Number);
const parsed = new Date(Date.UTC(year, month - 1, day));
if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
throw new TypeError('Invalid client-state field: date');
}
}
if (open && (!view || !date)) throw new TypeError('Open Calendar state requires view and date');
return { version: CLIENT_STATE_VERSION, open, minimized, view, date };
}

function _documentState(raw) {
const record = _record(raw);
const open = _boolean(record, 'open');
const minimized = _boolean(record, 'minimized');
if (open && minimized) throw new TypeError('Client state cannot be open and minimized');
const id = _nullableString(record, 'id', 128, /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/);
return { version: CLIENT_STATE_VERSION, open, minimized, id };
}

const _sanitize = {
calendar: _calendarState,
document: _documentState,
};

export function registerClientStateProvider(name, provider) {
name = _sliceName(name);
if (typeof provider !== 'function') throw new TypeError('Client-state provider must be a function');
if (_providers.has(name)) throw new Error(`Client-state provider already registered: ${name}`);
_providers.set(name, provider);
return () => {
if (_providers.get(name) !== provider) return;
_providers.delete(name);
markClientStateView(name, false);
};
}

export function markClientStateView(name, active = true) {
name = _sliceName(name);
if (typeof active !== 'boolean') throw new TypeError('Client-state active flag must be boolean');
const index = _activeOrder.indexOf(name);
if (index >= 0) _activeOrder.splice(index, 1);
if (active) {
if (!_providers.has(name)) throw new Error(`Client-state provider is not registered: ${name}`);
_activeOrder.push(name);
}
}

export function getClientStateSnapshot(slices = CLIENT_STATE_SLICES) {
if (!Array.isArray(slices) || slices.length < 1 || slices.length > CLIENT_STATE_SLICES.length) {
throw new TypeError('Client-state slices must be a non-empty bounded array');
}
const requested = slices.map(_sliceName);
if (new Set(requested).size !== requested.length) throw new TypeError('Duplicate client-state slice');

// Resolve every known slice once before applying request scope. Otherwise a
// request that omits the foreground could incorrectly promote a visible but
// background slice to `active_view`.
const globalState = {};
const globallyUnavailable = new Set();
for (const name of CLIENT_STATE_SLICES) {
try {
const provider = _providers.get(name);
if (!provider) throw new Error('provider unavailable');
globalState[name] = _sanitize[name](provider());
} catch (_) {
globalState[name] = _closed[name]();
globallyUnavailable.add(name);
}
}

const foreground = [..._activeOrder].reverse()
.find(name => globalState[name].open && !globalState[name].minimized);
const activeView = foreground && requested.includes(foreground) ? foreground : 'chat';
const state = Object.fromEntries(requested.map(name => [name, globalState[name]]));
const unavailable = requested.filter(name => globallyUnavailable.has(name));
const snapshot = {
version: CLIENT_STATE_VERSION,
active_view: activeView,
slices: state,
unavailable,
};
if (new TextEncoder().encode(JSON.stringify(snapshot)).byteLength > CLIENT_STATE_MAX_BYTES) {
throw new RangeError('Client-state snapshot exceeds size limit');
}
return snapshot;
}
21 changes: 21 additions & 0 deletions static/js/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documen
import signatureModule from './signature.js';
import * as Modals from './modalManager.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
import {
CLIENT_STATE_VERSION,
markClientStateView,
registerClientStateProvider,
} from './clientState.js';

let API_BASE = '';
let isOpen = false;
Expand Down Expand Up @@ -4757,6 +4762,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
if (!container) return;

isOpen = true;
markClientStateView('document');
// Doc was opened last → it goes in front of the email windows (clears the
// email-front flag; the doc/email z-index alternation lives in CSS).
document.body.classList.remove('email-front');
Expand Down Expand Up @@ -4796,6 +4802,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const pane = document.createElement('div');
pane.id = 'doc-editor-pane';
pane.className = 'doc-editor-pane';
pane.addEventListener('pointerdown', () => markClientStateView('document'), true);
// ── Mobile: make toolbar/footer buttons work on the FIRST tap with the
// keyboard up ──
// Normally a tap while the keyboard is open is eaten by the OS keyboard
Expand Down Expand Up @@ -6826,6 +6833,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
return;
}
isOpen = false;
markClientStateView('document', false);
// On touch, closing the doc should leave the keyboard DOWN. The tap blurs
// the textarea (keyboard starts down), but a stray refocus during teardown
// (the view behind regaining focus, etc.) was bouncing it back up. Blur any
Expand Down Expand Up @@ -10967,6 +10975,17 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
return activeDocId;
}

function _getDocumentClientState() {
const minimized = Modals.isMinimized('doc-panel');
const open = isOpen && !minimized;
return {
version: CLIENT_STATE_VERSION,
open,
minimized,
id: open ? activeDocId : (minimized ? _minimizedDocId : null),
};
}

export function getActiveEmailComposerContext() {
if (!activeDocId) return null;
const doc = docs.get(activeDocId);
Expand Down Expand Up @@ -11034,5 +11053,7 @@ const documentModule = {
isLibraryOpen,
};

registerClientStateProvider('document', _getDocumentClientState);

export default documentModule;
window.documentModule = documentModule;
3 changes: 2 additions & 1 deletion static/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v344';
const CACHE_NAME = 'odysseus-v345';

// Core shell precached on install so repeat opens are instant without any
// network wait. Keep this list in sync with the <script type="module"> tags
Expand All @@ -17,6 +17,7 @@ const PRECACHE = [
'/static/style.css',
'/static/app.js',
'/static/js/storage.js',
'/static/js/clientState.js',
'/static/js/ui.js',
'/static/js/markdown.js',
'/static/js/dragSort.js',
Expand Down
Loading