diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 436821ec..c2170bc6 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -580,7 +580,7 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. -⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the three selections the APP makes (boot restore, a solo window opening its target, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. +⚠️ **Only a HUMAN opening a session acknowledges**: `selectSession(id, { auto: true })` marks the four selections the APP makes (boot restore, a solo window opening its target, a `#session=` link from another page, the fallback after the active session is closed) and skips the acknowledgement, so a page load cannot silently spend an alert the user never saw. The flag defaults to user-initiated, so an untagged call site fails toward acknowledging rather than toward an alert nothing can clear; `test/session-select-ack-gate.test.ts` pins both the gate and the tagged call sites. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. ⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it. diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index 2d440485..5e0bb605 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -338,6 +338,33 @@ codeman ralph start|stop|status|reset codeman users add|passwd|list codeman status | list | attach codeman doctor ``` +### Opening a session from your own page + +To send someone from your page to one session, link to the dashboard with the +session id in the fragment, as in `http://127.0.0.1:3000/#session=`. The +dashboard selects that tab when it loads. It also removes the fragment from its +own URL, so a later link to the same session still counts as a change. + +Keep reusing one named window to make later links fast: + +```js +window.open(`${codeman}/#session=${encodeURIComponent(id)}`, 'codeman'); +``` + +When that window already shows the dashboard, only the fragment differs. The +browser therefore keeps the page loaded, and the dashboard switches tabs without +reloading it. A session the window has shown before appears at once. A session +your page has only just created may not be listed yet, so the dashboard waits +for its `session:created` event and selects it then. + +Following a link does not count as someone looking at the session, so it +leaves the session's idle alert in place. The alert clears when the person +clicks the tab or types into the session. A link to a session that is popped +out into its own window asks that window to come forward, as clicking its tab does. + +A link to `/session/` opens a page showing that session alone, and that +page loads from scratch for every link. + ## Seam 4: Hooks Claude Code hooks post to `POST /api/v1/hook-event` from inside an agent session. diff --git a/src/web/public/app.js b/src/web/public/app.js index 56997f21..c6662d90 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -602,6 +602,9 @@ class CodemanApp { // service-worker shell loads), with the server-injected global as a fallback. this.soloSessionId = this._detectSoloSessionId(); this.isSoloWindow = !!this.soloSessionId; + // A session another page asked for with a `#session=` link. It waits + // here until the session list has that id (see _selectUrlSession). + this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession(); this.detachedSessions = new Set(); // dashboard-side: ids currently popped out this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle @@ -997,6 +1000,16 @@ class CodemanApp { // strip never flashes before handleInit selects the target session. this._initWindowChannel(); if (this.isSoloWindow) document.body.classList.add('solo-mode'); + // A page holding this window switches its tab by changing only the + // fragment, which keeps the page loaded (see sessionIdFromFragment). + if (!this.isSoloWindow) { + window.addEventListener('hashchange', () => { + const id = this._takeUrlSession(); + if (!id) return; + this._urlSessionId = id; + this._selectUrlSession(); + }); + } // Initialize mobile handlers KeyboardHandler.init(); SwipeHandler.init(); @@ -1368,6 +1381,33 @@ class CodemanApp { } catch { return null; } } + /** Read a `#session=` link off the URL and drop the fragment. The next + * link to the same session is then a change the browser reports, even + * after you have clicked away to another tab. Returns the id or null. */ + _takeUrlSession() { + const id = window.CodemanUrlSession?.sessionIdFromFragment(location.hash) ?? null; + if (id) { + try { history.replaceState(history.state, '', location.pathname + location.search); } catch {} + } + return id; + } + + /** Show the session a `#session=` link asked for, once the session list + * has it. A page that has just created a session can link to it before + * session:created arrives here, so an unknown id stays pending and + * _onSessionCreated tries again. + * + * ⚠️ The selection is `auto`. The page that set the fragment may be a + * script, and this window may not even be in front, so following a link is + * not a human looking at the session and must not spend its idle alert. */ + _selectUrlSession() { + const id = this._urlSessionId; + if (!id || !this.sessions.has(id)) return false; + this._urlSessionId = null; + this.selectSession(id, { auto: true }); + return true; + } + /** * Pop a session out into its own browser window. SINGLE, idempotent entry * point: the tab's pop-out icon calls this, and a future gesture layer @@ -1949,6 +1989,7 @@ class CodemanApp { this.updateCost(); // Start stats polling when first session appears if (this.sessions.size === 1) this.startSystemStatsPolling(); + if (this._urlSessionId === data.id) this._selectUrlSession(); } _onSessionUpdated(data) { @@ -4367,6 +4408,13 @@ class CodemanApp { return; } + // A `#session=` link wins over restoring the last active tab. + if (this._urlSessionId && this.sessions.has(this._urlSessionId)) { + this.activeSessionId = null; + this._selectUrlSession(); + return; + } + const previousActiveId = this.activeSessionId; if (this.sessionOrder.length === 0) { this.activeSessionId = null; @@ -6560,6 +6608,12 @@ class CodemanApp { } async selectSession(sessionId, options = {}) { + // Picking another tab yourself retires a `#session=` link still + // waiting for its session, which would otherwise take the tab from you + // whenever that session turned up (see _selectUrlSession). + if (options?.auto !== true && this._urlSessionId && this._urlSessionId !== sessionId) { + this._urlSessionId = null; + } // If this session is popped out into its own window, raise that window // instead of showing it inline (focus-on-click for detached tabs). If we // owned a now-closed window, _raiseDetached re-docks and returns false so diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 6f9d85c8..9bb29beb 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1853,10 +1853,27 @@ function reconcilePtyGeometry(local, pty) { return { adopt: true, cols: pty.cols }; } +/** + * Which session does a dashboard URL's fragment ask for? Another page that + * holds the dashboard's window, such as a task board, points it at + * `/#session=`. Only the fragment changes between two such links, so the + * browser keeps the page loaded and fires `hashchange`, and the dashboard + * switches tabs without reloading. Any other fragment asks for nothing. + * + * @param {string} hash - `location.hash`, with or without its leading `#` + * @returns {string|null} the session id, or null + */ +function sessionIdFromFragment(hash) { + const params = new URLSearchParams(String(hash || '').replace(/^#/, '')); + const id = params.get('session'); + return id && id.trim() ? id.trim() : null; +} + if (typeof window !== 'undefined') { window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine }; window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS }; window.CodemanTerminalLines = { terminalLogicalLine }; + window.CodemanUrlSession = { sessionIdFromFragment }; window.CodemanSplitPane = { clampDividerPercent, buildSplitPickerSessions, diff --git a/test/session-select-ack-gate.test.ts b/test/session-select-ack-gate.test.ts index 8a93b167..904b5de9 100644 --- a/test/session-select-ack-gate.test.ts +++ b/test/session-select-ack-gate.test.ts @@ -5,9 +5,10 @@ * `selectSession()` acknowledges the session's idle approval item server-side * (`markIdleAlertSeen` → `POST /api/approvals/session/:id/viewed`), which is * what makes "I checked it" survive a reload and reach the user's other - * devices. Three call sites are the APP choosing a session rather than the - * user: the boot restore, a solo (popped-out) window opening its target, and - * the fallback after the active session is deleted. Those pass `auto: true` + * devices. Four call sites are the APP choosing a session rather than the + * user: the boot restore, a solo (popped-out) window opening its target, a + * `#session=` link from another page, and the fallback after the active + * session is deleted. Those pass `auto: true` * and must not spend the alert, or a yellow tab would clear itself every time * the page loaded and the user would never see it. * @@ -110,7 +111,7 @@ describe('selectSession acknowledgement gate', () => { }); describe('the call sites the app drives itself', () => { - // Source guard: these three are the reason the flag exists. If a refactor + // Source guard: these call sites are the reason the flag exists. If a refactor // moves or reformats them, fail loudly rather than silently going back to // "every page load clears the user's yellow tab". it.each([ @@ -118,6 +119,7 @@ describe('selectSession acknowledgement gate', () => { ['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'], ['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'], ['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'], + ['a #session= link from another page', 'this.selectSession(id, { auto: true });'], ])('%s passes auto: true', (_label, call) => { expect(APP_SOURCE).toContain(call); }); diff --git a/test/url-session-fragment.test.ts b/test/url-session-fragment.test.ts new file mode 100644 index 00000000..c75a8fe7 --- /dev/null +++ b/test/url-session-fragment.test.ts @@ -0,0 +1,179 @@ +// test/url-session-fragment.test.ts +// Port: N/A (no server/browser — loads constants.js and app.js via `vm`, like session-select-ack-gate.test.ts). +// +// A page that holds the dashboard's window switches its tab with a +// `#session=` link, and sessionIdFromFragment() is what reads the link. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { performance } from 'node:perf_hooks'; +import { describe, expect, it, vi } from 'vitest'; + +function loadHelper() { + const context = vm.createContext({ window: {}, globalThis: {}, URLSearchParams }); + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + vm.runInContext(source, context, { filename: 'constants.js' }); + return (context.window as { CodemanUrlSession: { sessionIdFromFragment: (hash: unknown) => string | null } }) + .CodemanUrlSession; +} + +describe('CodemanUrlSession.sessionIdFromFragment', () => { + const { sessionIdFromFragment } = loadHelper(); + + it('reads the id from a #session= fragment', () => { + expect(sessionIdFromFragment('#session=76763752-fa3a-40aa-a025-e1684c82d00e')).toBe( + '76763752-fa3a-40aa-a025-e1684c82d00e' + ); + }); + + it('accepts the fragment without its leading #', () => { + expect(sessionIdFromFragment('session=abc')).toBe('abc'); + }); + + it('decodes an encoded id', () => { + expect(sessionIdFromFragment('#session=' + encodeURIComponent('w1 my/app'))).toBe('w1 my/app'); + }); + + it('finds the id beside other fragment parameters', () => { + expect(sessionIdFromFragment('#tab=2&session=abc')).toBe('abc'); + }); + + it('asks for nothing when the fragment names no session', () => { + expect(sessionIdFromFragment('')).toBeNull(); + expect(sessionIdFromFragment('#')).toBeNull(); + expect(sessionIdFromFragment('#settings')).toBeNull(); + expect(sessionIdFromFragment('#session=')).toBeNull(); + expect(sessionIdFromFragment('#session=%20')).toBeNull(); + expect(sessionIdFromFragment(undefined)).toBeNull(); + }); +}); + +// The dashboard side: reading the link, holding an id it does not list yet, +// and handing the selection over. Loaded like session-select-ack-gate.test.ts, +// on a bare instance whose DOM-touching methods are stubbed. +function loadApp() { + const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const location = { hash: '', pathname: '/', search: '' }; + const history = { + state: null, + replaceState: vi.fn((_state: unknown, _title: string, url: string) => { + location.hash = url.includes('#') ? url.slice(url.indexOf('#')) : ''; + }), + }; + const context = vm.createContext({ + console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() }, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + fetch: vi.fn(), + URLSearchParams, + location, + history, + document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null }, + localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + MobileDetection: { isTouchDevice: () => false }, + }); + vm.runInContext(`${constants}\n${app}\nglobalThis.__CodemanApp = CodemanApp;`, context); + const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp; + const make = (ids: string[]) => { + const inst = Object.create(CodemanApp.prototype) as Record; + inst.sessions = new Map(ids.map((id) => [id, { id, name: id }])); + inst.sessionOrder = [...ids]; + inst.detachedSessions = new Set(); + inst.detachedWindows = new Map(); + inst.isSoloWindow = false; + inst._urlSessionId = null; + inst.selectSession = vi.fn(); + for (const stub of [ + 'saveSessionOrder', + 'markSessionTabEntering', + 'markTerminalEntering', + 'renderSessionTabs', + 'updateCost', + 'startSystemStatsPolling', + ]) { + inst[stub] = vi.fn(); + } + return inst; + }; + return { make, location, history, CodemanApp }; +} + +describe('dashboard handling of a #session= link', () => { + it('reads the link and removes the fragment, so the same link counts as a change next time', () => { + const { make, location, history } = loadApp(); + const app = make(['a']); + location.hash = '#session=a'; + expect(app._takeUrlSession()).toBe('a'); + expect(history.replaceState).toHaveBeenCalledWith(null, '', '/'); + expect(location.hash).toBe(''); + }); + + it('leaves a URL without a session link alone', () => { + const { make, location, history } = loadApp(); + location.hash = '#settings'; + expect(make([])._takeUrlSession()).toBeNull(); + expect(history.replaceState).not.toHaveBeenCalled(); + }); + + it('selects a listed session as an app selection, which leaves its idle alert armed', () => { + const { make } = loadApp(); + const app = make(['a']); + app._urlSessionId = 'a'; + expect(app._selectUrlSession()).toBe(true); + expect(app.selectSession).toHaveBeenCalledWith('a', { auto: true }); + expect(app._urlSessionId).toBeNull(); + }); + + it('holds an unlisted id until session:created names it', () => { + const { make } = loadApp(); + const app = make([]); + app._urlSessionId = 'new'; + expect(app._selectUrlSession()).toBe(false); + expect(app.selectSession).not.toHaveBeenCalled(); + app._onSessionCreated({ id: 'other', name: 'other' }); + expect(app.selectSession).not.toHaveBeenCalled(); + app._onSessionCreated({ id: 'new', name: 'new' }); + expect(app.selectSession).toHaveBeenCalledWith('new', { auto: true }); + expect(app._urlSessionId).toBeNull(); + }); + + it('retires a waiting link when you pick another tab yourself', async () => { + const { make, CodemanApp } = loadApp(); + const app = make(['a', 'b']); + app.selectSession = (CodemanApp.prototype as Record).selectSession; + app._urlSessionId = 'later'; + await app.selectSession('b').catch(() => {}); + expect(app._urlSessionId).toBeNull(); + }); + + it('keeps a waiting link through a selection the app makes itself', async () => { + const { make, CodemanApp } = loadApp(); + const app = make(['a', 'b']); + app.selectSession = (CodemanApp.prototype as Record).selectSession; + app._urlSessionId = 'later'; + await app.selectSession('b', { auto: true }).catch(() => {}); + expect(app._urlSessionId).toBe('later'); + }); + + it('puts the link ahead of restoring the last active tab when the page loads', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const link = source.indexOf('if (this._urlSessionId && this.sessions.has(this._urlSessionId))'); + const restore = source.indexOf("restoreId = localStorage.getItem('codeman-active-session')"); + expect(link).toBeGreaterThan(-1); + expect(link).toBeLessThan(restore); + }); + + it('never reads the link in a solo window', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + expect(source).toContain('this._urlSessionId = this.isSoloWindow ? null : this._takeUrlSession();'); + expect(source).toMatch(/if \(!this\.isSoloWindow\) \{\s*window\.addEventListener\('hashchange'/); + }); +});