mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-24 21:04:48 -06:00
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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user