mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user