fix(ui): repair interactive/proxied workstream lifecycle (reload, create, launcher)

Workstream-lifecycle bugfixes on the L-shell:

- Node-proxied interactive panes now SURVIVE a browser reload.  On first
  activate a pane resolves its owning node and (re)opens the session there
  before streaming — the node /events stream 404s on a ws not loaded on its
  node, so a rehydrated pane could not just connect blind.  Resolution is
  origin-first via the new TS_APP.resolveInteractiveNode seam (POST /open with
  a rendezvous /route fallback).  PaneManager now persists a pane's resolved
  nodeId as opaque meta and hands it back on rehydrate, so a reload restores
  the pane onto the SAME node even before the Tier-1 snapshot has populated —
  the exact timing that used to strand it on base="" (the console, not a node).

- Both launcher personas open the new session as a PANE, not a full-page nav
  (coordinator -> coordinator pane; interactive -> node-proxied pane); the
  full-page nav stays only as the shell-absent fallback.  Every interactive
  entry point (create, active row, rail, saved row, child link, reload) now
  funnels through one resolve-open-connect path, folding away the bespoke
  restoreInteractiveSession helper.

- The interactive launcher gains a node-selection strategy (Least loaded |
  Specific node, with a live node picker fed from the cluster snapshot) and a
  persona-aware task hint — the shared composer no longer shows
  "...coordinator orchestrate?" when the interactive persona is selected.

Guards updated to pin the new wiring; the stale console landing test (asserting
the renovation-retired bottom-bar node picker) is corrected to the rail.
This commit is contained in:
Patrick Buckley
2026-06-07 21:46:15 -07:00
parent 39dab5c430
commit b89148544c
7 changed files with 420 additions and 130 deletions
+9 -6
View File
@@ -1105,18 +1105,21 @@ class TestConsoleHTTPEndpoints:
def test_index_landing_surfaces(self, client):
status, body, ct = self._get_raw(client, "/")
assert status == 200
# Nodes are reached through the bottom-bar node picker; the old
# always-visible NODES table was replaced by it.
assert 'id="csb-node-picker"' in body
assert 'id="csb-np-trigger"' in body
assert 'id="csb-np-menu"' in body
# Node discovery moved to the L-shell RAIL; the legacy bottom-bar node
# picker (and #cluster-status-bar) was retired by the renovation — guard
# against reintroduction (mirrors test_shell_js bottom-bar-retired).
assert 'id="csb-node-picker"' not in body
assert 'id="cluster-status-bar"' not in body
# The landing now boots the shared shell module, which builds the rail +
# tab-bar + pane host and hands off to the legacy boot.
assert "/shared/shell.js" in body
# Removed in the 1.5.0 landing-page cleanup — guard against
# accidental reintroduction.
assert 'id="new-ws-overlay"' not in body
assert 'id="new-ws-btn"' not in body
assert 'id="cluster-summary-compact"' not in body
assert 'id="view-node"' not in body
# Replaced by the node picker — guard against reintroduction.
# Replaced by the rail — guard against reintroduction.
assert 'id="view-overview"' not in body
assert 'id="node-table"' not in body
+91 -8
View File
@@ -182,6 +182,80 @@ def test_console_launcher_routes_by_persona() -> None:
assert 'id="launcher-personas"' in index, "the persona toggle must be in the launcher panel"
def test_console_launcher_creates_open_panes() -> None:
"""Workstream-lifecycle bugfix: BOTH launcher personas open the new session as
an L-shell PANE (openPane), not a full-page nav — coordinator and interactive
alike. Full-page nav survives only as the shell-absent fallback."""
app = _CONSOLE_APP.read_text(encoding="utf-8")
assert 'pm.openPane("coordinator", res.data.ws_id)' in app, (
"coordinator create must open a pane, not full-page nav"
)
assert 'pm.openPane("interactive", wsId, { nodeId: node || null })' in app, (
"interactive create must open a node-proxied pane with the created node as hint"
)
# The saved-row interactive resume opens a pane (the pane resolves+opens the
# node itself) — the bespoke restoreInteractiveSession helper is retired.
assert 'pm.openPane("interactive", s.ws_id, { nodeId: s.node_id || null })' in app, (
"saved interactive resume must open a pane with the origin node as hint"
)
assert "function restoreInteractiveSession" not in app, (
"restoreInteractiveSession is folded into resolveInteractiveNode + the pane factory"
)
def test_console_resolve_interactive_node_seam() -> None:
"""The console exposes resolveInteractiveNode(wsId, hint): origin-first
POST /open (reuse a session already loaded on its node, no duplicate), with a
rendezvous (/v1/api/route) fallback when the origin is gone. This is what
makes a node-proxied pane survive a reload — the shell's interactive factory
calls it before streaming (the node /events 404s on a not-loaded ws)."""
app = _CONSOLE_APP.read_text(encoding="utf-8")
assert "window.TS_APP.resolveInteractiveNode = function (wsId, hintNodeId)" in app
assert "/v1/api/workstreams/" in app and '"/open"' in app, (
"origin-first must POST the node /open verb"
)
assert '"/v1/api/route?ws_id="' in app, "must rendezvous-fallback when the origin is gone"
def test_console_launcher_node_strategy() -> None:
"""Workstream-lifecycle bugfix: the interactive launcher gains a node-selection
strategy (Least loaded | Specific node) with a live node picker, and the shared
composer's task hint + node fields track the active persona."""
app = _CONSOLE_APP.read_text(encoding="utf-8")
assert 'id: "node_strategy"' in app and 'id: "node_id"' in app, (
"launcher must expose the node-strategy + node-picker option fields"
)
assert "function _applyLauncherFields" in app, (
"persona switch must update the hint + node-field visibility"
)
assert "function _populateLauncherNodes" in app, (
"the specific-node picker must populate from the live cluster snapshot"
)
assert 'opts.node_strategy === "node"' in app, (
"interactive create must pin to the chosen node only under the Specific strategy"
)
composer = (_SHARED / "composer.js").read_text(encoding="utf-8")
assert "Composer.prototype.setPlaceholder" in composer, (
"composer must support a per-persona placeholder swap"
)
assert "Composer.prototype.setOptionFieldVisible" in composer, (
"composer must support conditionally revealing an option field"
)
def test_pane_persists_meta_for_rehydrate() -> None:
"""Workstream-lifecycle bugfix: PaneManager persists a pane's serializable
open-time meta (the interactive pane's resolved nodeId) and hands it back as
`extra` on rehydrate, so a reload restores a node-proxied pane onto the SAME
node (origin-first) instead of re-routing + duplicate-loading."""
pane = _PANE_JS.read_text(encoding="utf-8")
assert "setPaneMeta(paneId, meta)" in pane, "PaneManager must expose setPaneMeta"
assert "entry.meta = p.meta" in pane, "_persist must include a pane's meta when present"
assert "this.openPane(item.type, item.id, item.meta)" in pane, (
"rehydrate must hand the persisted meta back to the factory as extra"
)
def test_step3_admin_pane_registered_and_manage_mounted() -> None:
"""Step 3: the shell registers the singleton Admin pane (which adopts
#view-admin) and mounts the rail's Manage groups from the admin IA seam."""
@@ -278,22 +352,31 @@ def test_step4_coordinator_pane_registered_and_wired() -> None:
def test_step5_interactive_pane_registered_and_wired() -> None:
"""Step 5b: the shell registers a ws_id-keyed interactive pane over the
NODE-PROXIED transport. Both panes are ES modules the shell IMPORTS (step
5e.0 lifted the coordinator off window too); its node is
derived from the Tier-1 snapshot (nodeForWs) or an open-time hint; the rail
opens it as a pane passing the owning node; and the console loads the shared
interactive stylesheet."""
5e.0 lifted the coordinator off window too). The pane is REHYDRATE-SAFE: on
first activate it RESOLVES its owning node and (re)opens the session there
before streaming (ensureInteractiveNode -> the console's resolveInteractiveNode
origin-first POST /open; the node /events stream 404s on a ws not loaded on
that node, so a reloaded pane can't connect blind), then PERSISTS the resolved
node so a reload restores the pane onto the same node. The rail opens it
passing the owning node; the console loads the shared interactive stylesheet."""
shell = _SHELL_JS.read_text(encoding="utf-8")
assert 'import { createInteractivePane } from "./interactive.js"' in shell, (
"interactive is ESM — the shell imports it (as it now does the coordinator)"
)
assert 'registerType("interactive"' in shell, "shell must register the interactive pane type"
assert "createInteractivePane(this.bodyEl, id, {" in shell, (
"onMount must build the controller into the pane body"
"first activate must build the controller into the pane body"
)
assert "nodeForWs(id)" in shell, (
"the node-proxy target must be derived from Tier-1 (rehydrate-safe)"
# Rehydrate-safety: the node is RESOLVED + the session (re)opened before the
# pane streams, and the resolved node is persisted for the next reload.
assert "ensureInteractiveNode(" in shell, (
"the node-proxy target must be resolved (origin-first open) — rehydrate-safe"
)
assert "function ensureInteractiveNode(" in shell, "shell must define the node resolver/opener"
assert "function nodeForWs(" in shell, "shell must keep the Tier-1 node fallback"
assert "pm.setPaneMeta(pane.id" in shell, (
"the resolved node must be persisted so a reload restores the same node"
)
assert "function nodeForWs(" in shell, "shell must define the node resolver"
# Focus-tracking lifecycle: connect/deactivate/destroy + login re-arm.
for hook in ("this._ctl.connect()", "this._ctl.deactivate()", "this._ctl.destroy()"):
assert hook in shell, f"interactive pane missing lifecycle {hook!r}"
+198 -95
View File
@@ -994,8 +994,15 @@ function _createCoordinator(opts) {
return;
}
onSuccess(res);
window.location.href =
"/coordinator/" + encodeURIComponent(res.data.ws_id);
// Open the new coordinator as an L-shell PANE (the renovation: sessions
// open as tabs, not full-page nav). Full-page nav stays as the
// shell-absent fallback. The session was just created + is live in the
// console, so the coordinator pane connects straight away (no /open hop).
const pm = window.TS_SHELL && window.TS_SHELL.panes;
if (pm) pm.openPane("coordinator", res.data.ws_id);
else
window.location.href =
"/coordinator/" + encodeURIComponent(res.data.ws_id);
})
.catch(function () {
setBusy(false);
@@ -1028,6 +1035,50 @@ function _setLauncherKind(kind, focus) {
btn.tabIndex = on ? 0 : -1; // roving tabindex — the radiogroup is one tab stop
if (on && focus) btn.focus();
});
_applyLauncherFields();
}
// Reflect the active persona in the shared launcher composer: the task-prompt
// hint, and which option fields are relevant. The node picker is
// interactive-only (coordinators run in the console, not on a compute node);
// its node list appears only under the "Specific node" strategy.
function _applyLauncherFields() {
if (!_homeCoordComposer) return;
const interactive = _launcherKind === "interactive";
_homeCoordComposer.setPlaceholder(
interactive
? "What do you want to work on?"
: "What should this coordinator orchestrate?",
);
_homeCoordComposer.setOptionFieldVisible("node_strategy", interactive);
const specific =
interactive &&
_homeCoordComposer.getOptionValue("node_strategy") === "node";
_homeCoordComposer.setOptionFieldVisible("node_id", specific);
if (specific) _populateLauncherNodes();
}
// Populate the launcher's "Specific node" picker from the live Tier-1 snapshot
// (the SAME clusterState the rail node list reads), excluding the `console`
// pseudo-node (interactive sessions run on compute nodes) and unreachable
// nodes. Re-read on each reveal so a node that just (dis)appeared is current.
function _populateLauncherNodes() {
if (!_homeCoordComposer) return;
const choices = [];
if (clusterState && clusterState.nodes) {
Object.keys(clusterState.nodes)
.filter(function (nid) {
return nid !== "console";
})
.sort()
.forEach(function (nid) {
const n = clusterState.nodes[nid];
if (n && n.reachable === false) return;
const count = n && n.workstreams ? n.workstreams.length : 0;
choices.push({ value: nid, text: nid + " (" + count + " ws)" });
});
}
_homeCoordComposer.setOptionChoices("node_id", choices);
}
function _wireLauncherToggle() {
@@ -1081,10 +1132,23 @@ function _createInteractive(opts) {
const setBusy = opts.setBusy || function () {};
const onSuccess = opts.onSuccess || function () {};
// Node placement (launcher node-strategy picker): "node" pins to the chosen
// node; anything else lets the console pick the least-loaded node ("auto").
// Validate the specific-node choice BEFORE flipping busy so a missing pick
// surfaces inline without a stuck spinner.
let placement = "auto";
if (opts.node_strategy === "node") {
placement = (opts.node_id || "").trim();
if (!placement) {
errEl.textContent = "Choose a node, or switch to Least loaded.";
return;
}
}
errEl.textContent = "";
setBusy(true);
const body = { node_id: "auto" };
const body = { node_id: placement };
if (name) body.name = name;
if (skill) body.skill = skill;
if (model) body.model = model;
@@ -1111,7 +1175,17 @@ function _createInteractive(opts) {
return;
}
onSuccess(res);
if (node) {
// Open the new session as an L-shell PANE (interactive sessions are
// node-proxied; the pane resolves + opens on its node before streaming).
// Full-page nav stays as the shell-absent fallback.
const pm = window.TS_SHELL && window.TS_SHELL.panes;
if (pm) {
// target_node is the just-created live node; pass it as the open-time
// hint so the pane pins there (origin-first) instead of re-routing. A
// missing node (server-contract drift) still recovers via the pane's
// rendezvous fallback.
pm.openPane("interactive", wsId, { nodeId: node || null });
} else if (node) {
window.location.href =
"/node/" +
encodeURIComponent(node) +
@@ -1341,8 +1415,21 @@ function _mountHomeCoordComposer() {
if (v.skill) bits.push(v.skill);
if (v.model) bits.push(v.model);
if (v.judge_model) bits.push("judge: " + v.judge_model);
// Node placement is interactive-only; surface it only when a specific
// node is pinned (the "Least loaded" default needs no summary line).
if (
_launcherKind === "interactive" &&
v.node_strategy === "node" &&
v.node_id
)
bits.push("node: " + v.node_id);
return bits.join(" \u00b7 ");
},
// Re-evaluate the interactive node-picker visibility whenever a field
// changes (the node list appears only under the "Specific node" strategy).
onChange: function () {
_applyLauncherFields();
},
fields: [
{
id: "name",
@@ -1373,6 +1460,27 @@ function _mountHomeCoordComposer() {
// session model — see IntentJudge.__init__).
choices: [{ value: "", text: "Default model" }],
},
// Node placement — INTERACTIVE persona only (coordinators run in the
// console, not on a compute node). _applyLauncherFields shows/hides
// these per persona. "auto" → the console picks the least-loaded node;
// "node" → reveal the live node picker below + pin to the chosen node.
{
id: "node_strategy",
label: "Node",
type: "select",
choices: [
{ value: "auto", text: "Least loaded" },
{ value: "node", text: "Specific node…" },
],
},
{
id: "node_id",
label: "Pick node",
type: "select",
// First option is the placeholder; _populateLauncherNodes appends the
// live reachable compute nodes (excluding the console pseudo-node).
choices: [{ value: "", text: "Select a node" }],
},
],
},
attachments: {
@@ -1547,6 +1655,9 @@ function submitHomeCoord(textFromComposer) {
);
return;
}
// Node placement from the launcher's node-strategy picker (interactive-only).
shared.node_strategy = opts.node_strategy || "auto";
shared.node_id = (opts.node_id || "").trim();
_createInteractive(shared);
} else {
shared.files = files;
@@ -1639,93 +1750,6 @@ function loadSavedCoordinators() {
});
}
// Restore a saved interactive session from the console onto a compute node.
// Origin-FIRST: rehydrate on the session's origin node (the saved DTO's
// node_id, stamped at create) whenever it is still reachable. This keeps
// node affinity and, crucially, REUSES a session already live on its origin
// instead of loading a duplicate copy elsewhere; the interactive pane talks
// directly to /node/{id} for every verb, so the load-node and the pane-node
// must be the same node (no split-brain). Only when the origin is gone
// (POST /open 404 = node not in registry / 502 = unreachable) do we re-home
// onto a fresh rendezvous node via GET /v1/api/route (the router skips dead
// nodes; persistence is shared ws_id-keyed Postgres, so any live node is
// state-safe). Rehydrating is required, not just node selection: the
// per-pane SSE /events 404s on a not-loaded ws and /history alone will not
// load it. Mirrors the coordinator open-before-navigate and the standalone
// dashboardResumeSession.
function restoreInteractiveSession(wsId, originNodeId, rowEl) {
const pm = window.TS_SHELL && window.TS_SHELL.panes;
if (rowEl) rowEl.classList.add("is-busy");
const clearBusy = function () {
if (rowEl) rowEl.classList.remove("is-busy");
};
const failToast = function (status) {
clearBusy();
if (status === 429)
showToast("Node at capacity; close a session and retry");
else if (status === 403) showToast("Not permitted to restore this session");
else showToast("Failed to restore session (" + status + ")");
};
// Rehydrate on a specific node, then pin the pane to that SAME node.
// Resolves true on success, else the failing HTTP status.
const openOn = function (nodeId) {
return authFetch(
"/node/" +
encodeURIComponent(nodeId) +
"/v1/api/workstreams/" +
encodeURIComponent(wsId) +
"/open",
{ method: "POST" },
).then(function (r) {
if (!r.ok) return r.status;
clearBusy();
if (pm) pm.openPane("interactive", wsId, { nodeId: nodeId });
return true;
});
};
// Re-home onto a live rendezvous node (router skips the dead origin) when
// there is no origin or the origin node is gone.
const routeFallback = function () {
return authFetch("/v1/api/route?ws_id=" + encodeURIComponent(wsId))
.then(function (r) {
if (!r.ok) {
clearBusy();
showToast(
r.status === 503
? "No nodes available to restore this session"
: "Could not locate session node (" + r.status + ")",
);
return null;
}
return r.json();
})
.then(function (route) {
if (!route) return;
if (!route.node_id) {
clearBusy();
showToast("Could not locate session node");
return;
}
return openOn(route.node_id).then(function (res) {
if (res !== true) failToast(res);
});
});
};
// Origin first; 404/502 (origin gone) -> rendezvous fallback. Capacity
// (429) / permission (403) are surfaced, NOT silently re-homed.
const flow = originNodeId
? openOn(originNodeId).then(function (res) {
if (res === true) return;
if (res === 404 || res === 502) return routeFallback();
failToast(res);
})
: routeFallback();
flow.catch(function () {
clearBusy();
showToast("Failed to restore session");
});
}
// Saved Coordinators table — same shared createSavedTable as the server UI
// (/shared/cards.js), with a CHILDREN column instead of MSGS and the
// body-keyed (router-proxied) delete. Activation POSTs /open before
@@ -1777,12 +1801,15 @@ const _coordTable = createSavedTable({
const pm = window.TS_SHELL && window.TS_SHELL.panes;
// Interactive sessions live on a compute node and, unlike coordinators,
// have no warm pool: a dormant one must be routed to a live node and
// rehydrated there before its pane can stream (see
// restoreInteractiveSession). Shell-absent falls back to a best-effort
// full-page nav to the origin node, whose detail page rehydrates lazily.
// rehydrated there before its pane can stream. The interactive pane does
// exactly that on first activate (resolveInteractiveNode: origin-first
// POST /open with a rendezvous fallback), so the saved-row click just opens
// the pane with the origin node as the hint. Shell-absent falls back to a
// best-effort full-page nav to the origin node, whose detail page
// rehydrates lazily.
if (s.kind !== "coordinator") {
if (pm) {
restoreInteractiveSession(s.ws_id, s.node_id, rowEl);
pm.openPane("interactive", s.ws_id, { nodeId: s.node_id || null });
} else if (s.node_id) {
window.location.href =
"/node/" +
@@ -1925,6 +1952,82 @@ window.TS_APP.focusLauncher = function () {
if (_homeCoordComposer && typeof _homeCoordComposer.focus === "function")
_homeCoordComposer.focus();
};
// Resolve the cluster node that should host an interactive pane's session AND
// ensure the session is loaded there before the pane streams — the node /events
// stream 404s on a ws not loaded on that node, and /history alone won't load it.
// The shell's interactive pane factory calls this on first activate (fresh open,
// saved-row resume, AND reload-rehydrate all funnel through here).
//
// Origin-FIRST: POST /open on the hint node (the live / origin / just-created
// node) — this REUSES a session already loaded there instead of loading a
// duplicate copy elsewhere, and keeps node affinity (the pane talks directly to
// /node/{id} for every verb, so load-node == pane-node). Only when the hint
// node is gone (404 = not in the registry / 502 = unreachable) or there is no
// hint do we re-home onto a live rendezvous node via GET /v1/api/route (the
// router skips dead nodes; persistence is shared ws_id-keyed Postgres, so any
// live node is state-safe). Capacity (429) / permission (403) surface as an
// error — NOT a silent re-home. Resolves to {nodeId} on success, else {error}.
window.TS_APP.resolveInteractiveNode = function (wsId, hintNodeId) {
const openOn = function (nodeId) {
return authFetch(
"/node/" +
encodeURIComponent(nodeId) +
"/v1/api/workstreams/" +
encodeURIComponent(wsId) +
"/open",
{ method: "POST" },
).then(function (r) {
return r.ok ? { nodeId: nodeId } : { status: r.status };
});
};
const failResult = function (status) {
if (status === 429) {
showToast("Node at capacity; close a session and retry");
return { error: "Node at capacity — close a session and retry." };
}
if (status === 403) {
showToast("Not permitted to open this session");
return { error: "You dont have permission to open this session." };
}
return { error: "Could not open this session (" + status + ")." };
};
const routeFallback = function () {
return authFetch("/v1/api/route?ws_id=" + encodeURIComponent(wsId))
.then(function (r) {
if (!r.ok) {
const msg =
r.status === 503
? "No nodes available to open this session."
: "Could not locate the session node (" + r.status + ").";
showToast(msg);
return { error: msg };
}
return r.json();
})
.then(function (route) {
if (!route) return { error: "Could not locate the session node." };
if (route.error) return route; // route fetch already failed + toasted
if (!route.node_id) {
showToast("Could not locate the session node");
return { error: "Could not locate the session node." };
}
return openOn(route.node_id).then(function (res) {
return res.nodeId ? res : failResult(res.status);
});
});
};
const flow = hintNodeId
? openOn(hintNodeId).then(function (res) {
if (res.nodeId) return res;
// Origin gone -> rendezvous re-home; capacity/permission surface as-is.
if (res.status === 404 || res.status === 502) return routeFallback();
return failResult(res.status);
})
: routeFallback();
return flow.catch(function () {
return { error: "Failed to open this session." };
});
};
window.TS_APP.boot = function () {
history.replaceState({ view: "home" }, "");
initLogin();
+21
View File
@@ -241,6 +241,15 @@
if (this.modelChipEl) this.modelChipEl.textContent = label || "—";
};
// Swap the textarea's idle placeholder — e.g. a launcher that reuses one
// composer across personas updates the task-prompt hint when the persona
// changes. Leaves a busy-state placeholder swap (setBusy) untouched.
Composer.prototype.setPlaceholder = function (text) {
this._idlePlaceholder = text == null ? "" : String(text);
if (this.inputEl && !this._busy)
this.inputEl.placeholder = this._idlePlaceholder;
};
Composer.prototype._buildInput = function (row, opts) {
this.inputEl = document.createElement("textarea");
this.inputEl.className = "composer-input";
@@ -742,6 +751,18 @@
this._refreshOptionsSummary();
};
// Show/hide a single options field (its label + control row). Lets a caller
// reveal a field conditionally — e.g. the launcher shows the node picker only
// for the interactive persona, and the node list only under "Specific node".
// Toggles inline display (robust against the field row's own flex/grid rule,
// which [hidden] alone would lose a specificity battle with).
Composer.prototype.setOptionFieldVisible = function (id, visible) {
var ctrl = this._optionFields && this._optionFields[id];
if (!ctrl) return;
var row = ctrl.closest(".composer-options-field");
if (row) row.style.display = visible ? "" : "none";
};
Composer.prototype._refreshOptionsSummary = function () {
if (!this.optionsSummaryEl) return;
var summaryFn = this._opts.options && this._opts.options.summary;
+25 -5
View File
@@ -135,6 +135,17 @@ export class PaneManager {
pane._glyphEl = el;
}
/** Update a pane's persisted open-time meta (a small serializable hint, e.g.
* the interactive pane's resolved `{nodeId}`) and re-persist immediately, so
* the next reload re-opens the pane with the same hint as `extra`. Generic:
* PaneManager treats meta as opaque; the pane type owns its shape. */
setPaneMeta(paneId, meta) {
const pane = this._panes.get(paneId);
if (!pane) return;
pane.meta = meta;
this._persist();
}
/** Update a tab's label text in place. The shell drives this from Tier-1 so a
* conversational tab tracks its workstream's live NAME instead of freezing at
* the open-time id; pane.title is updated too so a later tab rebuild keeps it. */
@@ -157,8 +168,11 @@ export class PaneManager {
/** Create the pane if absent, then focus it. Creation is auth-gated by the
* type's registered `canOpen` (deny -> no pane); focusing an already-open pane
* is never re-gated. `extra` is an optional open-time hint passed straight to
* the factory (e.g. the interactive pane's `{nodeId}` from a rail click); it is
* NOT persisted, so a factory must be able to re-derive it on rehydrate. */
* the factory (e.g. the interactive pane's `{nodeId}` from a rail click). A
* factory may copy a SERIALIZABLE hint onto `pane.meta` (and refresh it later
* via `setPaneMeta`) to have it persisted and handed back as `extra` on
* rehydrate — the interactive pane does this with its resolved nodeId so a
* reload restores the pane onto the SAME node (no re-route / duplicate load). */
openPane(type, id, extra) {
if (!this._types.has(type)) {
console.warn("PaneManager: unknown pane type", type);
@@ -510,7 +524,12 @@ export class PaneManager {
try {
const order = this._order.map((paneId) => {
const p = this._panes.get(paneId);
return { type: p.type, id: p.rawId };
const entry = { type: p.type, id: p.rawId };
// A pane's serializable open-time hint (e.g. interactive nodeId) rides
// along so rehydrate hands it back as `extra` — only when present, so
// hint-less panes (dashboard/admin/coordinator) persist unchanged.
if (p.meta != null) entry.meta = p.meta;
return entry;
});
sessionStorage.setItem(
this.storageKey,
@@ -539,8 +558,9 @@ export class PaneManager {
if (item && this._types.has(item.type)) {
// Only count it restored if the pane was actually created — an auth-gated
// type (a coordinator pane without scope) returns null, and must not
// suppress the Dashboard fallback into a blank shell.
if (this.openPane(item.type, item.id)) restored = true;
// suppress the Dashboard fallback into a blank shell. `item.meta` (e.g.
// the interactive pane's resolved nodeId) is handed back as `extra`.
if (this.openPane(item.type, item.id, item.meta)) restored = true;
}
}
if (state.active && this._panes.has(state.active)) {
+9
View File
@@ -713,6 +713,15 @@
min-height: 0;
}
/* Transient status line a conversational pane shows before its controller is
built (e.g. an interactive pane resolving + opening its node-proxied session
on first activate). The error variant rides chat.css's .msg.error styling. */
.pane-status {
padding: 16px;
color: var(--fg-dim);
font-size: 13px;
}
/* ===== Dashboard session launcher — persona toggle (coordinator | interactive).
A console-dashboard control; lives here because the L-shell loads shell.css. */
.launcher-personas {
+67 -16
View File
@@ -151,6 +151,28 @@ function stateForWs(wsId) {
return f ? f.ws.state || "idle" : "idle";
}
// Resolve which node an interactive pane's session lives on, ENSURING the
// session is loaded there before the pane streams. This is the one seam that
// 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.
// - 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 });
if (
window.TS_APP &&
typeof window.TS_APP.resolveInteractiveNode === "function"
) {
return window.TS_APP.resolveInteractiveNode(wsId, hint || null);
}
return Promise.resolve({ nodeId: hint || nodeForWs(wsId) || null });
}
// Repaint conversational tabs from Tier-1 in ONE pass: a single findWs per
// stateful tab feeds BOTH the live state glyph (one Tier-1 writer; the pane's
// Tier-2 stream drives its body, not the tab) and the live workstream name.
@@ -364,32 +386,61 @@ async function mountShell() {
? () => window.closeWorkstream(id)
: null,
});
// 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.
if (extra && extra.nodeId) pane.meta = { nodeId: extra.nodeId };
pane.onMount = function () {
// Node-proxy transport only exists in a cluster deployment (the console).
// On a single-node standalone (caps.cluster=false) every session is LOCAL,
// so nodeId stays null → the pane uses base="" (no /node/<id> hop), even
// though the synthesized one-node clusterState would otherwise name a node.
const nodeId = caps.cluster
? (extra && extra.nodeId) || nodeForWs(id)
: null;
this._ctl = createInteractivePane(this.bodyEl, id, {
nodeId,
onClose: () => pm.close(pane.id),
});
// The controller is built LAZILY on first activate — only after the owning
// node is resolved AND the session is (re)opened there (the node /events
// stream 404s on a ws not loaded on its node, so a rehydrated pane can't
// just connect blind). onMount only reserves a status line so a
// not-yet-resolved pane isn't a blank box.
this._statusEl = make("div", "pane-status", "Connecting…");
this.bodyEl.append(this._statusEl);
};
pane.onActivate = function () {
pm.setTabGlyph(pane.id, glyph(stateForWs(id))); // live Tier-1 state glyph
if (!this._ctl) return;
this._ctl.connect(); // idempotent — opens the stream once, re-marks focus
if (!this._loginArmed && window.TS_LOGIN && this._ctl.onLogin) {
this._loginArmed = true;
window.TS_LOGIN.subscribe(this._ctl.onLogin);
if (this._ctl) {
this._ctl.connect(); // built — idempotent re-mark focus
return;
}
if (this._resolving) return; // first-activate resolve already in flight
this._resolving = true;
const hint = (this.meta && this.meta.nodeId) || (extra && extra.nodeId);
ensureInteractiveNode(caps, id, hint).then((res) => {
this._resolving = false;
if (this._closed) return; // pane closed mid-resolve — don't build into a detached body
if (!res || res.error) {
if (this._statusEl) {
this._statusEl.className = "pane-status msg error";
this._statusEl.textContent =
(res && res.error) || "Could not connect to this session.";
}
return;
}
// Pin + persist the resolved node so the next reload reuses it.
this.meta = { nodeId: res.nodeId };
pm.setPaneMeta(pane.id, this.meta);
if (this._statusEl && this._statusEl.parentNode)
this._statusEl.remove();
this._statusEl = null;
this._ctl = createInteractivePane(this.bodyEl, id, {
nodeId: res.nodeId,
onClose: () => pm.close(pane.id),
});
this._ctl.connect();
if (window.TS_LOGIN && this._ctl.onLogin) {
this._loginArmed = true;
window.TS_LOGIN.subscribe(this._ctl.onLogin);
}
});
};
pane.onDeactivate = function () {
if (this._ctl && this._ctl.deactivate) this._ctl.deactivate();
};
pane.onClose = function () {
this._closed = true; // a pending resolve must not build after close
if (this._ctl) {
if (window.TS_LOGIN && this._ctl.onLogin)
window.TS_LOGIN.unsubscribe(this._ctl.onLogin);