Merge pull request #507 from irisitymichaelgrundberg/feat/select-session-from-url

feat(web): select a dashboard session from a #session=<id> link
This commit is contained in:
Codeman maintainer
2026-10-01 11:09:26 +02:00
6 changed files with 284 additions and 5 deletions
+1 -1
View File
@@ -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=<id>` 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.
+27
View File
@@ -338,6 +338,33 @@ codeman ralph start|stop|status|reset codeman users add|passwd|list
codeman status | list | attach <path> 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=<id>`. 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/<id>` 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.
+54
View File
@@ -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=<id>` 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=<id>` 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=<id>` 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=<id>` 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=<id>` 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
+17
View File
@@ -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=<id>`. 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,
+6 -4
View File
@@ -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=<id>` 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=<id> link from another page', 'this.selectSession(id, { auto: true });'],
])('%s passes auto: true', (_label, call) => {
expect(APP_SOURCE).toContain(call);
});
+179
View File
@@ -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=<id>` 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<string, any>;
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=<id> 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<string, any>).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<string, any>).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'/);
});
});