diff --git a/tests/test_shell_js.py b/tests/test_shell_js.py index 4072fe94..6b15088a 100644 --- a/tests/test_shell_js.py +++ b/tests/test_shell_js.py @@ -391,3 +391,33 @@ def test_step7_tab_menu_css_promoted_shared() -> None: assert "color-mix(in oklab, var(--err)" in css, ( "the destructive item must use the DS --err token (the translation happened)" ) + + +def test_step7_live_tab_state_glyphs() -> None: + """Step 7 #2: conversational tabs show LIVE state glyphs driven by Tier-1 — + the SAME source + builder the rail uses (one writer; the pane's Tier-2 stream + drives its body, not the tab glyph), so tab and rail agree and the glyph never + sits stale at an open-time placeholder. The coord/int static placeholders are + retired for a `stateful` flag.""" + rail = _RAIL_JS.read_text(encoding="utf-8") + assert "export function glyph(" in rail, ( + "rail must export its state-glyph builder so the tab reuses it (single source)" + ) + pane = _PANE_JS.read_text(encoding="utf-8") + assert "setTabGlyph(paneId, el)" in pane and "statefulTabs()" in pane, ( + "PaneManager must expose the generic glyph setter + the stateful-tab list" + ) + assert "this.stateful" in pane, "ShellPane must carry the stateful flag" + shell = _SHELL_JS.read_text(encoding="utf-8") + assert 'import { mountRail, mountManage, glyph } from "./rail.js"' in shell, ( + "the shell must import the rail's glyph builder (one source for tab + rail)" + ) + assert "function stateForWs(" in shell and "function paintConvTabGlyphs(" in shell + assert "window.TS_APP.onRender" in shell and "paintConvTabGlyphs(pm)" in shell, ( + "tab glyphs must repaint on every Tier-1 render (live, not stale)" + ) + assert shell.count("stateful: true") == 2, ( + "the coordinator + interactive panes are stateful (their static placeholders retired)" + ) + css = _SHELL_CSS.read_text(encoding="utf-8") + assert ".tab .tab-glyph" in css, "the tab-glyph spacing rule must apply to static + live glyphs" diff --git a/turnstone/shared_static/pane.js b/turnstone/shared_static/pane.js index 40316a3d..c7f2943b 100644 --- a/turnstone/shared_static/pane.js +++ b/turnstone/shared_static/pane.js @@ -31,7 +31,8 @@ export class ShellPane { this.rawId = opts.id != null ? opts.id : null; // ws_id for keyed panes, null for singletons this.id = this.rawId == null ? this.type : this.type + ":" + this.rawId; this.title = opts.title || this.type || ""; - this.glyph = opts.glyph || null; // a single char shown in the tab (e.g. "◇"); state glyphs use ui-base .ui-glyph-* + this.glyph = opts.glyph || null; // a single static char shown in the tab (e.g. "◇"); stateful panes use a live .ui-glyph-* instead + this.stateful = opts.stateful || false; // conversational panes: tab glyph tracks live Tier-1 state (set via setTabGlyph) this.closable = opts.closable !== false; // dashboard is not closable // DOM — created and owned by the PaneManager on mount: this.el = null; //
@@ -109,6 +110,29 @@ export class PaneManager { this._activeSubs.push(cb); } + /** Replace a tab's leading state glyph with `el` (a built glyph span). The + * shell drives this from Tier-1 so a conversational tab shows live shape+colour + * state — generic here (PaneManager owns no glyph vocabulary; the shell passes + * the element, reusing the rail's builder so tab and rail render identically). */ + setTabGlyph(paneId, el) { + const pane = this._panes.get(paneId); + if (!pane || !pane.tabEl || !el) return; + el.classList.add("tab-glyph"); // tab spacing (margin); colour stays the el's own + if (pane._glyphEl && pane._glyphEl.parentNode === pane.tabEl) + pane._glyphEl.replaceWith(el); + else pane.tabEl.insertBefore(el, pane.tabEl.firstChild); + pane._glyphEl = el; + } + + /** Open panes whose tab shows live Tier-1 state — `{id, rawId}[]` (the shell + * repaints these on every Tier-1 render). */ + statefulTabs() { + const out = []; + for (const p of this._panes.values()) + if (p.stateful && p.rawId != null) out.push({ id: p.id, rawId: p.rawId }); + return out; + } + /** Create the pane if absent, then focus it. Auth/cap gating is the caller's. * `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, @@ -253,11 +277,15 @@ export class PaneManager { tab.setAttribute("role", "tab"); tab.setAttribute("aria-controls", "pane-" + cssId(pane.id)); if (pane.glyph) { + // Static decorative char (Dashboard ◇ / Admin ⚙). Stateful panes get NO + // static glyph — the shell paints a live .ui-glyph onto the slot via + // setTabGlyph (on activate + every Tier-1 render). const g = document.createElement("span"); - g.className = "glyph"; + g.className = "glyph tab-glyph"; g.setAttribute("aria-hidden", "true"); // decorative glyph, not read as content g.textContent = pane.glyph; tab.append(g); + pane._glyphEl = g; } tab.append(document.createTextNode(pane.title)); // Tab-action menu (step 7): a pane that exposes `tabMenu()` gets a caret to diff --git a/turnstone/shared_static/rail.js b/turnstone/shared_static/rail.js index 24944377..afa82199 100644 --- a/turnstone/shared_static/rail.js +++ b/turnstone/shared_static/rail.js @@ -27,8 +27,10 @@ const STATE_LABEL = { }; /** A decorative shape+colour state glyph (the row/pill also carries the state - * in text/aria, so the glyph itself is aria-hidden). */ -function glyph(state) { + * in text/aria, so the glyph itself is aria-hidden). Exported so the shell can + * paint the SAME glyph onto a conversational tab from the SAME Tier-1 source — + * rail row and tab stay consistent, one writer (no Tier-2 tab-glyph race). */ +export function glyph(state) { const s = GLYPH[state] ? state : "idle"; const el = document.createElement("span"); el.className = "ui-glyph ui-glyph-" + s; diff --git a/turnstone/shared_static/shell.css b/turnstone/shared_static/shell.css index 67c8d5ce..f943dc24 100644 --- a/turnstone/shared_static/shell.css +++ b/turnstone/shared_static/shell.css @@ -411,11 +411,17 @@ background: none; font-family: var(--font-ui); } -.tab .glyph { +/* Tab glyph spacing — applies to BOTH the static decorative char (Dashboard / + Admin, via .glyph) and the live state glyph (.ui-glyph-* on conversational + tabs). The static glyph is dim --ink-4; the live glyph keeps its own + .ui-glyph-* state colour, so it is NOT given a colour here. */ +.tab .tab-glyph { margin-right: 6px; - color: var(--ink-4); font-size: 11px; } +.tab .glyph { + color: var(--ink-4); +} .tab:hover { background: var(--panel-2); } @@ -424,9 +430,8 @@ border-color: var(--hair-2); color: var(--ink); } -/* Active tab's glyph brightens so an open session's `○` placeholder doesn't read - permanently "idle" beside its live (e.g. running ●) rail row. Step 7 makes - tab glyphs live state; this is the interim hint. */ +/* Active tab's STATIC glyph (Dashboard / Admin) brightens a touch; live state + glyphs on conversational tabs keep their .ui-glyph-* state colour instead. */ .tab.active .glyph { color: var(--ink-2); } diff --git a/turnstone/shared_static/shell.js b/turnstone/shared_static/shell.js index 8b99221a..4281ecf8 100644 --- a/turnstone/shared_static/shell.js +++ b/turnstone/shared_static/shell.js @@ -20,7 +20,7 @@ ========================================================================== */ import { PaneManager, ShellPane } from "./pane.js"; -import { mountRail, mountManage } from "./rail.js"; +import { mountRail, mountManage, glyph } from "./rail.js"; // The interactive pane is a real ES module beside us in /shared (step 5a) — the // shell imports it directly, and it exists in every deployment. The coordinator // pane lives at an absolute /static path that only the CONSOLE serves, so it is @@ -155,6 +155,36 @@ function nodeForWs(wsId) { // 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 live state of a workstream from the Tier-1 snapshot (the SAME source the +// rail reads) — so a conversational tab's state glyph stays consistent with its +// rail row and updates live rather than sitting at an open-time placeholder. +function stateForWs(wsId) { + try { + const cs = + window.TS_APP && + window.TS_APP.getClusterState && + window.TS_APP.getClusterState(); + if (cs && cs.nodes) { + for (const nid in cs.nodes) { + for (const ws of cs.nodes[nid].workstreams || []) { + if (ws.id === wsId) return ws.state || "idle"; + } + } + } + } catch (e) { + /* snapshot not ready */ + } + return "idle"; +} + +// Repaint every stateful tab's glyph from Tier-1. Subscribed to the render +// signal (one Tier-1 writer for the tab glyph; the pane's Tier-2 stream drives +// its body, not the tab) and called on activate for the initial paint. +function paintConvTabGlyphs(pm) { + for (const t of pm.statefulTabs()) + pm.setTabGlyph(t.id, glyph(stateForWs(t.rawId))); +} + function convTabMenu(pane, pm, wsId, opts) { opts = opts || {}; const G = window; @@ -294,7 +324,7 @@ async function mountShell() { const pane = new ShellPane({ type: "interactive", title: wsTitle(id), - glyph: "○", + stateful: true, // tab shows live Tier-1 state (no static placeholder) }); pane.tabMenu = () => convTabMenu(pane, pm, id, { @@ -319,6 +349,7 @@ async function mountShell() { }); }; 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) { @@ -352,7 +383,7 @@ async function mountShell() { const pane = new ShellPane({ type: "coordinator", title: wsTitle(id), - glyph: "◆", + stateful: true, // tab shows live Tier-1 state (no static placeholder) }); pane.tabMenu = () => convTabMenu(pane, pm, id, { @@ -366,6 +397,7 @@ async function mountShell() { }); }; pane.onActivate = function () { + pm.setTabGlyph(pane.id, glyph(stateForWs(id))); // live Tier-1 state glyph if (this._ctl && !this._connected) { this._connected = true; this._ctl.connect(); @@ -427,6 +459,14 @@ async function mountShell() { // `pm` lets the rail seed its active marker when a restored Admin pane is open. mountManage(shell.manageSec, pm); + // Live tab state-glyphs (step 7): repaint conversational tabs' state glyphs on + // every Tier-1 render — the SAME source + builder the rail uses, so tab and + // rail agree and the glyph never sits stale at an open-time placeholder. One + // Tier-1 writer for the tab glyph; the pane's Tier-2 stream drives its body. + if (window.TS_APP && typeof window.TS_APP.onRender === "function") { + window.TS_APP.onRender(() => paintConvTabGlyphs(pm)); + } + // Hand off to the legacy boot (login + Tier-1 stream) now that the shell and // its status DOM exist. if (window.TS_APP && typeof window.TS_APP.boot === "function") {