feat(ui): L-shell step 6 — standalone adopts the L-shell, retire ui/static split-pane

The renovation META-GOAL: a standalone turnstone-server now serves the SAME
capability-parameterised L-shell the console serves (caps {cluster:false,
orchestration:false}), collapsing the console/static vs ui/static fork. No server
change was needed — turnstone/server.py already mounts ui/static at /static; this
changes what ui/static CONTAINS.

ui/static/index.html -> the L-shell skeleton: a hidden #header the shell
relocates (status -> rail, theme/logout -> footer), #main as the Dashboard pane
body (launcher + workstreams table + saved list), a one-panel #view-admin hosting
MCP connections (reusing the #settings-mcp-* table ids), the modals, and the caps
block flipped to {cluster:false, orchestration:false, brandSub:server}. The
split-pane chrome (#tab-bar/#split-root/#split-btn), admin.js/governance.js, and
the separate interactive.js module tag are gone (shell.js imports it).

ui/static/app.js -> a single-node TS_APP/TS_ADMIN/showHome provider (-1417 lines):
- TS_APP.{getClusterState, onRender, bucketByParent, boot}: getClusterState
  synthesizes a one-node cluster from the flat /v1/api/events/global roster;
  boot() is shell-driven (no parse-time auto-run).
- TS_ADMIN: a one-tab Manage IA (Extensions > Connections) whose openTab opens
  the Admin pane + renders the MCP table — the floating settings gear is retired.
- The binary split-pane machinery (layout tree, splitPane/renderLayout, tab bar,
  context menu, tab dropdown, STANDALONE_HOST, createPane, the gear menu) is
  deleted; the keep surfaces (dashboard, global SSE, new-ws modal, MCP
  consent/connections, health/theme/kb) are rewired onto PaneManager + the rail
  (switchTab/renderTabBar/showDashboard become thin shims; sessions open as
  interactive panes).

interactive.js -> the window.InteractivePane bridge is retired (the shell imports
the factory in both deployments; nothing reads the global anymore).

JS guards re-pointed to the L-shell reality (gear/split-pane/window-bridge guards).
126 JS guards green, ruff/mypy clean, node clean. Verified in a headless harness:
the standalone shell builds caps-off (rail = Workspaces + Manage > Connections, no
Cluster), the Dashboard pane adopts #main, TS_APP/TS_ADMIN wired, zero uncaught JS
errors. The owed merge-gate passes (designer both personas + live-backend +
/review) are unchanged.
This commit is contained in:
Patrick Buckley
2026-06-06 21:49:51 -07:00
parent f3a0954b76
commit cc508cf481
5 changed files with 376 additions and 1991 deletions
+65 -183
View File
@@ -35,28 +35,18 @@ def _pane_method_offset(body: str, name: str) -> int:
return m.start()
def test_switch_tab_bootstraps_pane_when_none_exists() -> None:
"""``switchTab`` must create a pane when none exists. A fresh-
loaded interactive UI with no workstreams shows the dashboard
and creates no panes (per ``initWorkstreams``); the user's first
``create`` or ``open`` then calls ``switchTab(newWsId)``. Pre-fix,
the early ``if (!pane) return;`` left switchTab with nowhere to
attach — the chat UI never connected SSE for the freshly-created
workstream, and only a page refresh fixed it. This test guards
against accidentally re-introducing the early-return."""
def test_switch_tab_opens_an_interactive_pane() -> None:
"""In the L-shell ``switchTab`` is a thin shim onto the PaneManager: it
opens/focuses the session as an interactive pane. The split-pane
``createPane`` bootstrap is retired."""
body = _APP_JS.read_text(encoding="utf-8")
start = body.index("function switchTab(wsId) {")
# Bound the search to the function body — switchTab is short.
fn = body[start : start + 2000]
assert "if (!pane) return;" not in fn, (
"switchTab must not early-return when no pane exists — that's "
"the no-chat-after-first-create bug. Bootstrap a pane instead."
)
# Affirmatively check the bootstrap path exists.
assert "createPane(wsId)" in fn, (
"switchTab must call createPane(wsId) to bootstrap the first "
"pane when getFocusedPane returns null"
fn = body[start : start + 400]
assert "openSessionPane(wsId)" in fn, (
"switchTab must delegate to openSessionPane (PaneManager.openPane "
"'interactive'), not the retired createPane bootstrap."
)
assert "createPane" not in body, "the split-pane createPane bootstrap is retired."
def test_tool_error_does_not_overwrite_approval_badge() -> None:
@@ -604,122 +594,54 @@ def test_phase8_no_unsafe_dom_write_in_settings_panel() -> None:
)
def test_phase8_settings_button_in_index_html() -> None:
"""The gear-icon entry-point for the settings menu must remain
in the appbar's actions span. The console proxy IIFE prepends a
node pill to ``header.firstChild`` (turnstone/console/server.py:
202); our button is appended inside ``<span class='appbar-actions'>``
on the right, so they don't collide. Pin both shape constraints
here so a future appbar refactor keeps them disjoint."""
def test_gear_retired_mcp_in_manage_pane() -> None:
"""Step 6: the floating settings gear is retired — no #settings-btn, no
toggle/open/close gear handlers. MCP server connections moved into the
Admin pane's Connections panel (#view-admin), reached via the rail's
Manage > Connections row (the TS_ADMIN seam)."""
index = _INDEX_HTML.read_text(encoding="utf-8")
app = _APP_JS.read_text(encoding="utf-8")
assert 'id="settings-btn"' not in index, "the floating settings gear is retired."
assert "toggleSettingsMenu" not in app, "the gear dropdown handlers are retired."
assert 'id="view-admin"' in index and 'id="settings-mcp-table"' in index, (
"MCP connections render into the Admin pane's #view-admin panel."
)
assert "window.TS_ADMIN.openTab = function" in app and '"connections"' in app, (
"the Manage > Connections row opens the MCP panel via the TS_ADMIN seam."
)
def test_dashboard_is_the_main_pane_body() -> None:
"""In the L-shell the dashboard is the Dashboard pane's body (#main) — the
shell adopts #main — not a floating overlay. It holds the launcher + the
workstreams table and is not a modal."""
body = _INDEX_HTML.read_text(encoding="utf-8")
assert 'id="settings-btn"' in body, (
"index.html must keep the #settings-btn — onclick handlers "
"and the consent badge target it by id."
)
assert 'onclick="toggleSettingsMenu(this)"' in body, (
"settings-btn must wire onclick=toggleSettingsMenu(this) — "
"the gear opens a dropdown with MCP connections + Logout; "
"losing the binding leaves the menu unreachable."
)
# The button must live inside <span class="appbar-actions"> so the
# console proxy's header.insertBefore(pill, header.firstChild)
# leaves it untouched.
actions_open = body.index('class="appbar-actions"')
actions_close = body.index("</span>", actions_open)
assert 'id="settings-btn"' in body[actions_open:actions_close], (
"settings-btn must be inside <span class='appbar-actions'> "
"so the console proxy's firstChild prepend doesn't shift it."
assert 'id="main"' in body, "the dashboard content lives in #main (the Dashboard pane body)."
start = body.index('id="main"')
chunk = body[start : start + 4000]
assert 'id="dashboard-input"' in chunk and 'id="dash-ws-table"' in chunk, (
"#main must hold the new-session launcher + the workstreams table."
)
assert 'class="dashboard-overlay"' not in body, "the fixed dashboard overlay is retired."
def test_settings_menu_handlers_defined() -> None:
"""The gear-icon dropdown exposes a toggle/open/close trio that the
inline ``onclick="toggleSettingsMenu(this)"`` in index.html depends
on, plus the menu items themselves must wire to existing entry
points (``openSettingsPanel`` for MCP connections, ``logout`` for
sign-out). Pin all four so a rename or deletion fails loudly here
instead of silently leaving the gear's menu broken or wired to a
stale function."""
body = _APP_JS.read_text(encoding="utf-8")
for name in [
"function toggleSettingsMenu",
"function openSettingsMenu",
"function closeSettingsMenu",
]:
assert name in body, f"Missing required handler: {name}"
# Bound to the settings-menu region so we don't accidentally match
# an unrelated openSettingsPanel/logout call elsewhere in the file.
start = body.index("function openSettingsMenu(")
end = body.index("function closeSettingsMenu(", start)
section = body[start:end]
assert "openSettingsPanel()" in section, (
"Settings menu's MCP-connections item must call openSettingsPanel() "
"— otherwise the existing settings overlay is unreachable from the "
"new dropdown."
)
assert "logout()" in section, (
"Settings menu's Logout item must call logout() — that's the "
"shared auth.js entry point that clears the cookie + session state."
)
def test_dashboard_overlay_is_region_not_dialog() -> None:
"""The dashboard overlay must be role='region' (not role='dialog' +
aria-modal='true'). The role downgrade is what allows ui-header to
stay interactive while the dashboard is open — see the comment at
showDashboard() in app.js. A revert to role='dialog' + aria-modal
would re-trap focus and break the gear/theme buttons + the console
proxy's node-picker pill while the dashboard is open."""
def test_mcp_connections_panel_and_revoke_modal_in_index_html() -> None:
"""MCP connections moved from the floating #settings-overlay into the Admin
pane's Connections panel (#view-admin), reusing the same #settings-mcp-*
table ids so the render code is unchanged. The revoke modal stays."""
body = _INDEX_HTML.read_text(encoding="utf-8")
idx = body.index('id="dashboard"')
# Bound to ~600 chars after the tag so we only check this element's
# attributes — same shape as test_phase8_settings_modal_in_index_html.
assert 'id="settings-overlay"' not in body, "the floating MCP settings overlay is retired."
assert 'id="view-admin"' in body, "the Admin pane host (#view-admin) must exist."
va = body.index('id="view-admin"')
panel = body[va : va + 1500]
assert 'id="settings-mcp-table"' in panel and 'id="settings-mcp-tbody"' in panel, (
"the MCP table (reused ids) must live inside #view-admin."
)
idx = body.index('id="revoke-mcp-overlay"')
chunk = body[idx : idx + 600]
assert 'role="region"' in chunk, (
"dashboard must be role='region' — see showDashboard() comment."
assert 'role="dialog"' in chunk and 'aria-modal="true"' in chunk, (
"revoke-mcp-overlay stays a modal dialog."
)
assert "aria-modal" not in chunk, (
"dashboard must NOT be aria-modal — re-trapping focus breaks "
"the appbar's interactive controls (theme toggle, settings menu, "
"proxy node-picker pill) while the dashboard is open."
)
def test_close_settings_menu_resets_aria() -> None:
"""closeSettingsMenu must reset aria-expanded='false' AND remove
aria-controls from the gear trigger. Without the reset the gear
keeps reporting 'expanded' to assistive tech after the menu closes;
without the removal aria-controls points at a dead DOM id."""
body = _APP_JS.read_text(encoding="utf-8")
start = body.index("function closeSettingsMenu(")
# Bound to ~600 chars so we don't catch unrelated handlers.
section = body[start : start + 600]
assert 'setAttribute("aria-expanded", "false")' in section, (
"closeSettingsMenu must set aria-expanded='false' on the gear."
)
assert 'removeAttribute("aria-controls")' in section, (
"closeSettingsMenu must remove aria-controls from the gear."
)
def test_phase8_settings_modal_in_index_html() -> None:
"""Both the settings overlay and the revoke-confirmation overlay
must remain in the modal area. The Escape-key deferral list in
app.js targets these ids, so removing them silently breaks the
handler chain."""
body = _INDEX_HTML.read_text(encoding="utf-8")
assert 'id="settings-overlay"' in body
assert 'id="revoke-mcp-overlay"' in body
# Each overlay must have role="dialog" + aria-modal="true" so
# screen readers and the existing modal-deferral handlers can
# treat them like the rest of the modal stack.
for overlay_id in ("settings-overlay", "revoke-mcp-overlay"):
idx = body.index(f'id="{overlay_id}"')
# Bound to ~600 chars after the open tag so we only check this
# overlay's attributes.
chunk = body[idx : idx + 600]
assert 'role="dialog"' in chunk, f"{overlay_id} missing role=dialog"
assert 'aria-modal="true"' in chunk, f"{overlay_id} missing aria-modal=true"
def test_phase8_xss_safe_render_in_build_mcp_error_embed() -> None:
@@ -1236,70 +1158,30 @@ def test_redact_api_keys_runtime_smoke() -> None:
)
def test_beforeunload_closes_sse_connections() -> None:
"""Pin the multi-pane refresh mitigation: the ``beforeunload``
handler closes ``globalEvtSource`` and every pane's ``evtSource``
before the page navigates away, freeing the browser's HTTP/1.1
6-connection-per-host budget so the refresh document fetch can
open a slot. Without this handler, refresh at MAX_PANES hangs
in Chrome and leaves Firefox stuck on the loading state.
This is a tactical mitigation; the real fix is the console SSE
fan-in (one connection per page). Pinning the handler here
prevents a future refactor from silently dropping it before
the fan-in lands."""
def test_beforeunload_closes_global_sse() -> None:
"""The ``beforeunload`` handler closes ``globalEvtSource`` before navigation.
In the L-shell the per-pane streams are owned by PaneManager/interactive.js,
so this handler only owns the global Tier-1 stream."""
body = _APP_JS.read_text(encoding="utf-8")
handler = _slice_listener_body(body, "beforeunload")
assert handler is not None, "beforeunload handler missing — refresh at MAX_PANES will hang."
assert "globalEvtSource" in handler, "beforeunload handler must reference globalEvtSource."
assert ".close()" in handler, "beforeunload handler must close at least one connection."
assert "panes" in handler, "beforeunload handler must reference the panes registry."
# Either bare `evtSource.close()` or `disconnectSSE()` (which closes +
# clears pending timers) is acceptable for per-pane teardown — pin the
# behaviour, not the implementation.
assert ".disconnectSSE()" in handler or ".evtSource.close()" in handler, (
"beforeunload handler must tear down per-pane SSEs "
"(`Pane.disconnectSSE()` is preferred — it also clears pending timers)."
assert handler is not None, "beforeunload handler missing."
assert "globalEvtSource" in handler and ".close()" in handler, (
"beforeunload must close the global Tier-1 stream."
)
def test_dead_sse_defensive_reconnect_registered() -> None:
"""Pin the defensive reconnect: visibilitychange + focus listeners
must re-establish SSE connections that were closed by beforeunload
when the navigation didn't actually complete (e.g. another
beforeunload handler's "Are you sure?" dialog dismissed). Without
these, the page stays alive with dead SSEs and no automatic
recovery — UI silently stops receiving events.
The two listeners cover different cancellation shapes: visibilitychange
catches hide/show; focus catches modal/browser-UI/OS-level focus loss
and return. Both call the same idempotent reconnect helper."""
"""visibilitychange + focus listeners re-open the global Tier-1 stream if it
was closed (e.g. a cancelled navigation). In the L-shell per-pane streams
are PaneManager's, so the helper only revives the global SSE."""
body = _APP_JS.read_text(encoding="utf-8")
# Both event registrations must be present.
assert 'addEventListener("visibilitychange"' in body, (
"visibilitychange listener missing — defensive reconnect won't fire on tab return."
)
assert 'addEventListener("focus"' in body, (
"focus listener missing — defensive reconnect won't catch "
"modal-dismissed cancellation paths."
)
# The reconnect helper must inspect EventSource state and call the
# existing connect helpers. Slice the helper's body by walking the
# matching `}` so the assertions are robust to comment growth + body
# reorganisation.
assert 'addEventListener("visibilitychange"' in body
assert 'addEventListener("focus"' in body
helper_body = _slice_function_body(body, "_reconnectDeadSSEs")
assert helper_body is not None, (
"_reconnectDeadSSEs helper missing — reconnect logic must live in "
"a named function the listeners can share."
assert helper_body is not None, "_reconnectDeadSSEs helper missing."
assert "EventSource" in helper_body and "connectGlobalSSE()" in helper_body, (
"_reconnectDeadSSEs must revive the global SSE when closed."
)
assert "EventSource" in helper_body, (
"_reconnectDeadSSEs must inspect EventSource state so live or "
"CONNECTING sockets aren't disrupted."
)
assert "connectGlobalSSE()" in helper_body, (
"_reconnectDeadSSEs must reconnect the global SSE when closed."
)
assert "connectSSE(" in helper_body, "_reconnectDeadSSEs must reconnect dead per-pane SSEs."
# ---------------------------------------------------------------------------
+24 -33
View File
@@ -25,22 +25,19 @@ def _strip_comments(js: str) -> str:
return js
def test_interactive_is_esm_with_window_bridge() -> None:
"""The module is a real ES module (the first legacy pane lifted into one):
it ``export``s the factory for the console shell's ``import`` AND publishes
``window.InteractivePane`` / ``window.createInteractivePane`` so the still
classic standalone ``app.js`` can read the class. Both halves are
load-bearing — drop the export and the console shell can't import it; drop
the window bridge and the standalone shell's ``createPane`` goes undefined."""
def test_interactive_is_esm_imported_by_the_shell() -> None:
"""Real ES module: it ``export``s the factory the shell imports in BOTH
deployments. Step 6 retired the window bridge (no window.InteractivePane)
and the standalone HTML no longer script-tags interactive.js — shell.js
pulls it via ``import``."""
body = _INTERACTIVE.read_text(encoding="utf-8")
assert "export { Pane as InteractivePane, createInteractivePane };" in body
assert "window.InteractivePane = Pane;" in body
assert "window.createInteractivePane = createInteractivePane;" in body
# And the standalone HTML must load it as a module (not a classic script).
assert "window.InteractivePane = Pane" not in body, (
"the window bridge is retired — the shell imports the factory (ESM)."
)
html = _UI_INDEX.read_text(encoding="utf-8")
assert '<script type="module" src="/shared/interactive.js"></script>' in html, (
"ui/static/index.html must load interactive.js as an ES module — a "
"classic <script src> would choke on the top-level export."
assert "/shared/interactive.js" not in html, (
"the standalone HTML must NOT script-tag interactive.js — shell.js imports it."
)
@@ -137,24 +134,18 @@ def test_host_seam_routes_shell_couplings() -> None:
)
def test_standalone_shell_constructs_via_window_bridge() -> None:
"""The standalone ``app.js`` shell builds panes through
``window.InteractivePane`` with its ``STANDALONE_HOST`` adapter, and keeps
the focused-pane stream-error recovery (``refetchWorkstreamsAndReassign``)
that moved out of the class."""
def test_standalone_opens_sessions_via_the_shell_pane_manager() -> None:
"""Step 6 retired the standalone's local split-pane construction: app.js no
longer builds panes via window.InteractivePane / STANDALONE_HOST. Sessions
open through the shared shell's PaneManager — openSessionPane delegates to
openPane('interactive', wsId)."""
app = _APP.read_text(encoding="utf-8")
assert "new window.InteractivePane(wsId, { host: STANDALONE_HOST })" in app
assert "const STANDALONE_HOST = {" in app
assert "function refetchWorkstreamsAndReassign(focusedPane) {" in app
# The host adapter must implement every seam the Pane calls.
for method in (
"getWsName(wsId)",
"isFocused(pane)",
"onStreamError(pane)",
"warningTarget()",
"onConsentDetected(server)",
):
assert method in app, f"STANDALONE_HOST missing {method!r}"
# The class itself no longer carries the split-pane reassign method.
inter = _INTERACTIVE.read_text(encoding="utf-8")
assert "_refetchWorkstreamsAndReassign" not in inter
assert "STANDALONE_HOST" not in app, "the standalone host adapter is retired."
assert "new window.InteractivePane(" not in app, (
"the standalone no longer constructs panes locally."
)
start = app.index("function openSessionPane(wsId)")
fn = app[start : start + 300]
assert 'openPane("interactive", wsId)' in fn, (
"openSessionPane must open the session as a pane via the shell PaneManager."
)
+3 -6
View File
@@ -3303,10 +3303,7 @@ function createInteractivePane(root, wsId, opts) {
}
// --- Shared-module exports -------------------------------------------------
// ES module: the console shell (shared_static/shell.js) imports the factory
// directly. The window assignments bridge the still-classic standalone
// app.js shell (which constructs panes via window.InteractivePane) — the same
// interop seam as window.createCoordinatorPane, plus the modern export.
window.InteractivePane = Pane;
window.createInteractivePane = createInteractivePane;
// ES module: the shell (shared_static/shell.js) imports the factory directly in
// BOTH deployments (console + standalone), so there is no window bridge — step 6
// retired the classic standalone app.js path that read window.InteractivePane.
export { Pane as InteractivePane, createInteractivePane };
+191 -1609
View File
File diff suppressed because it is too large Load Diff
+93 -160
View File
@@ -15,79 +15,61 @@
<link rel="stylesheet" href="/shared/chat.css" />
<link rel="stylesheet" href="/shared/conversation.css" />
<link rel="stylesheet" href="/shared/cards.css" />
<link rel="stylesheet" href="/shared/katex-0.17.0/katex.min.css" />
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/shared/shell.css" />
<link rel="stylesheet" href="/shared/interactive.css" />
<link rel="stylesheet" href="/shared/katex-0.17.0/katex.min.css" />
</head>
<body>
<div id="ui-header" class="appbar">
<h1 class="appbar-title">turnstone</h1>
<span
id="mcp-status"
class="appbar-status"
role="status"
aria-live="polite"
></span>
<!-- The L-shell (shell.js) reparents these into the rail (status) + footer
(theme/logout) and hides #header. The standalone server has no node
proxy and no full admin panel, so there is no #admin-btn here; the rail's
Manage section opens the (single) Connections pane via the TS_ADMIN seam. -->
<div id="header" style="display: none">
<h1>
<a
href="#"
id="header-home-link"
onclick="
showHome();
return false;
"
aria-label="Home (dashboard)"
>turnstone</a
>
</h1>
<span id="status-bar" role="status" aria-live="polite"></span>
<span id="mcp-status" role="status" aria-live="polite"></span>
<span
id="health-indicator"
class="health-ok appbar-status"
class="health-ok"
role="status"
aria-live="polite"
aria-atomic="true"
></span>
<span class="header-spacer appbar-spacer"></span>
<span class="appbar-actions">
<button
id="theme-toggle"
class="header-btn btn"
onclick="toggleTheme()"
aria-label="Toggle light/dark theme"
title="Switch to light theme"
>
&#9790;
</button>
<button
id="settings-btn"
class="header-btn btn"
type="button"
onclick="toggleSettingsMenu(this)"
aria-haspopup="menu"
aria-expanded="false"
aria-label="Settings"
title="Settings"
>
&#9881;
</button>
</span>
</div>
<div id="tab-bar" role="toolbar" aria-label="Workstreams">
<div id="tab-list" role="tablist"></div>
<button
id="new-tab-btn"
onclick="newWorkstream()"
title="New workstream (Ctrl+T)"
aria-label="New workstream"
aria-keyshortcuts="Control+t"
id="logout-btn"
class="header-btn"
onclick="logout()"
style="display: none"
>
+
logout
</button>
<button
id="split-btn"
onclick="splitFocusedPane()"
title="Split pane (Ctrl+\)"
aria-label="Split pane"
aria-keyshortcuts="Control+Backslash"
id="theme-toggle"
class="header-btn"
onclick="toggleTheme()"
aria-label="Toggle light/dark theme"
title="Switch to light theme"
>
&#x29C9;
&#9790;
</button>
</div>
<div
id="dashboard"
class="dashboard-overlay"
role="region"
aria-label="Dashboard"
>
<!-- #main becomes the Dashboard pane body (the L-shell's default pane). It
holds the new-session launcher, the live workstreams table, and the saved
list — the standalone's home surface. -->
<div id="main">
<div class="dashboard-content">
<div class="dashboard-composer" id="dashboard-composer">
<textarea
@@ -281,7 +263,46 @@
</div>
</div>
<div id="split-root"></div>
<!-- The Admin pane body. The standalone has no console-style admin panel;
its one Manage surface is MCP server connections, reusing the same table
ids the old settings overlay used so the render code is unchanged. -->
<div id="view-admin" style="display: none">
<div class="admin-content">
<section
id="view-conn"
class="admin-tabpanel"
role="tabpanel"
aria-label="MCP server connections"
>
<div class="admin-section-head">
<h2>MCP server connections</h2>
</div>
<div id="settings-mcp-loading" style="display: none">Loading...</div>
<div id="settings-mcp-empty" style="display: none">
No MCP server consents yet. Tools that need OAuth will show a
"Connect" button when used.
</div>
<table id="settings-mcp-table" style="display: none">
<thead>
<tr>
<th>Server</th>
<th>Scopes</th>
<th>Issued</th>
<th>Last refreshed</th>
<th></th>
</tr>
</thead>
<tbody id="settings-mcp-tbody"></tbody>
</table>
<div
id="settings-mcp-error"
role="alert"
aria-live="polite"
style="display: none"
></div>
</section>
</div>
</div>
<!-- New workstream modal -->
<div
@@ -405,55 +426,7 @@
</div>
</div>
<!-- Settings: MCP server connections -->
<div
id="settings-overlay"
style="display: none"
role="dialog"
aria-modal="true"
aria-labelledby="settings-heading"
>
<div id="settings-box">
<div id="settings-header">
<h3 id="settings-heading">MCP server connections</h3>
<button
type="button"
id="settings-close-btn"
onclick="closeSettingsPanel()"
aria-label="Close settings"
>
&#10005;
</button>
</div>
<div id="settings-body">
<div id="settings-mcp-loading" style="display: none">Loading...</div>
<div id="settings-mcp-empty" style="display: none">
No MCP server consents yet. Tools that need OAuth will show a
"Connect" button when used.
</div>
<table id="settings-mcp-table" style="display: none">
<thead>
<tr>
<th>Server</th>
<th>Scopes</th>
<th>Issued</th>
<th>Last refreshed</th>
<th></th>
</tr>
</thead>
<tbody id="settings-mcp-tbody"></tbody>
</table>
<div
id="settings-mcp-error"
role="alert"
aria-live="polite"
style="display: none"
></div>
</div>
</div>
</div>
<!-- Revoke confirmation modal (matches delete-ws pattern) -->
<!-- Revoke MCP connection confirmation modal -->
<div
id="revoke-mcp-overlay"
style="display: none"
@@ -510,27 +483,24 @@
<div id="toast" role="status" aria-live="polite"></div>
<script>
window.TURNSTONE_AUTH_TITLE = "turnstone";
// L-shell capability flags — a standalone turnstone-server is a single
// node with no cluster front-door and no console-local orchestration, so
// both are off (the rail's Cluster section is hidden; coordinator panes
// are never registered). Manage stays on, fed by the app.js TS_ADMIN
// seam (one Connections tab).
window.TURNSTONE_SHELL_CAPS = {
cluster: false,
orchestration: false,
brandSub: "server",
};
window.TURNSTONE_KB_SHORTCUTS = [
{
title: "Workstreams",
keys: [
{
desc: "Toggle dashboard",
badge: '<span class="kb-key">Ctrl+D</span>',
},
{
desc: "New workstream",
badge: '<span class="kb-key">Ctrl+T</span>',
},
{
desc: "Close workstream",
badge: '<span class="kb-key">Ctrl+W</span>',
},
{
desc: "Switch to tab 1\u20139",
badge:
'<span class="kb-key">Ctrl+1</span>\u2026<span class="kb-key">9</span>',
},
{
desc: "Refresh title",
badge: '<span class="kb-key">Ctrl+Shift+R</span>',
@@ -543,32 +513,6 @@
desc: "Fork workstream",
badge: '<span class="kb-key">Ctrl+Shift+F</span>',
},
{
desc: "Delete workstream",
badge: '<span class="kb-key">Ctrl+Shift+X</span>',
},
],
},
{
title: "Split panes",
keys: [
{
desc: "Split right",
badge: '<span class="kb-key">Ctrl+\\</span>',
},
{
desc: "Split down",
badge: '<span class="kb-key">Ctrl+Shift+\\</span>',
},
{
desc: "Close pane",
badge: '<span class="kb-key">Ctrl+Shift+W</span>',
},
{
desc: "Cycle pane focus",
badge:
'<span class="kb-key">Ctrl+Alt+\u2190</span> <span class="kb-key">\u2192</span>',
},
],
},
{
@@ -604,20 +548,6 @@
},
],
},
{
title: "Navigation",
keys: [
{
desc: "Navigate table rows",
badge:
'<span class="kb-key">\u2191</span> <span class="kb-key">\u2193</span>',
},
{
desc: "Close dashboard",
badge: '<span class="kb-key">Esc</span>',
},
],
},
{
title: "General",
keys: [
@@ -639,7 +569,10 @@
<script src="/shared/katex-0.17.0/katex.min.js"></script>
<script src="/shared/hljs-11.11.1/highlight.min.js"></script>
<script src="/shared/renderer.js"></script>
<script type="module" src="/shared/interactive.js"></script>
<!-- The shell module imports the interactive pane (a /shared ES module), so
it is not <script>-tagged here. app.js is classic and defines the
window.TS_APP / TS_ADMIN seams the deferred shell reads after it. -->
<script src="/static/app.js"></script>
<script type="module" src="/shared/shell.js"></script>
</body>
</html>