From decdaa1160531e50df70cb4077497f88cfa0e5e2 Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Wed, 15 Jul 2026 14:04:41 -0400 Subject: [PATCH] feat(client-state): add bounded host snapshot --- static/js/MODULE_SUMMARY.md | 1 + static/js/calendar.js | 24 ++- static/js/clientState.js | 162 ++++++++++++++++++++ static/js/document.js | 21 +++ static/sw.js | 3 +- tests/test_client_state_js.py | 268 ++++++++++++++++++++++++++++++++++ 6 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 static/js/clientState.js create mode 100644 tests/test_client_state_js.py diff --git a/static/js/MODULE_SUMMARY.md b/static/js/MODULE_SUMMARY.md index df5b0cb33c..1204a680f9 100644 --- a/static/js/MODULE_SUMMARY.md +++ b/static/js/MODULE_SUMMARY.md @@ -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. | diff --git a/static/js/calendar.js b/static/js/calendar.js index bc0cc89ad3..7b49eb657e 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -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, @@ -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. @@ -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(); @@ -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 @@ -3590,6 +3598,7 @@ let _highlightEventUid = null; function _doCloseCalendar() { _open = false; + markClientStateView('calendar', false); _restoreSidebar(); if (_modal) { _modal.style.display = 'none'; @@ -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 @@ -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; diff --git a/static/js/clientState.js b/static/js/clientState.js new file mode 100644 index 0000000000..1f9567574e --- /dev/null +++ b/static/js/clientState.js @@ -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; +} diff --git a/static/js/document.js b/static/js/document.js index 82aa5db802..9836c78731 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -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; @@ -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'); @@ -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 @@ -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 @@ -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); @@ -11034,5 +11053,7 @@ const documentModule = { isLibraryOpen, }; +registerClientStateProvider('document', _getDocumentClientState); + export default documentModule; window.documentModule = documentModule; diff --git a/static/sw.js b/static/sw.js index 05b3d4e9b4..2e1a27c468 100644 --- a/static/sw.js +++ b/static/sw.js @@ -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