fix(web): bound a pending #session= link and retire it on Home or a web tab (#507 review)

- A #session=<id> link whose session never appears (closed, a typo, or
  another user's session in multi-user mode) is dropped after
  URL_SESSION_WAIT_MS (30 s) with a "Session not found" toast instead of
  waiting forever. One stored timer per link, cleared whenever the link is
  followed, replaced by a newer link, or retired.
- goHome() and opening a web tab now retire a waiting link, so a session
  that turns up later no longer takes the screen. App-made web tab opens
  (frame self-recovery, the fallback after the active web tab closes) pass
  auto: true and keep it, as selectSession() does.
- zh-CN translation for the new toast.
- selectSession's auto: true comment now lists the #session=<id> link.
- docs: the 30 s bound, a win.location.replace() tip that avoids piling up
  history entries, and the fragment declared a stable SemVer surface in
  versioning-policy.md.
- Tests: timeout drops and toasts, an early arrival is still selected, the
  wait does not restart, goHome and a web tab retire it, an auto web tab
  open keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-01 11:20:14 +02:00
parent 73c0bfccc4
commit 846c62fbf7
6 changed files with 233 additions and 20 deletions
+65 -11
View File
@@ -567,6 +567,14 @@ const DEFAULT_SHORTCUTS = [
*/
const SIDEBAR_RICH_CLOCK_MS = 20000;
/**
* How long a `#session=<id>` link waits for the session list to name its id
* before the dashboard drops it with a "Session not found" toast (see
* _armUrlSessionWait). Long enough for a page that has just created the
* session to see its session:created land here.
*/
const URL_SESSION_WAIT_MS = 30000;
class CodemanApp {
constructor() {
this.sessions = new Map();
@@ -605,6 +613,7 @@ class CodemanApp {
// 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._urlSessionWaitTimer = null; // bounds that wait (_armUrlSessionWait)
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
@@ -1006,6 +1015,8 @@ class CodemanApp {
window.addEventListener('hashchange', () => {
const id = this._takeUrlSession();
if (!id) return;
// A new link replaces one still waiting, and gets a wait of its own.
this._retireUrlSession();
this._urlSessionId = id;
this._selectUrlSession();
});
@@ -1394,20 +1405,56 @@ class CodemanApp {
/** 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.
* session:created arrives here, so an unknown id stays pending (for at most
* URL_SESSION_WAIT_MS) 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;
if (!id) return false;
if (!this.sessions.has(id)) {
this._armUrlSessionWait(id);
return false;
}
this._retireUrlSession();
this.selectSession(id, { auto: true });
return true;
}
/** Bound the wait for a link whose id the session list does not have. A
* stale link (that session is closed), a typo, or in multi-user mode another
* user's session (never in this client's list) would otherwise wait with
* nothing on screen, and take the tab whenever a matching session turned up.
* One timer per link: handleInit running again (an SSE reconnect) does not
* restart it, and every way a link ends goes through _retireUrlSession. */
_armUrlSessionWait(id) {
if (this._urlSessionWaitTimer) return;
this._urlSessionWaitTimer = setTimeout(() => {
this._urlSessionWaitTimer = null;
if (this._urlSessionId !== id) return;
// Listed by a path other than session:created (a session:updated): select it.
if (this.sessions.has(id)) {
this._selectUrlSession();
return;
}
this._retireUrlSession();
this.showToast?.('Session not found', 'warning');
}, URL_SESSION_WAIT_MS);
}
/** Drop a waiting `#session=<id>` link and its timer: the link was followed,
* replaced by a newer one, timed out, or the user chose something else
* (another tab, Home, a web tab). */
_retireUrlSession() {
this._urlSessionId = null;
if (this._urlSessionWaitTimer) {
clearTimeout(this._urlSessionWaitTimer);
this._urlSessionWaitTimer = null;
}
}
/**
* 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
@@ -4421,6 +4468,9 @@ class CodemanApp {
this._selectUrlSession();
return;
}
// Not listed yet: its wait starts now that the list has loaded, and the
// last active tab is restored meanwhile.
if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId);
const previousActiveId = this.activeSessionId;
if (this.sessionOrder.length === 0) {
@@ -6619,7 +6669,7 @@ class CodemanApp {
// 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;
this._retireUrlSession();
}
// 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
@@ -6630,12 +6680,13 @@ class CodemanApp {
}
const forceReload = options?.forceReload === true;
// ⚠️ `auto: true` marks a selection the APP made rather than the human:
// the boot restore, a solo window opening its target, the fallback after
// the active session is deleted. Those must NOT spend a pending idle alert
// (the yellow survives until a real tap), because "the app put this on
// screen" is not "I checked it". The DEFAULT is user-initiated, so a call
// site nobody tagged fails toward acknowledging rather than toward an
// alert that can never be cleared.
// the boot restore, a solo window opening its target, a `#session=<id>`
// link from another page, the fallback after the active session is
// deleted. Those must NOT spend a pending idle alert (the yellow survives
// until a real tap), because "the app put this on screen" is not "I
// checked it". The DEFAULT is user-initiated, so a call site nobody tagged
// fails toward acknowledging rather than toward an alert that can never be
// cleared.
const userInitiated = options?.auto !== true;
if (this.activeSessionId === sessionId && !forceReload) {
// Tapping the tab you are already on is still "I checked it". The alert
@@ -7598,6 +7649,9 @@ class CodemanApp {
// ═══════════════════════════════════════════════════════════════
goHome() {
// Going Home is choosing something else, so a `#session=<id>` link still
// waiting for its session must not take the screen later.
this._retireUrlSession();
// Deselect active session and show welcome screen
this.activeSessionId = null;
try { localStorage.removeItem('codeman-active-session'); } catch {}
+2
View File
@@ -571,6 +571,8 @@
'Task Complete': '任务完成',
'Copied to clipboard': '已复制到剪贴板',
'Nothing to copy': '没有可复制的内容',
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
'Session not found': '未找到会话',
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
Copy: '复制',
+15 -4
View File
@@ -274,7 +274,10 @@ Object.assign(CodemanApp.prototype, {
// one `/`, and refuse whatever still opens a second one. The proxied form
// is refused server-side as well (resolveUpstreamUrl).
const path = data.path.replace(/[\t\n\r]/g, '').replace(/^[/\\]+/, '/');
void this.openWebview(id, { path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/' });
void this.openWebview(id, {
path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/',
auto: true,
});
return;
}
};
@@ -356,15 +359,23 @@ Object.assign(CodemanApp.prototype, {
*/
/**
* @param {string} id
* @param {{path?: string}} [options] `path` (pathname+search+hash) opens a
* @param {{path?: string, auto?: boolean}} [options] `path` (pathname+search+hash) opens a
* deep link inside the dashboard: appended to the proxy prefix, or resolved
* against the real URL in direct mode. A mounted frame is navigated there
* rather than left on whatever page it was showing.
* rather than left on whatever page it was showing. `auto: true` marks an
* open the APP made (a frame recovering itself, the fallback after the
* active web tab closes), as on selectSession().
*/
async openWebview(id, options = {}) {
const webview = this.webviews.get(id);
if (!webview) return;
// Opening a web tab yourself is choosing something else, so a
// `#session=<id>` link still waiting for its session must not take the
// screen from this tab later. Retired before the await below, which a
// session:created could otherwise land inside.
if (options.auto !== true) this._retireUrlSession?.();
if (!this.webviewOrder.includes(id)) {
this.webviewOrder.push(id);
this._persistWebviewOrder();
@@ -513,7 +524,7 @@ Object.assign(CodemanApp.prototype, {
this.activeWebviewId = null;
const next = this.webviewOrder[0];
if (next) {
this.openWebview(next);
this.openWebview(next, { auto: true });
} else {
this._hideWebviewLayer();
// Fall back to whatever session was last shown, or the welcome screen.