mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
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:
+65
-183
@@ -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."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+93
-160
@@ -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"
|
||||
>
|
||||
☾
|
||||
</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"
|
||||
>
|
||||
⚙
|
||||
</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"
|
||||
>
|
||||
⧉
|
||||
☾
|
||||
</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"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user