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
30 changes: 30 additions & 0 deletions docs/foreground-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Foreground action contract

Odysseus browser modules may register one of three version 1 foreground actions:

| Action ID | Core handler |
| --- | --- |
| `open_view:calendar` | Open Calendar |
| `close_view:document` | Close the visible or minimized document |
| `minimize_view:document` | Minimize the visible document |

Streaming consumers pass this exact `ui_control` data object to
`handleUIControl()`:

```json
{
"ui_event": "foreground_action",
"version": 1,
"action": "open_view:calendar",
"payload": {}
}
```

The registry rejects unknown versions, actions, fields, non-empty payloads,
messages larger than 1 KiB, duplicate registrations, and invocation before a
handler is registered. Registration returns an idempotent unregister function.

Action IDs include the only allowed target and version 1 payloads are empty.
This contract cannot carry a selector, URL, HTML, script, or generic DOM
command. Server-side authorization remains required before emitting an event;
the browser registry is an additional fail-closed boundary, not an auth layer.
22 changes: 21 additions & 1 deletion static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ import notesModule from './js/notes.js';
import adminModule from './js/admin.js';
import settingsModule from './js/settings.js';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js';
import * as Modals from './js/modalManager.js';
// Desktop window tiling — drag a modal near an edge/corner to snap.
import './js/tileManager.js';
import themeModule from './js/theme.js';
import { FOREGROUND_ACTIONS, registerForegroundAction } from './js/foregroundActions.js';
// IMPORTANT: import cookbook.js with NO ?v= query — the same plain specifier
// every other importer (cookbook-hwfit.js / cookbook-diagnosis.js) uses. A query
// mismatch makes the browser load cookbook.js twice as separate modules (two
Expand All @@ -53,6 +54,25 @@ window.uiModule = uiModule;
window.adminModule = adminModule;
window.cookbookModule = cookbookModule;

registerForegroundAction(FOREGROUND_ACTIONS.OPEN_CALENDAR, () => {
calendarModule.openCalendar();
return true;
});
registerForegroundAction(FOREGROUND_ACTIONS.CLOSE_DOCUMENT, () => {
if (Modals.isMinimized('doc-panel')) {
Modals.close('doc-panel');
return true;
}
if (!documentModule.isPanelOpen()) return false;
documentModule.closePanel();
return true;
});
registerForegroundAction(FOREGROUND_ACTIONS.MINIMIZE_DOCUMENT, () => {
if (!documentModule.isPanelOpen()) return false;
documentModule.closePanel('down');
return true;
});

function _isMobileChatInput() {
return window.innerWidth <= 768;
}
Expand Down
1 change: 1 addition & 0 deletions static/js/MODULE_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
| **`a11y.js`** | Accessibility helpers. |
| **`platform.js`** | Platform detection (macOS/Windows/Linux) and keyboard-modifier helpers. |
| **`escMenuStack.js`** | Stack manager for dismissible popups. |
| **`foregroundActions.js`** | Versioned, bounded allowlist for browser-owned Calendar/document foreground actions. |
| **`dragSort.js`** | Drag-to-sort shared behavior. |
| **`tourHints.js`** / **`tourAutoplay.js`** | Onboarding tour helpers. |
| **`color/hex.js`**, **`colorPicker.js`**, **`langIcons.js`**, **`util/ordinal.js`** | Small utility modules for color, language icons, and formatting. |
Expand Down
2 changes: 1 addition & 1 deletion static/js/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -3478,13 +3478,13 @@ function _wheelNav(e) {
}

function openCalendar() {
if (_open) return;
// If currently minimized — restore in place, preserve all state
if (Modals.isMinimized('calendar-modal')) {
Modals.restore('calendar-modal');
_open = true;
return;
}
if (_open) return;
_open = true;
if (_todayCount() > 0) { _markBadgeSeen(); _updateBadge(); }
_collapseSidebar();
Expand Down
3 changes: 3 additions & 0 deletions static/js/chatStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import themeModule from './theme.js';
import markdownModule from './markdown.js';
import sessionModule from './sessions.js';
import documentModule from './document.js';
import { dispatchForegroundAction } from './foregroundActions.js';

/**
* Handle a ui_control SSE event — AI-driven UI manipulation.
Expand All @@ -18,6 +19,8 @@ export function handleUIControl(uiData) {
var esc = uiModule.esc;

try {
if (dispatchForegroundAction(uiData)) return;

if (uiEvent === 'toggle' || uiData.ui_event === 'toggle') {
var toggleMap = {
web: 'web-toggle', bash: 'bash-toggle', rag: 'rag-toggle',
Expand Down
3 changes: 2 additions & 1 deletion static/js/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -6797,9 +6797,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
// the doc from the chat session so it doesn't reappear in that chat.
closeFn: () => {
// Content was already saved to the map when the panel was minimized,
// so just detach (don't re-read the now-removed editor).
// so clear its restore marker and detach without re-reading the editor.
const id = _minimizedDocId;
_minimizedDocId = null;
_markDocVisibleState(_lastSessionId, 'closed');
if (id) _detachDocFromSession(id);
},
restoreFn: () => {
Expand Down
124 changes: 124 additions & 0 deletions static/js/foregroundActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Strict browser-owned foreground action registry.
//
// Transport envelope:
// { ui_event: 'foreground_action', version: 1, action: '<allowlisted id>', payload: {} }
//
// Action IDs include their only valid target. V1 payloads are therefore empty
// objects: callers cannot smuggle selectors, URLs, markup, or executable text
// through this seam.

export const FOREGROUND_ACTION_VERSION = 1;
export const MAX_FOREGROUND_ACTION_BYTES = 1024;
export const FOREGROUND_ACTIONS = Object.freeze({
OPEN_CALENDAR: 'open_view:calendar',
CLOSE_DOCUMENT: 'close_view:document',
MINIMIZE_DOCUMENT: 'minimize_view:document',
});

const _allowedActions = new Set(Object.values(FOREGROUND_ACTIONS));
const _handlers = new Map();
const _envelopeKeys = new Set(['action', 'payload', 'version']);
const _messageKeys = new Set(['action', 'payload', 'ui_event', 'version']);

function _fail(code, message) {
const error = new Error(message);
error.name = 'ForegroundActionError';
error.code = code;
throw error;
}

function _isPlainObject(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

function _requireExactKeys(value, allowed, code) {
for (const key of Object.keys(value)) {
if (!allowed.has(key)) _fail(code, 'Foreground action fields do not match the contract');
}
for (const key of allowed) {
if (!Object.prototype.hasOwnProperty.call(value, key)) {
_fail(code, 'Foreground action fields do not match the contract');
}
}
}

function _serializedBytes(value) {
let encoded;
try {
encoded = JSON.stringify(value);
} catch (_) {
_fail('invalid_envelope', 'Foreground action must be JSON serializable');
}
if (typeof encoded !== 'string') {
_fail('invalid_envelope', 'Foreground action must be JSON serializable');
}
return new TextEncoder().encode(encoded).byteLength;
}

function _validateEnvelope(envelope) {
if (!_isPlainObject(envelope)) {
_fail('invalid_envelope', 'Foreground action envelope must be an object');
}
_requireExactKeys(envelope, _envelopeKeys, 'invalid_envelope');
if (_serializedBytes(envelope) > MAX_FOREGROUND_ACTION_BYTES) {
_fail('payload_too_large', 'Foreground action envelope is too large');
}
if (envelope.version !== FOREGROUND_ACTION_VERSION) {
_fail('unsupported_version', 'Unsupported foreground action version');
}
if (typeof envelope.action !== 'string' || !_allowedActions.has(envelope.action)) {
_fail('unknown_action', 'Unknown foreground action');
}
if (!_isPlainObject(envelope.payload) || Object.keys(envelope.payload).length !== 0) {
_fail('invalid_payload', 'Foreground action payload must be an empty object');
}
}

export function registerForegroundAction(action, handler) {
if (typeof action !== 'string' || !_allowedActions.has(action)) {
_fail('unknown_action', 'Cannot register an unknown foreground action');
}
if (typeof handler !== 'function') {
_fail('invalid_handler', 'Foreground action handler must be a function');
}
if (_handlers.has(action)) {
_fail('duplicate_action', `Foreground action already registered: ${action}`);
}

_handlers.set(action, handler);
let active = true;
return () => {
if (!active) return false;
active = false;
if (_handlers.get(action) !== handler) return false;
return _handlers.delete(action);
};
}

export function invokeForegroundAction(envelope) {
_validateEnvelope(envelope);
const handler = _handlers.get(envelope.action);
if (!handler) {
_fail('unregistered_action', `Foreground action is not registered: ${envelope.action}`);
}
return handler(Object.freeze({}));
}

// Returns false for every legacy ui_control event so existing dispatch can
// continue. A message that declares itself as a foreground action is either
// invoked successfully or rejected; it never falls through to another path.
export function dispatchForegroundAction(message) {
if (!_isPlainObject(message) || message.ui_event !== 'foreground_action') return false;
_requireExactKeys(message, _messageKeys, 'invalid_envelope');
if (_serializedBytes(message) > MAX_FOREGROUND_ACTION_BYTES) {
_fail('payload_too_large', 'Foreground action message is too large');
}
invokeForegroundAction({
version: message.version,
action: message.action,
payload: message.payload,
});
return true;
}
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 @@ -16,6 +16,7 @@ const PRECACHE = [
'/',
'/static/style.css',
'/static/app.js',
'/static/js/foregroundActions.js',
'/static/js/storage.js',
'/static/js/ui.js',
'/static/js/markdown.js',
Expand Down
Loading