fix(ui): ws lifecycle round 2 — dead-session revive + proxied tab-menu verbs

Two reported console bugs, one shared root: a pane can outlive its
session, and nothing brought the two back together.

Reconnect: an interactive pane whose stream died (ws closed/evicted
elsewhere, node restart, re-home) could never reconnect while its tab
existed — openPane() on an existing pane was focus-only, the
controller's connect() is one-shot, and its 5s recovery loop re-dialed
the SAME node forever (infinite 404 polling through the console proxy).
The only workaround was closing the tab before resuming.

- createInteractivePane now tracks terminal failure: 3 consecutive
  CLOSED recovery beats -> give up (stream closed, timers + any pending
  history load invalidated, status bar "Disconnected", opts.onDead
  fired once). host.onStreamOpen (new hook) resets the counter;
  isDead()/markDead()/base join the controller surface; onLogin
  ignores a dead controller — revive owns recovery, so a deliberately
  closed session is never resurrected by a timer.
- PaneManager.openPane fires pane.onReopen(extra) when it targets an
  ALREADY-OPEN pane — the explicit-intent signal (saved-list resume,
  rail row, child link) that activate() can't carry (hooks no-op on the
  active pane, and onActivate also fires on plain tab switches).
  getPane() added for cross-cutting lifecycle signals.
- The shell paints a click-to-reconnect banner on give-up — and
  immediately on Tier-1 ws_closed via the new
  TS_SHELL.notifySessionClosed seam (the console keeps the tab, unlike
  the standalone's auto-close, so the conversation stays readable).
  Reopen/banner-click revives: tear down the dead controller,
  re-resolve through the origin-first POST /open lane, rebuild.  The
  forced resolve skips BOTH beginConnect fast paths (a stale Tier-1 row
  must not bypass /open) while a live node leads the hint chain (an
  origin-first /open then reuses a genuinely-live session instead of
  loading a duplicate on the old meta node).  The standalone lane POSTs
  its local /open on revive too — /events 404s on an unloaded ws.
- Coordinator parity: the factory exposes reconnect() (acts only on a
  missing/CLOSED stream; OPEN is healthy, CONNECTING is already being
  worked) and the pane's onReopen drives it — the saved-list resume
  POSTs /open before openPane, so a fresh stream is all it needs.

Tab menu: a node-proxied interactive pane's dropdown gated every verb
on classic globals that only exist in ui/static/app.js, so the console
got a nearly-empty menu whose one surviving verb (Export) hit the
console origin and 404'd. convTabMenu gains a base-aware fallback lane:
verbs POST against the pane's OWN transport base (controller's exact
base -> persisted node hint -> live Tier-1 node; a verb is omitted
while no base is resolvable — never aimed at the wrong origin).
Close/Delete confirm first (window.confirm, the coordinator precedent)
and treat 404 as intent-satisfied (nothing left to stop/delete -> drop
the tab). exportWorkstreamDownload takes the base. The standalone
keeps its globals lane (incl. Fork) byte-identical, and an empty verb
section no longer renders a leading separator.

Verified: 189 JS-pin tests; two headless-Chrome live-DOM harnesses
driving the real modules — console 16/16 (connect -> ws_closed ->
banner -> reopen revives on a new node with the fresh hint -> give-up
stops retrying -> live-node-led resolve), standalone 10/10 (globals
menu intact, revive POSTs /open exactly once, no cluster resolve).
This commit is contained in:
Patrick Buckley
2026-06-09 11:44:12 -07:00
parent 91c4afb2d0
commit 4b1536be2c
9 changed files with 584 additions and 50 deletions
+27
View File
@@ -165,3 +165,30 @@ def test_approval_keyboard_shortcuts_wired() -> None:
"the feedback field uses the converged .conv-feedback, not the retired "
".ts-approval-feedback"
)
def test_controller_terminal_dead_state() -> None:
"""Lifecycle round 2: the console controller must STOP reconnect-polling a
session that is gone (closed / evicted / node restarted) — three consecutive
CLOSED recovery beats → give up: stream closed, status bar terminal,
``opts.onDead()`` fired once. A successful stream open resets the counter
(the new host.onStreamOpen seam). ``isDead()`` / ``markDead()`` / ``base``
are the shell's revive surface; a dead controller also ignores the login
re-arm (recovery may need a DIFFERENT node — the shell's revive owns it)."""
body = _INTERACTIVE.read_text(encoding="utf-8")
# The give-up ladder.
assert "let dead = false;" in body and "let failCount = 0;" in body
assert "const giveUp = function () {" in body
assert "failCount += 1;" in body and "if (failCount >= 3) giveUp();" in body
assert 'pane._sbTokens.textContent = "Disconnected"' in body, (
"the terminal state must be worded distinctly from the transient Reconnecting…"
)
assert "opts.onDead" in body, "the shell must hear about the give-up"
# The reset seam: Pane.connectSSE onopen → host.onStreamOpen → failCount = 0.
assert "this._host.onStreamOpen(this)" in body
assert "onStreamOpen() {}" in body, "the default host must carry the no-op"
# The shell-facing surface.
assert "isDead()" in body and "markDead: giveUp," in body
assert "base: base," in body, "the controller must expose its transport base"
# Dead controllers don't reconnect on re-auth.
assert "if (connected && !dead) pane._loadHistoryThenConnect(wsId);" in body
+156 -3
View File
@@ -453,7 +453,9 @@ def test_step7_tab_menu_wired_per_persona() -> None:
header's removed Export + end (5e.2e) return here as Export + Close workstream
(its controller's closeSession). The three-verb close (Close pane = pm.close
!= Close workstream != Delete) is the spine; the standalone interactive verbs
are feature-detected globals, so the console degrades to a reduced menu."""
prefer the feature-detected globals (which also manage its local roster), and
a deployment without them (the console) falls back to the base-aware lane
(see test_tab_menu_base_aware_verb_lane)."""
shell = _SHELL_JS.read_text(encoding="utf-8")
assert "function convTabMenu(" in shell, "the shared tab-menu builder must exist"
assert shell.count("pane.tabMenu =") >= 3, (
@@ -471,13 +473,52 @@ def test_step7_tab_menu_wired_per_persona() -> None:
assert "exportWorkstreamDownload" in shell, "Export conversation must wire the shared util"
# Deployment-aware: the standalone interactive verbs are feature-detected globals.
assert 'typeof window.closeWorkstream === "function"' in shell, (
"the interactive Close workstream is a standalone-only global (feature-detected)"
"the interactive Close workstream prefers the standalone global (feature-detected)"
)
assert "refreshWorkstreamTitle" in shell and "confirmDeleteWorkstream" in shell, (
"the interactive title/delete verbs are feature-detected standalone globals"
"the interactive title/delete verbs prefer the standalone globals"
)
def test_tab_menu_base_aware_verb_lane() -> None:
"""Lifecycle round 2: a proxied interactive pane's tab menu must act on the
pane's OWN transport base, not the console origin — the globals lane only
exists on the standalone. convTabMenu therefore takes a `base` getter and
falls back to POSTing the verb at {base}/v1/api/workstreams/{ws}/{verb}; a
node-verb is OMITTED while no base is resolvable (never aimed at the wrong
origin), and Export forwards the base to the shared util (a proxied export
must come from the node that owns the conversation)."""
shell = _SHELL_JS.read_text(encoding="utf-8")
assert "function postWsVerb(" in shell, "the base-aware verb POST helper must exist"
assert '"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/" + verb' in shell
# The interactive pane supplies its current base: live controller's (exact),
# else the persisted node hint, else the live Tier-1 node, else null.
assert "const menuBase = ()" in shell, "the interactive pane must expose a base getter"
assert "pane._ctl && pane._ctl.base != null" in shell, (
"a built controller's base is authoritative for the menu verbs"
)
# Fallback verbs exist for the console: refresh-title / title / close / delete.
for verb in ('"refresh-title"', '"title"', '"close"', '"delete"'):
assert (
f"postWsVerb(base, wsId, {verb}" in shell
or f"postWsVerb(closeBase, id, {verb}" in shell
), f"the {verb} verb must have a base-aware fallback"
# Export rides the base too (3-arg form), and node-verbs are null-gated.
assert "exportWorkstreamDownload(wsId, null, base)" in shell
assert "base != null" in shell, "node-verbs must be omitted while the base is unresolved"
# Destructive fallbacks confirm first (window.confirm is the house precedent).
assert shell.count("window.confirm(") >= 2, (
"the close + delete fallbacks must confirm before acting"
)
# No leading separator when the verb section is empty.
assert "if (items.length) items.push({ separator: true })" in shell
util = (_SHARED / "utils.js").read_text(encoding="utf-8")
assert "function exportWorkstreamDownload(wsId, btn, base)" in util, (
"the shared export util must accept the transport base"
)
assert '(base || "") +' in util, "the export URL must be base-prefixed"
def test_step7_tab_menu_css_promoted_shared() -> None:
"""Step 7: the dropdown chrome is promoted to the SHARED shell sheet (so both
deployments render it), recovered from the retired .ws-tab-dropdown design but
@@ -559,3 +600,115 @@ def test_step7_auth_gated_open_pane() -> None:
assert "canOpen:" in shell and "onDeny:" in shell, (
"the coordinator registerType must supply the auth gate"
)
# ---------------------------------------------------------------------------
# Workstream-lifecycle round 2: dead-session revive + explicit-reopen seam.
# ---------------------------------------------------------------------------
def test_pane_manager_reopen_seam() -> None:
"""openPane() on an ALREADY-OPEN pane fires `pane.onReopen(extra)` — the
explicit-intent signal (saved-list resume, rail row, child link) that
activate() cannot carry: hooks no-op on the already-active pane, and
onActivate also fires on plain tab switches. Fired AFTER activate so the
pane is visible when it reacts. getPane lets the shell reach a pane for
cross-cutting lifecycle signals."""
pane = _PANE_JS.read_text(encoding="utf-8")
assert "onReopen(extra) {}" in pane, "ShellPane must document the onReopen hook"
assert "const existed = !!pane" in pane, "openPane must remember create-vs-focus"
assert "pane.onReopen(extra)" in pane, "openPane must fire onReopen on existing panes"
# Ordering: the reopen signal comes after activation.
assert pane.index("this.activate(paneId)") < pane.index("pane.onReopen(extra)")
assert "getPane(type, id)" in pane, "PaneManager must expose getPane for the shell"
def test_interactive_pane_dead_session_revive() -> None:
"""The reported round-2 bug: an interactive session whose stream died
(closed / evicted / node restarted) could never reconnect while its tab
existed — openPane focused the dead pane, onActivate's connect() is one-shot,
and the controller's recovery loop re-dialed the SAME node forever. The fix:
the shell paints a click-to-reconnect banner when the controller reports
dead, and an explicit reopen (onReopen) revives — tear down the dead
controller, re-resolve the node (POST /open), rebuild."""
shell = _SHELL_JS.read_text(encoding="utf-8")
assert "const showDeadBanner = ()" in shell, "the dead banner painter must exist"
assert "pane-dead-banner" in shell, "the banner carries its own style hook"
css = _SHELL_CSS.read_text(encoding="utf-8")
assert ".pane-dead-banner" in css and ".pane-dead-banner:focus-visible" in css, (
"the banner is a real <button>; shell.css must reset its native chrome "
"and give it a keyboard focus treatment"
)
assert "Session disconnected" in shell, "the banner states the terminal condition"
assert "const revive = (freshNodeId)" in shell, "the revive path must exist"
assert "onDead: showDeadBanner" in shell, (
"the controller's terminal give-up must surface the banner"
)
# Revive is full teardown + re-resolve: unsubscribe login, destroy, rebuild.
ridx = shell.index("const revive =")
rbody = shell[ridx : ridx + 900]
assert "TS_LOGIN.unsubscribe" in rbody and "destroy()" in rbody
assert "beginConnect(true)" in rbody, "revive must force the resolve path"
# onActivate shows the banner for a dead controller instead of connect();
# onReopen revives (the resume-with-a-pre-existing-tab path).
assert "this._ctl.isDead && this._ctl.isDead()" in shell
assert "revive(reExtra && reExtra.nodeId)" in shell, (
"onReopen must revive with the caller's fresh node hint"
)
# The standalone revive path must (re)open the local session — /events 404s
# on an unloaded ws; only the forceResolve lane POSTs /open.
assert "function ensureInteractiveNode(caps, wsId, hint, openFirst)" in shell
eidx = shell.index("function ensureInteractiveNode(")
ebody = shell[eidx : eidx + 700]
assert '"/open"' in ebody and '{ method: "POST" }' in ebody.replace("\n", " ").replace(
" ", " "
).replace(" ", " "), "standalone openFirst must POST /open"
# Revive must skip BOTH fast paths (a stale Tier-1 row must not bypass the
# /open), while the live node — when present — stays the resolve HINT so an
# origin-first /open reuses a genuinely-live session instead of loading a
# duplicate copy on the old meta node.
assert "if (!forceResolve && (liveNode || !caps.cluster))" in shell, (
"beginConnect's fast paths must both yield to the forced resolve"
)
assert "liveNode || (pane.meta && pane.meta.nodeId)" in shell, (
"the live Tier-1 node must lead the resolve-hint chain"
)
def test_shell_marks_pane_dead_on_ws_closed() -> None:
"""Tier-1 ws_closed → the open pane stops reconnect-polling a session that
is GONE and shows the reconnect affordance immediately (the console keeps
the tab — unlike the standalone, which closes the pane outright)."""
shell = _SHELL_JS.read_text(encoding="utf-8")
assert "const notifySessionClosed = (wsId)" in shell
assert 'pm.getPane("interactive", wsId)' in shell
assert "p._ctl.markDead()" in shell
assert "window.TS_SHELL = { panes: pm, caps, notifySessionClosed }" in shell, (
"the seam must be exported on TS_SHELL for the console's Tier-1 handler"
)
app = _CONSOLE_APP.read_text(encoding="utf-8")
closed = app.index('=== "ws_closed"')
block = app[closed : closed + 1200]
assert "notifySessionClosed" in block, (
"the console ws_closed handler must notify the shell's open pane"
)
def test_coordinator_pane_reconnects_on_reopen() -> None:
"""The coordinator variant of resume-with-a-pre-existing-tab: the saved-list
resume POSTs /open BEFORE openPane, so the dead pane just needs a fresh
stream — onReopen calls the controller's reconnect(), which resets backoff
and reconnects only when the source is gone or CLOSED (OPEN is healthy;
CONNECTING means native retry / a fresh connect is already in flight)."""
shell = _SHELL_JS.read_text(encoding="utf-8")
assert "this._ctl.reconnect()" in shell, (
"the coordinator pane's onReopen must drive the controller's reconnect"
)
coord = (_ROOT / "turnstone/console/static/coordinator/coordinator.js").read_text(
encoding="utf-8"
)
assert "function reconnect()" in coord
assert "readyState !== EventSource.CLOSED) return" in coord.replace("\n", " "), (
"reconnect must only act on a missing/CLOSED stream"
)
assert "reconnect: reconnect," in coord, "the factory must return reconnect"
+9
View File
@@ -146,6 +146,15 @@ function patchClusterState(data) {
if (typeof loadSavedCoordinators === "function") {
loadSavedCoordinators();
}
// An open pane on this session must stop reconnect-polling a stream that
// is now gone and show its reconnect affordance instead (the shell owns
// the pane lifecycle; this is the Tier-1 → pane seam).
if (
window.TS_SHELL &&
typeof window.TS_SHELL.notifySessionClosed === "function"
) {
window.TS_SHELL.notifySessionClosed(data.ws_id);
}
} else if (t === "ws_rename") {
Object.keys(clusterState.nodes).forEach(function (nid) {
(clusterState.nodes[nid].workstreams || []).forEach(function (ws) {
@@ -4682,6 +4682,19 @@ function createCoordinatorPane(root, wsId, opts) {
_childObserver.disconnect();
}
// Reconnect a DEAD stream NOW (reset backoff), leaving a live one alone —
// OPEN is healthy and CONNECTING means native retry / a fresh connect is
// already working the problem. The shell calls this on an explicit re-open
// of an already-open pane (saved-list resume POSTs /open first): a
// coordinator whose session was closed under the pane sits in the capped
// retry loop — this short-circuits straight to a fresh /events against the
// reopened session (a stale replay cursor degrades to the server's
// fresh-replay path).
function reconnect() {
if (evtSource && evtSource.readyState !== EventSource.CLOSED) return;
onLogin();
}
// Enter-to-send / Shift-Enter newline / IME-safe handling lives in
// shared/composer.js; no duplicate listener here.
return {
@@ -4689,6 +4702,7 @@ function createCoordinatorPane(root, wsId, opts) {
connect: init,
destroy: destroy,
onLogin: onLogin,
reconnect: reconnect,
closeSession: coordCloseSession,
};
}
+67 -5
View File
@@ -139,6 +139,9 @@ const INTERACTIVE_DEFAULT_HOST = {
// bare/console pane relies on it, so this is a no-op. The standalone focused
// pane additionally refetches + reassigns the ws list (see app.js).
onStreamError() {},
// EventSource (re)opened — the dual of onStreamError. The console pane host
// uses it to reset its terminal-failure counter (see createInteractivePane).
onStreamOpen() {},
// Where the ``--skip-permissions`` banner lands (standalone: #ui-header).
warningTarget(pane) {
return pane.messagesEl;
@@ -877,6 +880,7 @@ class Pane {
this.retryDelay = 1000;
this.statusBarEl.classList.remove("ws-sb-disconnected");
if (this._lastStatusEvt) this.updateStatus(this._lastStatusEvt);
this._host.onStreamOpen(this);
};
this.evtSource.onmessage = (e) => {
@@ -3266,6 +3270,41 @@ function createInteractivePane(root, wsId, opts) {
let active = false;
let connected = false;
let recoverTimer = null;
// Terminal-failure tracking. Native EventSource auto-reconnect (plus the 5s
// CLOSED-state recovery below) covers transient drops — but a session that is
// GONE from its node (closed/evicted, node restarted, re-homed) 404s every
// reconnect forever. After 3 consecutive CLOSED checks the controller gives
// up: stream closed, timers dropped, status bar terminal, `opts.onDead()`
// fired ONCE so the shell can paint its reconnect affordance. Recovery is
// the shell's revive path (re-resolve node + POST /open + rebuild) — never
// automatic, so a deliberately-closed session is not resurrected by a timer.
// A successful stream open resets the counter (host.onStreamOpen).
let dead = false;
let failCount = 0;
const giveUp = function () {
if (dead) return;
dead = true;
failCount = 0;
if (recoverTimer) {
clearTimeout(recoverTimer);
recoverTimer = null;
}
// Invalidate any in-flight history load: its .finally would otherwise
// reopen a stream for a session we just declared dead.
pane._historyLoadToken = (pane._historyLoadToken || 0) + 1;
pane.disconnectSSE();
// Terminal wording — the transient error path says "Reconnecting…".
pane.statusBarEl.classList.add("ws-sb-disconnected");
pane._sbTokens.textContent = "Disconnected";
if (typeof opts.onDead === "function") {
try {
opts.onDead();
} catch (e) {
console.error("interactive pane: onDead callback failed", e);
}
}
};
const host = {
// Workstream name from the Tier-1 cluster snapshot the shell owns, else the
@@ -3295,18 +3334,29 @@ function createInteractivePane(root, wsId, opts) {
// deliberately does not close the source on error). Guard the terminal
// case: if the source is genuinely CLOSED after a beat, open a fresh
// same-ws stream. No global ws-list refetch — a console pane owns one ws.
// Three consecutive CLOSED beats = the session is gone, not flaky: give up
// (a dead session would otherwise be 404-polled every 5s indefinitely).
onStreamError(pane) {
if (dead) return;
if (recoverTimer) clearTimeout(recoverTimer);
recoverTimer = setTimeout(() => {
recoverTimer = null;
if (
!pane.evtSource ||
pane.evtSource.readyState === EventSource.CLOSED
pane.evtSource &&
pane.evtSource.readyState !== EventSource.CLOSED
) {
pane.connectSSE(pane.wsId);
return; // native reconnect is still working the problem
}
failCount += 1;
if (failCount >= 3) giveUp();
else pane.connectSSE(pane.wsId);
}, 5000);
},
// Stream (re)opened — the session is reachable again; reset the give-up
// counter so unrelated future blips get a fresh allowance.
onStreamOpen() {
failCount = 0;
},
// The --skip-permissions banner lands in the pane's own slim header.
warningTarget(pane) {
return pane.messagesEl;
@@ -3328,6 +3378,10 @@ function createInteractivePane(root, wsId, opts) {
return {
wsId: wsId,
pane: pane,
// The transport base this controller talks through ("" local, "/node/{id}"
// proxied) — the shell's tab-menu verbs aim at the SAME backend the pane
// streams from.
base: base,
// First activation opens the Tier-2 stream (REST history first, then live);
// re-activations just re-mark focus. Idempotent — the shell calls it on
// every tab switch.
@@ -3343,10 +3397,18 @@ function createInteractivePane(root, wsId, opts) {
deactivate() {
active = false;
},
// Re-auth fan-out: reconnect the stream.
// Re-auth fan-out: reconnect the stream. A dead controller stays dead —
// recovery is the shell's revive path (which may need a different node).
onLogin() {
if (connected) pane._loadHistoryThenConnect(wsId);
if (connected && !dead) pane._loadHistoryThenConnect(wsId);
},
// Terminal-state surface for the shell: `isDead()` gates revive-vs-connect
// on activate/reopen; `markDead()` lets Tier-1 lifecycle (ws_closed) stop
// the retry loop NOW instead of after three failed beats.
isDead() {
return dead;
},
markDead: giveUp,
// Full teardown — close the stream + all timers/recording/tts, drop the
// recovery timer, detach the DOM. A backgrounded pane must not leak an
// upstream node connection.
+27
View File
@@ -47,6 +47,12 @@ export class ShellPane {
onActivate() {}
/** Pane left the visible tab — keep-or-teardown is per-type. */
onDeactivate() {}
/** An openPane() call targeted this ALREADY-OPEN pane — explicit user intent
* (saved-list resume, rail row, child link), distinct from onActivate which
* also fires on plain tab switches and only on a pane CHANGE. Conversational
* panes use this to revive a dead session even when the pane is already the
* active tab. `extra` is the caller's open-time hint (e.g. `{nodeId}`). */
onReopen(extra) {}
/** Pane is being destroyed — release resources (close streams, timers). */
onClose() {}
}
@@ -108,6 +114,14 @@ export class PaneManager {
return this._panes.has(paneId);
}
/** The open pane for (type, id), or null — lets the shell reach a pane for
* cross-cutting lifecycle signals (e.g. Tier-1 ws_closed → mark its session
* controller dead). Omit id for a singleton. */
getPane(type, id) {
const paneId = id == null ? type : type + ":" + id;
return this._panes.get(paneId) || null;
}
/** The active pane's identity ({type, rawId}), or null — lets the rail mark
* the workspace row that mirrors the active tab. */
getActive() {
@@ -180,6 +194,7 @@ export class PaneManager {
}
const paneId = id == null ? type : type + ":" + id;
let pane = this._panes.get(paneId);
const existed = !!pane;
if (!pane) {
// Auth gate runs only on CREATE — a denied pane is never built (the backend
// enforces the scope too; this just avoids opening a doomed pane).
@@ -198,6 +213,18 @@ export class PaneManager {
this._mount(pane);
}
this.activate(paneId);
// Explicit-reopen signal: openPane on an existing pane is a user saying
// "open this AGAIN" (saved-list resume, rail row, child link) — activate()
// alone can't carry that (it no-ops hooks on the already-active pane, and
// onActivate also fires on plain tab switches). Fired AFTER activate so
// the pane is visible when it reacts (e.g. revives a dead session).
if (existed) {
try {
pane.onReopen(extra);
} catch (e) {
console.error("PaneManager: onReopen failed", paneId, e);
}
}
return pane;
}
+22
View File
@@ -727,6 +727,28 @@
text-decoration: underline;
text-underline-offset: 2px;
}
/* Dead-session reconnect affordance (shell.js showDeadBanner) — a real <button>
for keyboard/AT reach, restyled so native OS button chrome doesn't leak into
the pane; reads as an error status line. Sits above the (dead) conversation. */
.pane-dead-banner {
display: block;
width: 100%;
text-align: left;
background: none;
border: 0;
border-bottom: 1px solid var(--hair);
font: inherit;
font-size: 13px;
color: color-mix(in srgb, var(--err) 80%, var(--ink-2));
}
.pane-dead-banner:hover {
color: var(--err);
background: color-mix(in oklab, var(--err) 8%, transparent);
}
.pane-dead-banner:focus-visible {
outline: 2px solid var(--err);
outline-offset: -2px;
}
/* ===== Dashboard session launcher — persona toggle (coordinator | interactive).
A console-dashboard control; lives here because the L-shell loads shell.css. */
+250 -38
View File
@@ -156,14 +156,29 @@ function stateForWs(wsId) {
// makes a node-proxied session survive a reload: the node /events stream 404s
// on a ws that isn't loaded on that node, so a freshly-rehydrated pane must
// (re)open the session on a node first. Resolves to {nodeId} or {error}.
// - Standalone (no cluster): every session is LOCAL → base "" (nodeId null),
// no open round-trip.
// - Standalone (no cluster): every session is LOCAL → base "" (nodeId null).
// The first-activate path skips the /open round-trip (the resume flows
// POST /open before opening the pane); the REVIVE path passes `openFirst`
// because a closed / post-restart session 404s its /events until reopened.
// - Console: the route + proxy + open work is the console's — delegate to the
// TS_APP seam (origin-first POST /open with a rendezvous fallback). Without
// the seam (unexpected on a cluster console) fall back to the open-time hint
// or the live Tier-1 snapshot, accepting the pre-reload behaviour.
function ensureInteractiveNode(caps, wsId, hint) {
if (!caps.cluster) return Promise.resolve({ nodeId: null });
// TS_APP seam (origin-first POST /open with a rendezvous fallback; it always
// opens, so `openFirst` is implicit). Without the seam (unexpected on a
// cluster console) fall back to the open-time hint or the live Tier-1
// snapshot, accepting the pre-reload behaviour.
function ensureInteractiveNode(caps, wsId, hint, openFirst) {
if (!caps.cluster) {
if (!openFirst) return Promise.resolve({ nodeId: null });
return authFetch(
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/open",
{ method: "POST" },
)
.then((r) =>
r.ok
? { nodeId: null }
: { error: "Could not reopen this session (" + r.status + ")." },
)
.catch(() => ({ error: "Could not reopen this session." }));
}
if (
window.TS_APP &&
typeof window.TS_APP.resolveInteractiveNode === "function"
@@ -188,29 +203,83 @@ function paintConvTabs(pm) {
}
}
// POST a workstream verb against a pane's OWN transport base — the node proxy
// for a console interactive pane, "" locally. The base-aware fallback lane for
// deployments without the classic verb globals (see convTabMenu).
function postWsVerb(base, wsId, verb, body) {
return authFetch(
base + "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/" + verb,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
},
);
}
// Tab-action menu items for a conversational pane — the three-verb close plus
// the per-persona verbs. Pane-type-derived AND deployment-aware: a verb appears
// only when its handler exists here, so the SAME shell yields the full menu in
// the standalone (whose interactive verbs are classic globals in ui/static's
// app.js) and a reduced menu in the console — the capability-derived-affordances
// thesis applied to the tab menu. `opts`: titleVerbs (Refresh/Edit/Fork title),
// deleteVerb (the destructive Delete), closeSession (stop the workstream itself).
// the per-persona verbs. Pane-type-derived AND deployment-aware, in two lanes:
// the classic verb GLOBALS where they exist (the standalone's ui/static app.js,
// whose verbs also manage its local roster), else a base-aware fallback that
// POSTs the verb straight to the pane's own transport base (`opts.base()` — the
// console's node proxy). A node-verb is omitted only when no base is resolvable
// yet (a never-activated pane with no node hint) — never aimed at the wrong
// origin. `opts`: titleVerbs (Refresh/Edit/Fork title), deleteVerb (the
// destructive Delete), closeSession (stop the workstream itself), base (a
// () => string|null transport-base getter; omitted = "" local).
function convTabMenu(pane, pm, wsId, opts) {
opts = opts || {};
const G = window;
const items = [];
const base = typeof opts.base === "function" ? opts.base() : "";
const toast = (msg, kind) => {
if (typeof G.showToast === "function") G.showToast(msg, kind);
};
if (opts.titleVerbs) {
if (typeof G.refreshWorkstreamTitle === "function")
items.push({
label: "Refresh title",
action: () => G.refreshWorkstreamTitle(wsId),
});
else if (base != null)
items.push({
label: "Refresh title",
action: () =>
postWsVerb(base, wsId, "refresh-title")
.then((r) =>
r.ok
? toast("Title regeneration started…", "info")
: toast("Failed to refresh title", "error"),
)
.catch(() => toast("Failed to refresh title", "error")),
});
if (typeof G.editWorkstreamTitle === "function")
items.push({
label: "Edit title",
key: "Ctrl+Shift+E",
action: () => G.editWorkstreamTitle(wsId),
});
else if (base != null)
items.push({
label: "Edit title",
action: () => {
const f = findWs(wsId, false);
const cur = (f && (f.ws.name || f.ws.title)) || "";
const next = window.prompt("Session title", cur);
if (next == null) return; // cancelled
const title = next.trim();
if (!title || title === cur) return;
postWsVerb(base, wsId, "title", { title })
.then((r) =>
r.ok
? toast("Title updated", "success")
: toast("Failed to set title", "error"),
)
.catch(() => toast("Failed to set title", "error"));
},
});
// Fork stays global-only: it needs the standalone's seeded new-session
// modal; the console has no interactive fork surface (yet).
if (typeof G.forkWorkstream === "function")
items.push({
label: "Fork",
@@ -218,12 +287,14 @@ function convTabMenu(pane, pm, wsId, opts) {
action: () => G.forkWorkstream(wsId),
});
}
if (typeof G.exportWorkstreamDownload === "function")
// Export is base-aware everywhere (a proxied pane must export from its node,
// not the console origin) — omitted while the node is unresolved.
if (typeof G.exportWorkstreamDownload === "function" && base != null)
items.push({
label: "Export conversation",
action: () => G.exportWorkstreamDownload(wsId),
action: () => G.exportWorkstreamDownload(wsId, null, base),
});
items.push({ separator: true });
if (items.length) items.push({ separator: true });
// Close pane — drop the tab, leave the session running (PaneManager-level).
items.push({
label: "Close pane",
@@ -233,14 +304,41 @@ function convTabMenu(pane, pm, wsId, opts) {
// Close workstream — stop the session itself (distinct from closing the tab).
if (opts.closeSession)
items.push({ label: "Close workstream", action: opts.closeSession });
// Delete — destroy + unsave (interactive standalone only; confirms itself).
if (opts.deleteVerb && typeof G.confirmDeleteWorkstream === "function")
items.push({
label: "Delete",
key: "Ctrl+Shift+X",
cls: "destructive",
action: () => G.confirmDeleteWorkstream(wsId),
});
// Delete — destroy + unsave. Standalone delegates to its modal-confirming
// global; the console fallback confirms inline and deletes on the node.
if (opts.deleteVerb) {
if (typeof G.confirmDeleteWorkstream === "function")
items.push({
label: "Delete",
key: "Ctrl+Shift+X",
cls: "destructive",
action: () => G.confirmDeleteWorkstream(wsId),
});
else if (base != null)
items.push({
label: "Delete",
cls: "destructive",
action: () => {
if (!window.confirm("Delete this session? This cannot be undone."))
return;
postWsVerb(base, wsId, "delete")
.then((r) => {
// 404 = no row left to delete (already deleted elsewhere) — the
// intent is satisfied either way; drop the tab.
if (!r.ok && r.status !== 404) {
toast("Failed to delete session", "error");
return;
}
pm.close(pane.id);
toast("Session deleted", "success");
// The saved list holds the deleted row — refresh it if present.
if (typeof G.loadSavedCoordinators === "function")
G.loadSavedCoordinators();
})
.catch(() => toast("Failed to delete session", "error"));
},
});
}
return items;
}
@@ -377,15 +475,56 @@ async function mountShell() {
title: wsTitle(id),
stateful: true, // tab shows live Tier-1 state (no static placeholder)
});
pane.tabMenu = () =>
convTabMenu(pane, pm, id, {
// The pane's CURRENT transport base for tab-menu verbs: the live
// controller's (exact), else the persisted node hint, else the live Tier-1
// node; null = unresolved (node-verbs are omitted until the pane connects).
// Standalone is always local ("").
const menuBase = () => {
if (pane._ctl && pane._ctl.base != null) return pane._ctl.base;
if (pane.meta && pane.meta.nodeId)
return "/node/" + encodeURIComponent(pane.meta.nodeId);
if (!caps.cluster) return "";
const live = nodeForWs(id);
return live ? "/node/" + encodeURIComponent(live) : null;
};
pane.tabMenu = () => {
// Close workstream: the standalone's roster-managing global where it
// exists, else end the session on its own node (confirm-first, like the
// coordinator's End session) and drop the tab. Hidden while the node is
// unresolved — same omit-don't-misaim rule as the other node-verbs.
const closeBase = menuBase();
const closeSession =
typeof window.closeWorkstream === "function"
? () => window.closeWorkstream(id)
: closeBase == null
? null
: () => {
if (
!window.confirm(
"End this session? The server will terminate it.",
)
)
return;
const failToast = () => {
if (typeof window.showToast === "function")
window.showToast("Could not end session", "error");
};
postWsVerb(closeBase, id, "close")
.then((r) => {
// 404 = nothing left to stop (closed under us / node lost
// it) — the user's intent is satisfied; drop the tab.
if (r.ok || r.status === 404) pm.close(pane.id);
else failToast();
})
.catch(failToast);
};
return convTabMenu(pane, pm, id, {
titleVerbs: true,
deleteVerb: true,
closeSession:
typeof window.closeWorkstream === "function"
? () => window.closeWorkstream(id)
: null,
base: menuBase,
closeSession: closeSession,
});
};
// Persist the open-time node hint so a reload re-opens on the SAME node
// (origin-first; avoids a re-route + duplicate load). Updated to the
// resolved node after ensureInteractiveNode settles, below.
@@ -413,6 +552,7 @@ async function mountShell() {
pane._ctl = createInteractivePane(pane.bodyEl, id, {
nodeId,
onClose: () => pm.close(pane.id),
onDead: showDeadBanner,
});
pane._ctl.connect();
if (window.TS_LOGIN && pane._ctl.onLogin) {
@@ -420,11 +560,49 @@ async function mountShell() {
window.TS_LOGIN.subscribe(pane._ctl.onLogin);
}
};
// Terminal dead session (the controller exhausted its reconnects, or Tier-1
// said ws_closed): keep the conversation readable, but surface ONE
// actionable affordance. Reviving is never automatic — a deliberately
// closed session must not resurrect on a timer; the user (or an explicit
// reopen gesture) decides.
const showDeadBanner = () => {
if (pane._closed || pane._deadBanner) return;
const b = document.createElement("button");
b.type = "button";
b.className = "pane-status pane-status--retry pane-dead-banner";
b.textContent = "Session disconnected — click to reconnect.";
b.addEventListener("click", () => revive());
pane.bodyEl.prepend(b);
pane._deadBanner = b;
};
// Tear down the dead controller and re-run the resolve + connect path.
// forceResolve: the session may need (re)opening on its node (POST /open)
// or may have re-homed — never trust the dead controller's base. An
// explicit fresh hint (a saved-row click carries the roster's node_id)
// supersedes the stale persisted one.
const revive = (freshNodeId) => {
if (pane._closed || pane._resolving || !pane._ctl) return;
if (pane._deadBanner) {
pane._deadBanner.remove();
pane._deadBanner = null;
}
if (window.TS_LOGIN && pane._ctl.onLogin)
window.TS_LOGIN.unsubscribe(pane._ctl.onLogin);
pane._ctl.destroy();
pane._ctl = null;
if (freshNodeId && (!pane.meta || pane.meta.nodeId !== freshNodeId)) {
pane.meta = { nodeId: freshNodeId };
pm.setPaneMeta(pane.id, pane.meta);
}
pane._statusEl = make("div", "pane-status", "Reconnecting…");
pane.bodyEl.append(pane._statusEl);
beginConnect(true);
};
// Errored resolve (capacity / no node free): show it in the status line and
// offer a one-click retry — re-clicking the tab won't re-fire onActivate
// (PaneManager fires it only on a pane CHANGE), so without this a transient
// failure would strand the pane until the user closed + reopened it.
const showResolveError = (msg) => {
const showResolveError = (msg, forceResolve) => {
const el = pane._statusEl;
if (!el) return;
el.className = "pane-status pane-status--retry msg error";
@@ -436,7 +614,7 @@ async function mountShell() {
el.title = "";
el.onclick = null;
el.textContent = "Connecting…";
beginConnect();
beginConnect(forceResolve);
};
};
// First-activate connect. A LIVE session (Tier-1 already names its node, so
@@ -444,19 +622,26 @@ async function mountShell() {
// hot rail / active-row / just-created path. Standalone runs locally. Only
// the dormant / reload case (the snapshot has no node for this ws) resolves
// the node + (re)opens the session, whose /events would otherwise 404.
const beginConnect = () => {
// `forceResolve` (the revive path) skips BOTH fast paths so the resolve
// POSTs /open — the give-up fired because /events 404'd, so the session
// needs (re)loading even when a stale Tier-1 row still names a node. The
// live node (when one exists) stays the HINT, so the origin-first /open
// reuses a genuinely-live session in place rather than loading a second
// copy on the old meta node.
const beginConnect = (forceResolve) => {
const liveNode = caps.cluster ? nodeForWs(id) : null;
if (liveNode || !caps.cluster) {
if (!forceResolve && (liveNode || !caps.cluster)) {
buildController(liveNode || null);
return;
}
pane._resolving = true;
const hint = (pane.meta && pane.meta.nodeId) || (extra && extra.nodeId);
ensureInteractiveNode(caps, id, hint).then((res) => {
const hint =
liveNode || (pane.meta && pane.meta.nodeId) || (extra && extra.nodeId);
ensureInteractiveNode(caps, id, hint, forceResolve).then((res) => {
pane._resolving = false;
if (pane._closed) return; // closed mid-resolve — don't build into a detached body
if (!res || res.error) {
showResolveError(res && res.error);
showResolveError(res && res.error, forceResolve);
return;
}
buildController(res.nodeId);
@@ -465,12 +650,24 @@ async function mountShell() {
pane.onActivate = function () {
pm.setTabGlyph(pane.id, glyph(stateForWs(id))); // live Tier-1 state glyph
if (this._ctl) {
if (this._ctl.isDead && this._ctl.isDead()) {
showDeadBanner(); // visible terminal state; reviving is the user's call
return;
}
this._ctl.connect(); // built — idempotent re-mark focus
return;
}
if (this._resolving) return; // first-activate resolve already in flight
beginConnect();
};
// Explicit re-open (saved-list resume, rail row, child link) targeted this
// already-open pane. A healthy pane needs nothing (activate re-marked
// focus); a DEAD one revives — this is the "resume with a pre-existing tab"
// path, which previously focused the dead pane and reconnected nothing.
pane.onReopen = function (reExtra) {
if (this._ctl && this._ctl.isDead && this._ctl.isDead())
revive(reExtra && reExtra.nodeId);
};
pane.onDeactivate = function () {
if (this._ctl && this._ctl.deactivate) this._ctl.deactivate();
};
@@ -525,6 +722,14 @@ async function mountShell() {
}
}
};
// Explicit re-open (saved-list resume with this pane already open): the
// resume already POSTed /open, so a coordinator whose stream went dead
// (session was closed, console restarted) just needs a fresh connect —
// reconnect() no-ops on a healthy OPEN stream.
pane.onReopen = function () {
if (this._connected && this._ctl && this._ctl.reconnect)
this._ctl.reconnect();
};
pane.onClose = function () {
if (this._ctl) {
if (window.TS_LOGIN && this._ctl.onLogin)
@@ -553,7 +758,14 @@ async function mountShell() {
}
}
window.TS_SHELL = { panes: pm, caps };
// Tier-1 lifecycle → pane signal. The console's ws_closed handler calls this
// so an open pane on that session stops its reconnect loop NOW (instead of
// 404-polling a session that is gone) and shows the reconnect affordance.
const notifySessionClosed = (wsId) => {
const p = pm.getPane("interactive", wsId);
if (p && p._ctl && p._ctl.markDead) p._ctl.markDead();
};
window.TS_SHELL = { panes: pm, caps, notifySessionClosed };
// Login fan-out: app.js owns the single window.onLoginSuccess (the Tier-1
// reconnect, set at load). Wrap it in a tiny registry so EVERY conversational
+12 -4
View File
@@ -143,14 +143,18 @@ function setMarkdown(el, content) {
}
// Download a workstream's conversation as OpenAI-shaped JSON. Hits
// GET /v1/api/workstreams/{ws_id}/export, which streams a
// GET {base}/v1/api/workstreams/{ws_id}/export, which streams a
// ``{"messages":[...]}`` body with a Content-Disposition attachment
// filename. Shared by the interactive appbar (app.js) and the
// coordinator appbar (coordinator.js) so both export buttons behave
// identically. authFetch already handles the 401 (shows login) and
// identically. ``base`` is the session's transport prefix — "" for a
// local / console-homed session (the default), "/node/{id}" when the
// console proxies a node-hosted interactive session (the export must
// come from the node that owns the conversation, not the console).
// authFetch already handles the 401 (shows login) and
// 429 (retry) paths and returns the raw Response, so we read .blob()
// directly and synthesise an anchor click to trigger the browser save.
async function exportWorkstreamDownload(wsId, btn) {
async function exportWorkstreamDownload(wsId, btn, base) {
if (!wsId) {
showToast("No conversation to export", "error");
return;
@@ -166,7 +170,11 @@ async function exportWorkstreamDownload(wsId, btn) {
btn.setAttribute("aria-busy", "true");
}
try {
const url = "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/export";
const url =
(base || "") +
"/v1/api/workstreams/" +
encodeURIComponent(wsId) +
"/export";
let r;
try {
r = await authFetch(url);