mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52bea510e2 | |||
| 1f700bf72a | |||
| 6b353ea225 | |||
| caafac901e | |||
| 745d6ece59 | |||
| 75b9222a7f | |||
| 70cc8dd97d |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.6.2"
|
||||
version = "1.6.3"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -39,6 +39,12 @@ Console harness (?open=): schedule-create · schedule-edit · model-create ·
|
||||
title instead of passing silently.
|
||||
Governance surfaces (roles/HR/OGP/memory/skill) need fixtures that are not
|
||||
canned yet — add a fixture + driver branch below when you need one.
|
||||
Shell harness (?split=): right (default) · down · three · none — boots the
|
||||
REAL shell.js + pane.js split-view engine over stubbed seams (two demo
|
||||
conversational panes; ?split=three adds the Dashboard cell). + &theme=light.
|
||||
document.title stamps SPLIT-READY-<visible cells> on success and
|
||||
SPLIT-FAILED-<reason> when a driven split was denied — judge the focused
|
||||
cell's top accent bar, the separators, and the .shown tab marker.
|
||||
|
||||
Rebuild after ANY markup change: the dialog blocks are embedded at build
|
||||
time. Assets are symlinked, so CSS/JS edits are live on refresh.
|
||||
@@ -455,6 +461,135 @@ CONSOLE_TEMPLATE = """<!doctype html>
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Shell harness — the SPLIT-VIEW surface. Unlike the ui/console pages (which
|
||||
# embed extracted markup), this one boots the REAL shell.js + pane.js over
|
||||
# stubbed classic seams and drives the split engine via ?split=. Two demo
|
||||
# conversational panes give the cells plausible content; the Dashboard pane
|
||||
# (registered by the shell itself) fills the third cell in ?split=three.
|
||||
# Loud-failure rule: the title stamps SPLIT-READY-<cells> only when the built
|
||||
# state matches the request — a denied/failed split stamps SPLIT-FAILED-<why>.
|
||||
# --------------------------------------------------------------------------
|
||||
SHELL_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>shell livepass</title>
|
||||
<link rel="stylesheet" href="shared/base.css" />
|
||||
<link rel="stylesheet" href="shared/ui-base.css" />
|
||||
<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="static/style.css" />
|
||||
<link rel="stylesheet" href="shared/shell.css" />
|
||||
<link rel="stylesheet" href="shared/interactive.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="header"><div id="status-bar"></div><button id="theme-toggle">☾</button></div>
|
||||
<div id="breadcrumb"></div>
|
||||
<div id="main" style="padding: 18px">
|
||||
<h2 style="margin: 0 0 8px">Dashboard</h2>
|
||||
<p style="color: var(--ink-3)">
|
||||
Launcher + workstreams table live here (livepass stub).
|
||||
</p>
|
||||
</div>
|
||||
<div id="view-admin" style="display: none"></div>
|
||||
<script>
|
||||
window.TURNSTONE_SHELL_CAPS = { cluster: false, brandSub: "console" };
|
||||
window.TS_APP = {
|
||||
boot() {},
|
||||
getClusterState() { return { nodes: {} }; },
|
||||
onRender() {},
|
||||
};
|
||||
window.TS_ADMIN = {};
|
||||
var q = new URLSearchParams(location.search);
|
||||
if (q.get("theme") === "light")
|
||||
document.documentElement.dataset.theme = "light";
|
||||
</script>
|
||||
<script type="module" src="shared/shell.js"></script>
|
||||
<script type="module">
|
||||
const q = new URLSearchParams(location.search);
|
||||
for (let i = 0; i < 100 && !window.TS_SHELL; i++)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
if (!window.TS_SHELL) {
|
||||
document.title = "SPLIT-FAILED-no-shell";
|
||||
} else {
|
||||
sessionStorage.clear();
|
||||
const pm = window.TS_SHELL.panes;
|
||||
const { ShellPane } = await import("./shared/pane.js");
|
||||
const mkConv = (type, title, lines) => {
|
||||
pm.registerType(type, () => {
|
||||
const p = new ShellPane({ type, title });
|
||||
p.tabMenu = () => [
|
||||
{ label: "Close pane", action: () => pm.close(p.id) },
|
||||
];
|
||||
p.onMount = function () {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.cssText =
|
||||
"flex:1;min-height:0;padding:16px;display:flex;flex-direction:column;gap:10px;overflow:auto;";
|
||||
for (const [role, text] of lines) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "msg " + role;
|
||||
d.textContent = text;
|
||||
wrap.append(d);
|
||||
}
|
||||
// Edge-touching opaque chrome — the strip that occluded the
|
||||
// focus ring before the ::after overlay; keeps the bug class
|
||||
// visible in every future pass.
|
||||
const sb = document.createElement("div");
|
||||
sb.className = "ws-status-bar";
|
||||
sb.textContent = "17,418 / 393,216 (4.4%) · max 9 tools";
|
||||
this.bodyEl.append(wrap, sb);
|
||||
};
|
||||
return p;
|
||||
});
|
||||
};
|
||||
mkConv("repro", "repro-flaky-suite", [
|
||||
["user", "Track down the flaky retry in the channel gateway tests."],
|
||||
[
|
||||
"assistant",
|
||||
"Three suspects so far — the debounce window in mcp_client, the " +
|
||||
"circuit-breaker reset, and the socket-mode reconnect. Bisecting now.",
|
||||
],
|
||||
[
|
||||
"assistant",
|
||||
"Found it: the breaker reset races the stream pre-close. Patch incoming.",
|
||||
],
|
||||
]);
|
||||
mkConv("relnotes", "draft-1.6.2-notes", [
|
||||
["user", "Draft the 1.6.2 patch notes from the merged PR list."],
|
||||
[
|
||||
"assistant",
|
||||
"Pulling #657–#662. Consent badge, orphan verb, MCP task hygiene, " +
|
||||
"the anthropic-compatible lane, and the mcp<2 cap.",
|
||||
],
|
||||
]);
|
||||
pm.openPane("repro");
|
||||
pm.openPane("relnotes");
|
||||
const want = q.get("split") || "right";
|
||||
let failed = null;
|
||||
if (want !== "none") {
|
||||
const r1 = pm.splitFocused("right");
|
||||
if (!r1.ok) failed = r1.reason;
|
||||
if (!failed && (want === "three" || want === "down")) {
|
||||
const r2 = pm.splitFocused("down");
|
||||
if (!r2.ok) failed = r2.reason;
|
||||
}
|
||||
}
|
||||
const cells = document.querySelectorAll(
|
||||
".panes > section.pane:not([hidden])",
|
||||
).length;
|
||||
document.title = failed
|
||||
? "SPLIT-FAILED-" + failed
|
||||
: "SPLIT-READY-" + cells;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def build(out: Path) -> None:
|
||||
ui = out / "ui"
|
||||
con = out / "console"
|
||||
@@ -483,6 +618,13 @@ def build(out: Path) -> None:
|
||||
(con / "livepass.html").write_text(page, encoding="utf-8")
|
||||
print(f"{con}/livepass.html — admin fragment + {len(riders)} rider dialogs")
|
||||
|
||||
sh = out / "shell"
|
||||
sh.mkdir(parents=True, exist_ok=True)
|
||||
symlink(sh / "shared", ROOT / "turnstone/shared_static")
|
||||
symlink(sh / "static", ROOT / "turnstone/console/static")
|
||||
(sh / "livepass.html").write_text(SHELL_TEMPLATE, encoding="utf-8")
|
||||
print(f"{sh}/livepass.html — split-view shell surface")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
|
||||
@@ -641,10 +641,12 @@ def test_coordinator_chrome_builder_and_thin_page():
|
||||
|
||||
|
||||
def test_coord_child_links_open_interactive_pane():
|
||||
"""Step 5c: a coordinator child ws link (children tree + linkified tool
|
||||
output) opens the child as a node-proxied interactive pane in the console
|
||||
L-shell. A delegated handler on the pane root reads data-ws-id/data-node-id
|
||||
and calls openPane('interactive', ...) with the CHILD's node; the link's
|
||||
"""Step 5c (+ split revival): a coordinator child ws link (children tree +
|
||||
linkified tool output) opens the child as a node-proxied interactive pane
|
||||
in the console L-shell — in a split cell BESIDE the coordinator
|
||||
(openPaneBeside; the parent stays on screen, and the click's pointerdown
|
||||
focused the coordinator's cell first). A delegated handler on the pane
|
||||
root reads data-ws-id/data-node-id and passes the CHILD's node; the link's
|
||||
href stays the standalone fallback (the standalone coordinator page has no
|
||||
PaneManager, so the new-tab nav stands)."""
|
||||
from pathlib import Path
|
||||
@@ -656,7 +658,7 @@ def test_coord_child_links_open_interactive_pane():
|
||||
# Delegated handler, gated on the pane host so standalone keeps the href nav.
|
||||
assert '.closest(".ws-link, .coord-ws-link")' in coord_js
|
||||
assert "window.TS_SHELL && window.TS_SHELL.panes" in coord_js
|
||||
assert 'pm.openPane("interactive", childWs, { nodeId: childNode })' in coord_js
|
||||
assert 'pm.openPaneBeside("interactive", childWs, { nodeId: childNode })' in coord_js
|
||||
# Both link sites carry the ids the handler reads.
|
||||
assert "a.dataset.wsId = safeWs;" in coord_js # renderChildRow (DOM)
|
||||
assert "a.dataset.nodeId = safeNode;" in coord_js
|
||||
|
||||
+107
-14
@@ -686,17 +686,107 @@ def test_step7_live_tab_state_glyphs() -> None:
|
||||
assert ".tab .tab-glyph" in css, "the tab-glyph spacing rule must apply to static + live glyphs"
|
||||
|
||||
|
||||
def test_step7_new_tab_launcher_button() -> None:
|
||||
"""Step 7 #3: the tab bar's right tail carries a [+] new-session button that
|
||||
focuses the persona launcher (the Dashboard pane hosts it; a new session needs
|
||||
a task prompt so it composes there). Cross-deployment via showHome with an
|
||||
openPane fallback; reuses the scaffold's .tab-add styling."""
|
||||
def test_split_view_controls() -> None:
|
||||
"""The revived split-view's affordance surface: Split right / Split down /
|
||||
Unsplit buttons in the tab-bar tail (they REPLACED the redundant [+] — the
|
||||
permanent Dashboard tab is the launcher). Deliberately NO contextmenu
|
||||
override (the pre-L-shell split UI hijacked right-click); denials surface
|
||||
as a toast with the manager's reason."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert 'make("button", "tab-add")' in shell, "the [+] new-tab button must exist"
|
||||
assert "shell.tail.append(addTab)" in shell, "the [+] lives in the right-floated tail slot"
|
||||
assert "window.showHome()" in shell, "[+] must focus the persona launcher (showHome)"
|
||||
assert 'tbBtn("tb-split", "◫", "Split right")' in shell
|
||||
assert 'tbBtn("tb-split tb-split--down", "◫", "Split down")' in shell
|
||||
assert 'pm.splitFocused("right")' in shell and 'pm.splitFocused("down")' in shell
|
||||
assert "pm.unsplit()" in shell, "the Unsplit button must call pm.unsplit"
|
||||
assert "unsplitBtn.hidden = !pm.isSplit()" in shell, (
|
||||
"Unsplit only shows while split (synced via onActiveChange)"
|
||||
)
|
||||
assert "shell.tail.append(splitRightBtn, splitDownBtn, unsplitBtn)" in shell
|
||||
# The [+] is gone with its showHome/focusLauncher plumbing kept out.
|
||||
assert "tab-add" not in shell, "the [+] new-tab button was replaced by the split controls"
|
||||
css = _SHELL_CSS.read_text(encoding="utf-8")
|
||||
assert ".tab-add" in css, "the .tab-add button style must exist (from the scaffold)"
|
||||
assert ".tb-split" in css and ".tb-split--down .tb-glyph" in css, (
|
||||
"split buttons styled; the down variant rotates the GLYPH (not the button)"
|
||||
)
|
||||
assert "tab-add" not in css, "the dead .tab-add style must not survive"
|
||||
|
||||
|
||||
def test_pane_manager_split_engine() -> None:
|
||||
"""The split-view engine in PaneManager: an optional binary layout tree
|
||||
(null = the pre-feature single-pane behaviour, bit-for-bit). Visible panes
|
||||
are positioned by inline % insets — NEVER reparented, so live stream DOM,
|
||||
scroll state and media survive every layout change. Tabs stay global:
|
||||
active = the focused cell, a backgrounded tab swaps into it, a click inside
|
||||
a visible pane focuses its cell. Separators resize by drag AND keyboard
|
||||
(role=separator + aria-value*); the tree persists in the working-set blob
|
||||
and rehydrate prunes leaves whose pane did not restore."""
|
||||
pane = _PANE_JS.read_text(encoding="utf-8")
|
||||
# public surface (splitFocused takes an optional explicit fill — openPaneBeside)
|
||||
assert "splitFocused(dir, fillId)" in pane
|
||||
assert "unsplit()" in pane and "isSplit()" in pane
|
||||
# no reparenting: layout is applied as % insets on the pane elements
|
||||
assert 'p.el.style.left = r.x * 100 + "%"' in pane
|
||||
# the focused-cell swap + pure focus move both live in activate()
|
||||
assert "this._leafFor(paneId)" in pane and "target.paneId = paneId" in pane
|
||||
# close() collapses the cell and prefers the absorbing sibling as fallback
|
||||
assert "_collapseLeaf(leaf)" in pane and "preferFallback" in pane
|
||||
# auto-fill source: most-recently-focused backgrounded pane
|
||||
assert "_nextBackgroundPane()" in pane and "this._mru" in pane
|
||||
# separators: ARIA + keyboard + pointer-capture drag, ratio bounds from the
|
||||
# split node's OWN px region (nested splits clamp against their own space)
|
||||
assert 'setAttribute("role", "separator")' in pane
|
||||
assert "setPointerCapture" in pane and "_ratioBounds(node)" in pane
|
||||
assert '"aria-valuenow"' in pane and "ArrowRight" in pane
|
||||
# the ARIA range mirrors the REAL clamp (_ratioBounds per handle in the
|
||||
# _applyLayout loop) — never a hard-coded constant
|
||||
assert "this._ratioBounds(h.node)" in pane
|
||||
assert '"aria-valuemin"' in pane and '"aria-valuemax"' in pane
|
||||
assert 'setAttribute("aria-valuemin", "10")' not in pane
|
||||
# limits: cell minimums + cap (the old ui/static ceiling, kept)
|
||||
assert "SPLIT_MAX_CELLS = 6" in pane
|
||||
assert "SPLIT_MIN_W = 200" in pane and "SPLIT_MIN_H = 150" in pane
|
||||
# persistence: layout rides the working-set blob; restore prunes dead leaves
|
||||
assert "state.layout = this._serializeLayout(this._layout)" in pane
|
||||
assert "_restoreLayout(data)" in pane and "seen.has(d.paneId)" in pane
|
||||
# the visible-but-unfocused tab marker
|
||||
assert 'classList.toggle("shown"' in pane
|
||||
# per-pane ✕: split mode hides ONE cell keeping the tab (closeCell);
|
||||
# single-pane it closes the pane (withheld from non-closable) — the click
|
||||
# decides at click time, the label tracks the mode. Manager-injected into
|
||||
# the pane SECTION (content untouched), removed via _clearCellStyle.
|
||||
assert "closeCell(paneId)" in pane and "_refreshCellChips()" in pane
|
||||
assert 'b.className = "cell-unsplit"' in pane
|
||||
assert '"Close pane"' in pane, "the single-pane chip mode"
|
||||
# mode-DISTINCT glyphs (designer P1: identical signifier + locus with a
|
||||
# reversible/destructive divergence is a mode-error trap)
|
||||
assert 'b.textContent = multi ? "−" : "✕"' in pane
|
||||
assert '"cell-unsplit--close"' in pane
|
||||
assert "this._removeCellChip(pane)" in pane
|
||||
# open-beside: the coordinator child-link placement (split right of the
|
||||
# focused cell, degrade to the plain swap on deny)
|
||||
assert "openPaneBeside(type, id, extra)" in pane
|
||||
assert 'this.splitFocused("right", paneId)' in pane
|
||||
css = _SHELL_CSS.read_text(encoding="utf-8")
|
||||
assert ".panes--split > section.pane" in css, (
|
||||
"split cells must target section.pane ONLY — the interactive pane's inner "
|
||||
"div also carries .pane (the step-5b lesson)"
|
||||
)
|
||||
assert ".split-handle" in css and "col-resize" in css and "row-resize" in css
|
||||
assert ".tab.shown:not(.active)" in css, "the visible-but-unfocused tab marker"
|
||||
# the focused ring rides an ::after OVERLAY — an inset shadow on the
|
||||
# section itself paints UNDER edge-touching children (the status-bar
|
||||
# occlusion bug); the ::before bar sits above the ring line
|
||||
assert ".panes--split > section.pane.split-focused::after" in css
|
||||
assert ".cell-unsplit" in css, "the per-cell hide-from-split chip style"
|
||||
assert ".cell-unsplit--close:hover" in css, "destructive mode telegraphs on hover"
|
||||
# the pane is the chip's containing block in BOTH modes — unpositioned,
|
||||
# the single-pane chip anchored to the VIEWPORT (offsetParent <body>)
|
||||
assert ".panes > section.pane" in css
|
||||
# pane-hosted coordinator sidebar drops below the chip's corner lane —
|
||||
# the chip sat exactly on the Children refresh button (user report)
|
||||
coord_chrome = (_ROOT / "turnstone/console/static/coordinator/coord-chrome.css").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert ".pane-body.coord-chrome-root #coord-sidebar.sidebar" in coord_chrome
|
||||
|
||||
|
||||
def test_step7_auth_gated_open_pane() -> None:
|
||||
@@ -798,14 +888,17 @@ def test_interactive_pane_dead_session_revive() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_shell_marks_pane_dead_on_ws_closed() -> None:
|
||||
"""Tier-1 ws_closed → the open pane stops reconnect-polling a session that
|
||||
is GONE and shows the reconnect affordance immediately (the console keeps
|
||||
the tab — unlike the standalone, which closes the pane outright)."""
|
||||
def test_shell_closes_pane_on_ws_closed() -> None:
|
||||
"""Tier-1 ws_closed → the open pane CLOSES outright (tab gone; a split
|
||||
cell collapses onto its sibling) — the coordinator-closes-its-child flow,
|
||||
matching the standalone's pane-auto-close. The dead-BANNER lane survives
|
||||
for streams that die WITHOUT a ws_closed (node crash / network), where the
|
||||
session may still be revivable."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert "const notifySessionClosed = (wsId)" in shell
|
||||
assert 'pm.getPane("interactive", wsId)' in shell
|
||||
assert "p._ctl.markDead()" in shell
|
||||
assert "if (p) pm.close(p.id)" in shell, "ws_closed closes the pane, not mark-dead"
|
||||
assert "showDeadBanner" in shell, "the banner lane must survive for non-closed deaths"
|
||||
assert "window.TS_SHELL = { panes: pm, caps, notifySessionClosed, setRowBadge }" in shell, (
|
||||
"the seam must be exported on TS_SHELL for the console's Tier-1 handler"
|
||||
)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.6.2"
|
||||
__version__ = "1.6.3"
|
||||
|
||||
@@ -146,9 +146,10 @@ function patchClusterState(data) {
|
||||
if (typeof loadSavedCoordinators === "function") {
|
||||
loadSavedCoordinators();
|
||||
}
|
||||
// An open pane on this session must stop reconnect-polling a stream that
|
||||
// is now gone and show its reconnect affordance instead (the shell owns
|
||||
// the pane lifecycle; this is the Tier-1 → pane seam).
|
||||
// An open pane on this session closes with it — the coordinator just
|
||||
// closed its child (or the session ended elsewhere); a lingering tab
|
||||
// would only reconnect-poll a stream that is gone (the shell owns the
|
||||
// pane lifecycle; this is the Tier-1 → pane seam).
|
||||
if (
|
||||
window.TS_SHELL &&
|
||||
typeof window.TS_SHELL.notifySessionClosed === "function"
|
||||
@@ -1962,14 +1963,6 @@ window.TS_APP.bucketByParent = function (list) {
|
||||
window.TS_APP.buildNodeInfo = function (node) {
|
||||
return buildNodeInfoFromSnapshot(node);
|
||||
};
|
||||
// Focus the persona launcher's composer — the [+] new-session button calls this
|
||||
// after showHome so "new session" lands you ready to type (showHome alone, on the
|
||||
// already-active Dashboard, is a no-op). _ensureHomeComposerInit (run by
|
||||
// showHome) has set _homeCoordComposer by the time this fires.
|
||||
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.
|
||||
|
||||
@@ -62,6 +62,16 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
/* Pane-hosted: the shell's per-pane ✕/− chip floats in the pane's top-right
|
||||
corner (top 5px + 28px tall) — exactly where this sidebar's toggle row and
|
||||
the Children refresh sit. Drop the sidebar content below the chip's lane;
|
||||
padding (not margin) keeps the column's left border running the full pane
|
||||
height. (In practice this is EVERY coordinator — the /coordinator/{ws_id}
|
||||
standalone page is a chip-less direct-URL fallback no UI path navigates to;
|
||||
the .pane-body scope just keeps that lane honest.) */
|
||||
.pane-body.coord-chrome-root #coord-sidebar.sidebar {
|
||||
padding-top: 44px;
|
||||
}
|
||||
#coord-children-wrap {
|
||||
min-height: 40%;
|
||||
max-height: 60%;
|
||||
|
||||
@@ -310,9 +310,15 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
const childNode = link.dataset.nodeId;
|
||||
if (!childWs || !childNode) return;
|
||||
const pm = window.TS_SHELL && window.TS_SHELL.panes;
|
||||
if (pm && pm.openPane) {
|
||||
if (pm && pm.openPaneBeside) {
|
||||
e.preventDefault();
|
||||
pm.openPane("interactive", childWs, { nodeId: childNode });
|
||||
// Beside, not instead: the child lands in a cell to the RIGHT of the
|
||||
// coordinator (you are usually cross-checking the child against the
|
||||
// tree that spawned it, so the parent stays on screen). The click's
|
||||
// pointerdown already focused this coordinator's cell, so "beside the
|
||||
// focused cell" is beside THIS pane; a denied split (cell cap / narrow
|
||||
// viewport) degrades to the old focused-cell swap.
|
||||
pm.openPaneBeside("interactive", childWs, { nodeId: childNode });
|
||||
}
|
||||
});
|
||||
// Approval keyboard shortcuts (designer P2 — the console twin of the
|
||||
|
||||
+658
-14
@@ -6,7 +6,9 @@
|
||||
host is generic and every surface is a registered factory. This is the spine
|
||||
the rest of the renovation hangs off — step 1 exercises it with a single
|
||||
`dashboard` pane that adopts the legacy `#main`; richer pane types arrive in
|
||||
steps 2-5.
|
||||
steps 2-5. The split-view section lets the host show several open panes at
|
||||
once (the revived split-pane feature — see "Split view" below); without an
|
||||
active split tree the manager is strictly one-pane-per-tab.
|
||||
|
||||
House style: ES module, programmatic DOM (createElement / textContent /
|
||||
append), NO innerHTML. Panes scope all queries to their own `bodyEl` so they
|
||||
@@ -19,6 +21,15 @@ function cssId(s) {
|
||||
return String(s).replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
/* ----- Split view limits (the revived split-pane feature) -----
|
||||
A split cell below ~200×150 can't render a usable conversation (the composer
|
||||
alone needs ~150px of width headroom); 6 cells is the old ui/static ceiling,
|
||||
kept — past it the cells fall under the minimums on any sane viewport. */
|
||||
const SPLIT_MAX_CELLS = 6;
|
||||
const SPLIT_MIN_W = 200;
|
||||
const SPLIT_MIN_H = 150;
|
||||
const SPLIT_HANDLE_PX = 7; // keep in sync with shell.css .split-handle--row/--col
|
||||
|
||||
/**
|
||||
* A pane: a typed window with its own scoped root. Subtypes (or callers that
|
||||
* patch the lifecycle hooks) build content into `bodyEl` on first mount and
|
||||
@@ -221,6 +232,23 @@ export class PaneManager {
|
||||
this._activeId = null;
|
||||
this._activeSubs = []; // active-pane-change listeners (e.g. the rail marker)
|
||||
this._openMenu = null; // the currently-open tab-action dropdown, if any
|
||||
// Split view: a binary layout tree ({type:"leaf",paneId} | {type:"split",
|
||||
// dir:"row"|"col", ratio, children:[2]}), or null — null is single-pane
|
||||
// mode, where every code path below behaves exactly as before the feature.
|
||||
// Visible panes are positioned by inline % insets (no reparenting: a pane's
|
||||
// live SSE DOM and media elements are never detached).
|
||||
this._layout = null;
|
||||
this._handleEls = []; // live .split-handle separators (rebuilt per layout change)
|
||||
this._mru = []; // paneId[], most-recently-focused first (split auto-fill order)
|
||||
// Clicking anywhere inside a visible-but-unfocused pane focuses its cell
|
||||
// (capture phase — pane content may stopPropagation on bubbled events).
|
||||
if (this.panesEl) {
|
||||
this.panesEl.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => this._onPanesPointerdown(e),
|
||||
true,
|
||||
);
|
||||
}
|
||||
// The tab bar is a WAI-ARIA tablist; arrow keys rove focus across the open
|
||||
// tabs (delegated, so it survives tab reconciliation).
|
||||
if (this.tabbarEl) {
|
||||
@@ -330,7 +358,7 @@ export class PaneManager {
|
||||
* 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) {
|
||||
openPane(type, id, extra, _beside) {
|
||||
if (!this._types.has(type)) {
|
||||
console.warn("PaneManager: unknown pane type", type);
|
||||
return null;
|
||||
@@ -355,7 +383,14 @@ export class PaneManager {
|
||||
this._order.push(paneId);
|
||||
this._mount(pane);
|
||||
}
|
||||
this.activate(paneId);
|
||||
if (_beside && this._activeId !== paneId && !this._leafFor(paneId)) {
|
||||
// Open BESIDE the focused cell (see openPaneBeside) — a denied split
|
||||
// (cap / space) degrades to the plain focused-cell placement below.
|
||||
const r = this.splitFocused("right", paneId);
|
||||
if (!r.ok) this.activate(paneId);
|
||||
} else {
|
||||
this.activate(paneId);
|
||||
}
|
||||
// Explicit-reopen signal: openPane on an existing pane is a user saying
|
||||
// "open this AGAIN" (saved-list resume, rail row, child link) — activate()
|
||||
// alone can't carry that (it no-ops hooks on the already-active pane, and
|
||||
@@ -371,6 +406,17 @@ export class PaneManager {
|
||||
return pane;
|
||||
}
|
||||
|
||||
/** openPane, but a pane that was not already on screen lands in a fresh
|
||||
* cell to the RIGHT of the focused one instead of replacing it — the
|
||||
* coordinator child-link gesture (the parent stays visible; you are
|
||||
* usually cross-checking the child against the tree that spawned it).
|
||||
* An already-visible pane is just focused; a denied split (cell cap /
|
||||
* not enough space) degrades to the plain focused-cell swap. Everything
|
||||
* else (auth gate, factory hint, onReopen) is openPane's. */
|
||||
openPaneBeside(type, id, extra) {
|
||||
return this.openPane(type, id, extra, true);
|
||||
}
|
||||
|
||||
_mount(pane) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "pane";
|
||||
@@ -394,31 +440,55 @@ export class PaneManager {
|
||||
this._renderTabs();
|
||||
}
|
||||
|
||||
/** Show one pane, hide the rest, fire deactivate/activate hooks. */
|
||||
/** Show one pane (single-pane mode: hide the rest) or focus it (split mode),
|
||||
* firing deactivate/activate hooks. In split mode "active" means FOCUSED:
|
||||
* a pane already in a cell keeps every cell as-is (pure focus move); a
|
||||
* backgrounded pane swaps into the focused cell, parking that cell's
|
||||
* current pane. onDeactivate therefore means "lost focus", not necessarily
|
||||
* "hidden" — which matches the panes' contract (interactive panes only stop
|
||||
* focus-stealing and keep streaming: exactly what a visible-but-unfocused
|
||||
* cell wants). */
|
||||
activate(paneId) {
|
||||
// Re-activating the already-active pane is a cheap no-op (it still re-renders
|
||||
// tabs / re-persists / re-notifies below, just no onDeactivate/onActivate).
|
||||
if (!this._panes.has(paneId)) return;
|
||||
const prev = this._activeId ? this._panes.get(this._activeId) : null;
|
||||
if (prev && prev.id !== paneId) {
|
||||
const next = this._panes.get(paneId);
|
||||
const changed = this._activeId !== paneId;
|
||||
if (this._layout) {
|
||||
if (changed && !this._leafFor(paneId)) {
|
||||
// Swap the backgrounded pane into the focused cell.
|
||||
const target = this._leafFor(this._activeId) || this._firstLeaf();
|
||||
const old = target ? this._panes.get(target.paneId) : null;
|
||||
if (target) target.paneId = paneId;
|
||||
if (old && old !== next) {
|
||||
old.el.hidden = true;
|
||||
this._clearCellStyle(old);
|
||||
}
|
||||
}
|
||||
} else if (prev && prev.id !== paneId) {
|
||||
prev.el.hidden = true;
|
||||
}
|
||||
if (changed && prev) {
|
||||
try {
|
||||
prev.onDeactivate();
|
||||
} catch (e) {
|
||||
console.error("PaneManager: onDeactivate failed", prev.id, e);
|
||||
}
|
||||
}
|
||||
const next = this._panes.get(paneId);
|
||||
next.el.hidden = false;
|
||||
const changed = this._activeId !== paneId;
|
||||
this._activeId = paneId;
|
||||
if (changed) {
|
||||
// Most-recently-focused order — the split auto-fill source.
|
||||
this._mru = [paneId].concat(this._mru.filter((p) => p !== paneId));
|
||||
try {
|
||||
next.onActivate();
|
||||
} catch (e) {
|
||||
console.error("PaneManager: onActivate failed", paneId, e);
|
||||
}
|
||||
}
|
||||
if (this._layout) this._applyLayout();
|
||||
this._refreshCellChips();
|
||||
this._renderTabs();
|
||||
this._persist();
|
||||
this._notifyActive();
|
||||
@@ -434,11 +504,19 @@ export class PaneManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a pane (tab + content), release it, focus a neighbour. */
|
||||
/** Drop a pane (tab + content), release it, focus a neighbour. In split
|
||||
* mode a visible pane's cell collapses first (its sibling takes the space),
|
||||
* and the sibling is preferred as the fallback focus target so closing a
|
||||
* cell lands you on the pane that absorbed it. */
|
||||
close(paneId) {
|
||||
const pane = this._panes.get(paneId);
|
||||
if (!pane || pane.closable === false) return;
|
||||
this._closeTabMenu(); // a dropdown anchored on the closing tab must not strand
|
||||
let preferFallback = null;
|
||||
if (this._layout) {
|
||||
const leaf = this._leafFor(paneId);
|
||||
if (leaf) preferFallback = this._collapseLeaf(leaf);
|
||||
}
|
||||
try {
|
||||
pane.onClose();
|
||||
} catch (e) {
|
||||
@@ -447,17 +525,576 @@ export class PaneManager {
|
||||
if (pane.el && pane.el.parentNode) pane.el.parentNode.removeChild(pane.el);
|
||||
this._panes.delete(paneId);
|
||||
this._order = this._order.filter((p) => p !== paneId);
|
||||
this._mru = this._mru.filter((p) => p !== paneId);
|
||||
if (this._activeId === paneId) {
|
||||
this._activeId = null;
|
||||
const fallback = this._order[this._order.length - 1];
|
||||
const fallback = preferFallback || this._order[this._order.length - 1];
|
||||
if (fallback)
|
||||
this.activate(fallback); // fires _notifyActive itself
|
||||
else this._notifyActive(); // last pane closed — clear the marker
|
||||
} else {
|
||||
this._notifyActive(); // split state may have changed (a cell collapsed)
|
||||
}
|
||||
this._renderTabs();
|
||||
this._persist();
|
||||
}
|
||||
|
||||
/* ===== Split view ============================================================
|
||||
The layout tree shows MORE than one open pane at once. Tabs stay global:
|
||||
the active tab is the FOCUSED cell, clicking a backgrounded tab swaps that
|
||||
pane into the focused cell, clicking inside a visible pane focuses its
|
||||
cell. Cells are rendered as inline % insets on the pane elements
|
||||
themselves — a pane is NEVER reparented or detached, so its live stream
|
||||
DOM, scroll positions and media elements are untouched by layout changes.
|
||||
With `_layout === null` (the default) every path above behaves exactly as
|
||||
it did before this feature existed.
|
||||
========================================================================== */
|
||||
|
||||
/** Is the pane host currently showing more than one cell? */
|
||||
isSplit() {
|
||||
return !!this._layout;
|
||||
}
|
||||
|
||||
/** Split the focused pane's cell — `dir` "right" puts the new cell beside
|
||||
* it, "down" below it. The new cell shows `fillId` when given (the
|
||||
* openPaneBeside path), else the most-recently-focused backgrounded pane.
|
||||
* Splitting never duplicates a pane (panes are keyed singletons — two
|
||||
* mounts of one session would race its stream). Returns `{ok:true}` or
|
||||
* `{ok:false, reason}`; the CALLER owns user feedback (the shell toasts
|
||||
* the reason — PaneManager stays chrome-free). */
|
||||
splitFocused(dir, fillId) {
|
||||
const activeId = this._activeId;
|
||||
if (!activeId || !this._panes.has(activeId))
|
||||
return { ok: false, reason: "Nothing to split" };
|
||||
if (this._leafCount() >= SPLIT_MAX_CELLS)
|
||||
return {
|
||||
ok: false,
|
||||
reason: "Pane limit reached (" + SPLIT_MAX_CELLS + ")",
|
||||
};
|
||||
if (fillId != null) {
|
||||
// An explicit fill must be an open, backgrounded, non-focused pane —
|
||||
// anything else is the caller's bug; deny so it can degrade cleanly.
|
||||
if (
|
||||
!this._panes.has(fillId) ||
|
||||
fillId === activeId ||
|
||||
this._leafFor(fillId)
|
||||
)
|
||||
return { ok: false, reason: "Not splittable" };
|
||||
} else {
|
||||
fillId = this._nextBackgroundPane();
|
||||
if (!fillId)
|
||||
return {
|
||||
ok: false,
|
||||
reason: "Every open tab is already visible — open another one first",
|
||||
};
|
||||
}
|
||||
// Space guard: the focused cell must fit two cells plus the divider.
|
||||
const rect = this._cellRect(activeId);
|
||||
const need =
|
||||
(dir === "down" ? SPLIT_MIN_H : SPLIT_MIN_W) * 2 + SPLIT_HANDLE_PX;
|
||||
if ((dir === "down" ? rect.h : rect.w) < need)
|
||||
return { ok: false, reason: "Not enough space to split" };
|
||||
const leaf = this._layout ? this._leafFor(activeId) : null;
|
||||
const node = {
|
||||
type: "split",
|
||||
dir: dir === "down" ? "col" : "row",
|
||||
ratio: 0.5,
|
||||
children: [
|
||||
leaf || { type: "leaf", paneId: activeId },
|
||||
{ type: "leaf", paneId: fillId },
|
||||
],
|
||||
};
|
||||
if (!leaf) {
|
||||
this._layout = node;
|
||||
} else {
|
||||
const found = this._findParent(this._layout, leaf);
|
||||
if (found) found.parent.children[found.index] = node;
|
||||
else this._layout = node; // the leaf was the root (defensive — see _collapseLeaf)
|
||||
}
|
||||
this.panesEl.classList.add("panes--split");
|
||||
this._applyLayout(true);
|
||||
this.activate(fillId); // focus the new cell (already a leaf → pure focus move)
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Collapse back to a single pane — the focused one. The other panes stay
|
||||
* open as tabs (they just stop being visible); nothing is closed. */
|
||||
unsplit() {
|
||||
if (!this._layout) return;
|
||||
this._exitLayout(this._activeId);
|
||||
this._renderTabs();
|
||||
this._persist();
|
||||
this._notifyActive();
|
||||
}
|
||||
|
||||
/** Remove ONE cell from the split — the pane stays open as a (now hidden)
|
||||
* tab and its sibling absorbs the space. The per-cell ✕ chip calls this;
|
||||
* distinct from close() (destroys the pane) and unsplit() (collapses every
|
||||
* cell but the focused one). */
|
||||
closeCell(paneId) {
|
||||
if (!this._layout) return;
|
||||
const leaf = this._leafFor(paneId);
|
||||
if (!leaf) return;
|
||||
const sibling = this._collapseLeaf(leaf); // hides the pane + may exit split mode
|
||||
if (this._activeId === paneId && sibling) {
|
||||
this.activate(sibling); // fires the focus hooks + renders + persists + notifies
|
||||
} else {
|
||||
this._renderTabs();
|
||||
this._persist();
|
||||
this._notifyActive();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- tree helpers -----
|
||||
|
||||
_leaves(node, out) {
|
||||
out = out || [];
|
||||
node = node || this._layout;
|
||||
if (!node) return out;
|
||||
if (node.type === "leaf") out.push(node);
|
||||
else {
|
||||
this._leaves(node.children[0], out);
|
||||
this._leaves(node.children[1], out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
_leafCount() {
|
||||
return this._layout ? this._leaves().length : 1;
|
||||
}
|
||||
|
||||
_leafFor(paneId) {
|
||||
if (paneId == null || !this._layout) return null;
|
||||
return this._leaves().find((l) => l.paneId === paneId) || null;
|
||||
}
|
||||
|
||||
_firstLeaf(node) {
|
||||
node = node || this._layout;
|
||||
if (!node) return null;
|
||||
return node.type === "leaf" ? node : this._firstLeaf(node.children[0]);
|
||||
}
|
||||
|
||||
_findParent(node, target) {
|
||||
if (!node || node.type === "leaf") return null;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (node.children[i] === target) return { parent: node, index: i };
|
||||
const found = this._findParent(node.children[i], target);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Remove a leaf: its sibling subtree takes the parent's place. Exits split
|
||||
* mode when one cell remains. Returns the sibling's first pane id — the
|
||||
* natural focus target for a close() that emptied the focused cell. */
|
||||
_collapseLeaf(leaf) {
|
||||
const found = this._findParent(this._layout, leaf);
|
||||
if (!found) {
|
||||
// The leaf IS the root — a tree this small should already have exited
|
||||
// split mode; recover rather than strand a stale layout.
|
||||
this._exitLayout(null);
|
||||
return null;
|
||||
}
|
||||
const sibling = found.parent.children[found.index === 0 ? 1 : 0];
|
||||
const grand = this._findParent(this._layout, found.parent);
|
||||
if (grand) grand.parent.children[grand.index] = sibling;
|
||||
else this._layout = sibling;
|
||||
const first = this._firstLeaf(sibling);
|
||||
if (this._layout.type === "leaf") this._exitLayout(this._layout.paneId);
|
||||
else this._applyLayout(true);
|
||||
return first ? first.paneId : null;
|
||||
}
|
||||
|
||||
/** The most-recently-focused open pane that is not currently visible —
|
||||
* what a fresh split cell shows. Null when every open pane is visible. */
|
||||
_nextBackgroundPane() {
|
||||
const visible = new Set(
|
||||
this._layout
|
||||
? this._leaves().map((l) => l.paneId)
|
||||
: [this._activeId].filter(Boolean),
|
||||
);
|
||||
for (const pid of this._mru) {
|
||||
if (this._panes.has(pid) && !visible.has(pid)) return pid;
|
||||
}
|
||||
for (const pid of this._order) {
|
||||
if (!visible.has(pid)) return pid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ----- geometry + rendering -----
|
||||
|
||||
/** The px rect of a pane's current cell (the whole host when unsplit). */
|
||||
_cellRect(paneId) {
|
||||
const W = this.panesEl.clientWidth;
|
||||
const H = this.panesEl.clientHeight;
|
||||
if (!this._layout) return { x: 0, y: 0, w: W, h: H };
|
||||
let hit = null;
|
||||
const walk = (node, x, y, w, h) => {
|
||||
if (hit) return;
|
||||
if (node.type === "leaf") {
|
||||
if (node.paneId === paneId) hit = { x, y, w, h };
|
||||
return;
|
||||
}
|
||||
const r = node.ratio;
|
||||
if (node.dir === "row") {
|
||||
walk(node.children[0], x, y, w * r, h);
|
||||
walk(node.children[1], x + w * r, y, w * (1 - r), h);
|
||||
} else {
|
||||
walk(node.children[0], x, y, w, h * r);
|
||||
walk(node.children[1], x, y + h * r, w, h * (1 - r));
|
||||
}
|
||||
};
|
||||
walk(this._layout, 0, 0, W, H);
|
||||
return hit || { x: 0, y: 0, w: W, h: H };
|
||||
}
|
||||
|
||||
/** Lay the visible panes out as % insets and place the separators.
|
||||
* `rebuild` re-creates the handle ELEMENTS (tree structure changed);
|
||||
* without it only styles update, so a mid-drag handle keeps its pointer
|
||||
* capture. % insets make window resizes free — no JS resize listener. */
|
||||
_applyLayout(rebuild) {
|
||||
if (!this._layout) return;
|
||||
const rects = new Map();
|
||||
const handles = [];
|
||||
const walk = (node, x, y, w, h) => {
|
||||
if (node.type === "leaf") {
|
||||
rects.set(node.paneId, { x, y, w, h });
|
||||
return;
|
||||
}
|
||||
const r = node.ratio;
|
||||
if (node.dir === "row") {
|
||||
walk(node.children[0], x, y, w * r, h);
|
||||
handles.push({ node, x: x + w * r, y, span: h });
|
||||
walk(node.children[1], x + w * r, y, w * (1 - r), h);
|
||||
} else {
|
||||
walk(node.children[0], x, y, w, h * r);
|
||||
handles.push({ node, x, y: y + h * r, span: w });
|
||||
walk(node.children[1], x, y + h * r, w, h * (1 - r));
|
||||
}
|
||||
};
|
||||
walk(this._layout, 0, 0, 1, 1);
|
||||
const multi = rects.size > 1;
|
||||
for (const p of this._panes.values()) {
|
||||
const r = rects.get(p.id);
|
||||
if (r) {
|
||||
p.el.hidden = false;
|
||||
p.el.style.left = r.x * 100 + "%";
|
||||
p.el.style.top = r.y * 100 + "%";
|
||||
p.el.style.width = r.w * 100 + "%";
|
||||
p.el.style.height = r.h * 100 + "%";
|
||||
// The focus ring only means something with 2+ cells on screen.
|
||||
p.el.classList.toggle(
|
||||
"split-focused",
|
||||
multi && p.id === this._activeId,
|
||||
);
|
||||
} else {
|
||||
p.el.hidden = true;
|
||||
this._clearCellStyle(p);
|
||||
}
|
||||
}
|
||||
if (rebuild) {
|
||||
for (const h of this._handleEls) h.remove();
|
||||
this._handleEls = handles.map((h) => this._buildHandle(h.node));
|
||||
}
|
||||
// Position fresh AND surviving handles from the same walk (identical
|
||||
// traversal order, so index pairing is stable while the tree shape is).
|
||||
for (let i = 0; i < handles.length && i < this._handleEls.length; i++) {
|
||||
const h = handles[i];
|
||||
const el = this._handleEls[i];
|
||||
el.style.left = h.x * 100 + "%";
|
||||
el.style.top = h.y * 100 + "%";
|
||||
if (h.node.dir === "row") el.style.height = h.span * 100 + "%";
|
||||
else el.style.width = h.span * 100 + "%";
|
||||
// The ARIA range is the REAL clamp (_ratioBounds: the cell minimums
|
||||
// against this split's OWN px region — nested splits sit tighter than
|
||||
// any constant; the old hard-coded 10–90 misreported it to AT). It
|
||||
// refreshes with every drag/keyboard/structure pass through here; a
|
||||
// bare window resize can stale it until the next interaction (no
|
||||
// resize listener by design — % insets make resizes free), which is
|
||||
// still strictly truer than a constant. The max>=min guard covers a
|
||||
// host shrunk below two minimums, where the bounds legitimately cross.
|
||||
const b = this._ratioBounds(h.node);
|
||||
const lo = Math.round(b.min * 100);
|
||||
el.setAttribute("aria-valuemin", String(lo));
|
||||
el.setAttribute(
|
||||
"aria-valuemax",
|
||||
String(Math.max(lo, Math.round(b.max * 100))),
|
||||
);
|
||||
el.setAttribute("aria-valuenow", String(Math.round(h.node.ratio * 100)));
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave split mode. `keepId` (usually the focused pane) stays visible and
|
||||
* the other panes hide; null leaves visibility to the caller (the close()
|
||||
* fallback re-activates). No extra deactivate hooks fire: a pane hidden
|
||||
* here already lost focus — and with it its onDeactivate — earlier. */
|
||||
_exitLayout(keepId) {
|
||||
this._layout = null;
|
||||
this.panesEl.classList.remove("panes--split");
|
||||
for (const h of this._handleEls) h.remove();
|
||||
this._handleEls = [];
|
||||
for (const p of this._panes.values()) {
|
||||
this._clearCellStyle(p);
|
||||
if (keepId) p.el.hidden = p.id !== keepId;
|
||||
}
|
||||
this._refreshCellChips(); // the survivor's ✕ flips to close-pane mode
|
||||
}
|
||||
|
||||
_clearCellStyle(pane) {
|
||||
pane.el.style.left = "";
|
||||
pane.el.style.top = "";
|
||||
pane.el.style.width = "";
|
||||
pane.el.style.height = "";
|
||||
pane.el.classList.remove("split-focused");
|
||||
this._removeCellChip(pane);
|
||||
}
|
||||
|
||||
/** The per-pane ✕ chip, top-right of every VISIBLE pane. Mode-dependent:
|
||||
* in a multi-cell split it hides that cell (closeCell — the tab stays);
|
||||
* single-pane it closes the pane outright (tab and all), so it is withheld
|
||||
* from non-closable panes (the Dashboard) there. The click decides at
|
||||
* CLICK time, the label tracks the mode. Injected by the MANAGER into the
|
||||
* pane's section (not bodyEl — pane content is never touched). */
|
||||
_refreshCellChips() {
|
||||
const multi = !!this._layout && this._leaves().length > 1;
|
||||
for (const p of this._panes.values()) {
|
||||
const want =
|
||||
!p.el.hidden && (multi ? !!this._leafFor(p.id) : p.closable !== false);
|
||||
if (want) this._ensureCellChip(p, multi);
|
||||
else this._removeCellChip(p);
|
||||
}
|
||||
}
|
||||
|
||||
_ensureCellChip(pane, multi) {
|
||||
let b = pane._cellChip;
|
||||
if (!b || !b.isConnected) {
|
||||
b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "cell-unsplit";
|
||||
b.addEventListener("click", () => {
|
||||
if (this._layout && this._leafFor(pane.id)) this.closeCell(pane.id);
|
||||
else this.close(pane.id);
|
||||
});
|
||||
pane.el.append(b);
|
||||
pane._cellChip = b;
|
||||
}
|
||||
// Mode-DISTINCT glyphs — an identical signifier at an identical locus with
|
||||
// divergent outcomes is a mode-error trap (split-mode muscle memory would
|
||||
// fire the destructive close): − hides the cell (reversible — the tab
|
||||
// stays), ✕ closes the pane. Close mode also wears a danger hover
|
||||
// (shell.css .cell-unsplit--close).
|
||||
b.textContent = multi ? "−" : "✕";
|
||||
b.classList.toggle("cell-unsplit--close", !multi);
|
||||
const label = multi ? "Hide from split — the tab stays open" : "Close pane";
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
_removeCellChip(pane) {
|
||||
if (pane._cellChip) {
|
||||
pane._cellChip.remove();
|
||||
pane._cellChip = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Focus follows the pointer between cells: a click anywhere inside a
|
||||
* visible-but-unfocused pane focuses it (capture phase — pane content may
|
||||
* stop propagation of bubbled events). */
|
||||
_onPanesPointerdown(e) {
|
||||
if (!this._layout) return;
|
||||
// The ✕ chip collapses its cell — focusing that cell first would fire a
|
||||
// spurious onActivate on the very pane about to leave the screen.
|
||||
if (e.target.closest && e.target.closest(".cell-unsplit")) return;
|
||||
let el = e.target;
|
||||
// The ShellPane <section> is the DIRECT child of the host (the interactive
|
||||
// pane's inner <div> also carries .pane — walking to the direct child
|
||||
// disambiguates without knowing any pane-type internals).
|
||||
while (el && el.parentElement !== this.panesEl) el = el.parentElement;
|
||||
if (!el || !el.classList || !el.classList.contains("pane")) return;
|
||||
for (const p of this._panes.values()) {
|
||||
if (p.el === el) {
|
||||
if (p.id !== this._activeId && this._leafFor(p.id)) this.activate(p.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- separators (drag + keyboard resize) -----
|
||||
|
||||
_buildHandle(node) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "split-handle split-handle--" + node.dir;
|
||||
el.setAttribute("role", "separator");
|
||||
el.tabIndex = 0;
|
||||
el.setAttribute(
|
||||
"aria-orientation",
|
||||
node.dir === "row" ? "vertical" : "horizontal",
|
||||
);
|
||||
// aria-valuenow/min/max are written by _applyLayout's handle loop (the
|
||||
// single writer) — the range comes from _ratioBounds, not a constant.
|
||||
el.setAttribute(
|
||||
"aria-label",
|
||||
node.dir === "row"
|
||||
? "Resize panes horizontally"
|
||||
: "Resize panes vertically",
|
||||
);
|
||||
this._wireHandle(el, node);
|
||||
this.panesEl.append(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Ratio bounds that keep both children of a split above the cell minimums,
|
||||
* derived from the split node's CURRENT px region (so nested splits clamp
|
||||
* against their own space, not the whole host). */
|
||||
_ratioBounds(node) {
|
||||
const W = this.panesEl.clientWidth;
|
||||
const H = this.panesEl.clientHeight;
|
||||
let region = null;
|
||||
const walk = (n, x, y, w, h) => {
|
||||
if (region) return;
|
||||
if (n === node) {
|
||||
region = { w, h };
|
||||
return;
|
||||
}
|
||||
if (n.type === "leaf") return;
|
||||
const r = n.ratio;
|
||||
if (n.dir === "row") {
|
||||
walk(n.children[0], x, y, w * r, h);
|
||||
walk(n.children[1], x + w * r, y, w * (1 - r), h);
|
||||
} else {
|
||||
walk(n.children[0], x, y, w, h * r);
|
||||
walk(n.children[1], x, y + h * r, w, h * (1 - r));
|
||||
}
|
||||
};
|
||||
walk(this._layout, 0, 0, W, H);
|
||||
const px = region ? (node.dir === "row" ? region.w : region.h) : 0;
|
||||
const minPx = node.dir === "row" ? SPLIT_MIN_W : SPLIT_MIN_H;
|
||||
return {
|
||||
min: px > 0 ? Math.max(0.05, minPx / px) : 0.1,
|
||||
max: px > 0 ? Math.min(0.95, 1 - minPx / px) : 0.9,
|
||||
px: px || 1,
|
||||
};
|
||||
}
|
||||
|
||||
_wireHandle(el, node) {
|
||||
el.addEventListener("pointerdown", (e) => {
|
||||
// Touch/pen carry no primary-button semantics — only filter MOUSE
|
||||
// non-primary buttons (a bare `e.button !== 0` would break touch).
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||
e.preventDefault();
|
||||
el.setPointerCapture(e.pointerId);
|
||||
el.classList.add("dragging");
|
||||
const bounds = this._ratioBounds(node);
|
||||
const startRatio = node.ratio;
|
||||
const horiz = node.dir === "row";
|
||||
const startPos = horiz ? e.clientX : e.clientY;
|
||||
document.body.style.cursor = horiz ? "col-resize" : "row-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
const onMove = (e2) => {
|
||||
const delta = (horiz ? e2.clientX : e2.clientY) - startPos;
|
||||
node.ratio = Math.max(
|
||||
bounds.min,
|
||||
Math.min(bounds.max, startRatio + delta / bounds.px),
|
||||
);
|
||||
this._applyLayout();
|
||||
};
|
||||
const onUp = () => {
|
||||
el.classList.remove("dragging");
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("pointermove", onMove);
|
||||
document.removeEventListener("pointerup", onUp);
|
||||
document.removeEventListener("pointercancel", onUp);
|
||||
this._persist(); // the settled ratio is part of the working set
|
||||
};
|
||||
// The move/up listeners live on DOCUMENT, not the handle: a layout
|
||||
// rebuild can remove the handle MID-DRAG (e.g. a server-side ws_closed
|
||||
// collapses a cell), and handle-bound listeners would die with it,
|
||||
// stranding the body-wide cursor/user-select overrides. Capture loss on
|
||||
// removal just redirects the events to the hit-test chain — document
|
||||
// still hears them and onUp always runs.
|
||||
document.addEventListener("pointermove", onMove);
|
||||
document.addEventListener("pointerup", onUp);
|
||||
document.addEventListener("pointercancel", onUp);
|
||||
});
|
||||
// Keyboard resize: arrows nudge (Shift = coarse), Home/End to the bounds.
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const bounds = this._ratioBounds(node);
|
||||
const step = e.shiftKey ? 0.1 : 0.02;
|
||||
let delta = 0;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") delta = step;
|
||||
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") delta = -step;
|
||||
else if (e.key === "Home") delta = bounds.min - node.ratio;
|
||||
else if (e.key === "End") delta = bounds.max - node.ratio;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
node.ratio = Math.max(
|
||||
bounds.min,
|
||||
Math.min(bounds.max, node.ratio + delta),
|
||||
);
|
||||
this._applyLayout();
|
||||
this._persist();
|
||||
});
|
||||
}
|
||||
|
||||
// ----- persistence -----
|
||||
|
||||
_serializeLayout(node) {
|
||||
if (node.type === "leaf") return { type: "leaf", paneId: node.paneId };
|
||||
return {
|
||||
type: "split",
|
||||
dir: node.dir,
|
||||
ratio: node.ratio,
|
||||
children: [
|
||||
this._serializeLayout(node.children[0]),
|
||||
this._serializeLayout(node.children[1]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-apply a persisted layout after rehydrate re-opened the panes. Leaves
|
||||
* whose pane did not restore (skipped type, auth-denied, duplicate) prune
|
||||
* away and their sibling absorbs the space — the same degrade-don't-error
|
||||
* stance rehydrate takes on pane types. */
|
||||
_restoreLayout(data) {
|
||||
const seen = new Set();
|
||||
const prune = (d) => {
|
||||
if (!d || typeof d !== "object") return null;
|
||||
if (d.type === "leaf") {
|
||||
if (!this._panes.has(d.paneId) || seen.has(d.paneId)) return null;
|
||||
seen.add(d.paneId);
|
||||
return { type: "leaf", paneId: d.paneId };
|
||||
}
|
||||
if (d.type !== "split" || !Array.isArray(d.children)) return null;
|
||||
const a = prune(d.children[0]);
|
||||
const b = prune(d.children[1]);
|
||||
if (!a || !b) return a || b;
|
||||
return {
|
||||
type: "split",
|
||||
dir: d.dir === "col" ? "col" : "row",
|
||||
ratio:
|
||||
typeof d.ratio === "number" && d.ratio >= 0.05 && d.ratio <= 0.95
|
||||
? d.ratio
|
||||
: 0.5,
|
||||
children: [a, b],
|
||||
};
|
||||
};
|
||||
const tree = prune(data);
|
||||
if (!tree || tree.type === "leaf") return; // 0-1 cells — stay single-pane
|
||||
this._layout = tree;
|
||||
this.panesEl.classList.add("panes--split");
|
||||
this._applyLayout(true);
|
||||
// The persisted active pane may have failed to restore — focus the first
|
||||
// cell rather than leaving a hidden pane active.
|
||||
if (!this._leafFor(this._activeId)) {
|
||||
const first = this._firstLeaf(tree);
|
||||
if (first) this.activate(first.paneId);
|
||||
}
|
||||
this._refreshCellChips();
|
||||
this._renderTabs(); // pick up the .shown markers
|
||||
}
|
||||
|
||||
_renderTabs() {
|
||||
// Reconcile the managed tabs IN PLACE — never destroy + recreate. Keeps
|
||||
// keyboard focus and click/keydown listeners stable across activate / open /
|
||||
@@ -538,10 +1175,13 @@ export class PaneManager {
|
||||
}
|
||||
|
||||
/** Refresh a tab's selection state: roving tabindex (only the selected tab is
|
||||
* in the Tab order) + aria-selected + the `.active` style hook. */
|
||||
* in the Tab order) + aria-selected + the `.active` style hook. `.shown`
|
||||
* marks a pane that is VISIBLE in a split cell without being the focused
|
||||
* one (aria-selected stays single — selection means focus). */
|
||||
_refreshTab(tab, pane) {
|
||||
const active = pane.id === this._activeId;
|
||||
tab.classList.toggle("active", active);
|
||||
tab.classList.toggle("shown", !active && !!this._leafFor(pane.id));
|
||||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||||
tab.tabIndex = active ? 0 : -1;
|
||||
}
|
||||
@@ -625,10 +1265,11 @@ export class PaneManager {
|
||||
if (p.meta != null) entry.meta = p.meta;
|
||||
return entry;
|
||||
});
|
||||
sessionStorage.setItem(
|
||||
this.storageKey,
|
||||
JSON.stringify({ order, active: this._activeId }),
|
||||
);
|
||||
const state = { order, active: this._activeId };
|
||||
// The split tree rides along (leaves reference pane ids, which are
|
||||
// deterministic type[:id] strings — rehydrate recomputes the same ones).
|
||||
if (this._layout) state.layout = this._serializeLayout(this._layout);
|
||||
sessionStorage.setItem(this.storageKey, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
/* sessionStorage may be unavailable (private mode / disabled) — non-fatal */
|
||||
}
|
||||
@@ -660,6 +1301,9 @@ export class PaneManager {
|
||||
if (state.active && this._panes.has(state.active)) {
|
||||
this.activate(state.active);
|
||||
}
|
||||
// Layout LAST: every pane it references is open (or pruned), and the
|
||||
// active pane is settled — the restore just re-applies the cells.
|
||||
if (restored && state.layout) this._restoreLayout(state.layout);
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,29 +559,54 @@
|
||||
.tab-caret:hover {
|
||||
background: var(--hair-2);
|
||||
}
|
||||
.tab-add {
|
||||
/* Split controls in the tab-bar tail (they replaced the redundant [+] — the
|
||||
permanent Dashboard tab IS the launcher). One ◫ glyph for both directions;
|
||||
the down variant rotates the glyph span, not the button (a rotated button
|
||||
would rotate its focus ring too). */
|
||||
.tb-split {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-4);
|
||||
/* --ink-3, not the chrome-ghost --ink-4: these are available CONTROLS and
|
||||
the [+] convention they replaced is gone — they must read as present. */
|
||||
color: var(--ink-3);
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.tab-add:hover {
|
||||
.tb-split:hover {
|
||||
background: var(--panel-2);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.tb-split:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.tb-split .tb-glyph {
|
||||
display: inline-block;
|
||||
/* greyscale AA — subpixel RGB rendering fringes these box-drawing glyphs
|
||||
with blue/amber bleed against the panel */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.tb-split--down .tb-glyph {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.tabbar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ink-4);
|
||||
/* hairline fence so the split cluster reads as one "layout" control group */
|
||||
border-left: 1px solid var(--hair);
|
||||
padding-left: 10px;
|
||||
}
|
||||
/* Drawer toggle + backdrop — desktop keeps the rail in the grid, so both are
|
||||
dormant here; the mobile block at the end of this sheet brings them up. */
|
||||
@@ -729,8 +754,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* pane host — ONE pane visible per tab (no split; the mock's 2-up was a
|
||||
display device only, so this is a single column, not `1fr 1fr`). */
|
||||
/* pane host — ONE pane visible per tab by default; the split view below can
|
||||
show several at once (PaneManager toggles .panes--split). */
|
||||
.panes {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
@@ -746,6 +771,189 @@
|
||||
.pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== Split view — visible panes become absolutely-positioned cells (inline
|
||||
% insets from PaneManager._applyLayout; % keeps window resizes free). The
|
||||
panes stay DIRECT children of .panes and are never reparented, so a live
|
||||
stream's DOM, scroll state and media playback ride out every layout change.
|
||||
Only `section.pane` (the ShellPane host) is targeted — the interactive
|
||||
pane's INNER div also carries .pane (the 5b lesson) and must not match. ===== */
|
||||
.panes--split {
|
||||
position: relative;
|
||||
}
|
||||
/* The pane is the containing block in BOTH modes: unpositioned, the per-pane
|
||||
✕ chip's absolute would resolve to the VIEWPORT (offsetParent <body>) and
|
||||
only coincidentally land near the pane corner. The split rule below wins
|
||||
on specificity while split. */
|
||||
.panes > section.pane {
|
||||
position: relative;
|
||||
}
|
||||
.panes--split > section.pane {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* the focused cell — only meaningful with 2+ cells. A quiet inset ring PLUS
|
||||
a 2px top accent bar (the rail's .row.open::before vocabulary): the bar is
|
||||
the load-bearing cue — designer-measured, no thin tinted ring clears WCAG
|
||||
1.4.11's 3:1 on the light theme, and 38%-mix was ~invisible on dark.
|
||||
The ring lives on an ::after OVERLAY, not the section's own box-shadow: an
|
||||
inset shadow paints in the element's background layer, UNDER any opaque
|
||||
child touching the edge — the status bar / composer strip occluded it (the
|
||||
reported paint bug). The overlay sits above pane content (a coordinator
|
||||
overlay label uses z:10) and stays click-transparent. */
|
||||
.panes--split > section.pane.split-focused::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-shadow: inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--accent) 55%, var(--hair-2));
|
||||
z-index: 12;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* light needs a hotter mix: 55% measured 2.60:1 there (sub-3:1); 75% clears.
|
||||
Dark stays 55% (3.75:1) — hotter reads as a heavy border on dark. */
|
||||
[data-theme="light"] .panes--split > section.pane.split-focused::after {
|
||||
box-shadow: inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--accent) 75%, var(--hair-2));
|
||||
}
|
||||
.panes--split > section.pane.split-focused::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
/* 1px in from the cell sides so the bar never butts a separator line into
|
||||
one doubled-accent stripe at the T-junction */
|
||||
inset: 0 1px auto 1px;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
z-index: 13; /* above the ring overlay — a clean bar, not bar-plus-ring-line */
|
||||
pointer-events: none;
|
||||
}
|
||||
/* per-pane ✕ — on every visible pane. Split mode: "hide this cell" (the TAB
|
||||
stays — closeCell); single-pane: "close pane" (withheld from the unclosable
|
||||
Dashboard). Shell chrome floating over pane content: elevated panel +
|
||||
hairline so it reads as the shell's, not the conversation's; sits clear of
|
||||
the 2px focus bar and the cell corner. */
|
||||
.cell-unsplit {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 14px; /* clear of the message scroller's scrollbar gutter */
|
||||
z-index: 14;
|
||||
/* 28px: WCAG 2.5.8's 24px floor + a hair (22px under-shot it) */
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* --hair-2 measured ~1.3:1 against the chip bg — an invisible boundary;
|
||||
--ink-4 clears 3:1 in both themes */
|
||||
border: 1px solid var(--ink-4);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--panel-2);
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cell-unsplit:hover {
|
||||
opacity: 1;
|
||||
color: var(--ink);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
/* the destructive mode (single-pane "Close pane" ✕) telegraphs BEFORE the
|
||||
click lands — split-mode muscle memory must not fire it blind */
|
||||
.cell-unsplit--close:hover {
|
||||
color: var(--err);
|
||||
border-color: var(--err);
|
||||
}
|
||||
.cell-unsplit:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.cell-unsplit--close:focus-visible {
|
||||
outline-color: var(--err);
|
||||
}
|
||||
/* light resting glyph at .85 sat right at the 3:1 floor with --ink-3 — one
|
||||
ink step restores headroom (~4.5:1) without making the chip shout */
|
||||
[data-theme="light"] .cell-unsplit {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
/* separator — a 7px hit area straddling the cell boundary with a 1px visual
|
||||
line; drag (pointer capture) or arrow keys resize, see pane.js _wireHandle.
|
||||
Resting line is --ink-4, the lightest token clearing 3:1 in BOTH themes
|
||||
(--hair measured ~1.3:1 — an undiscoverable drag target); hover/drag goes
|
||||
SOLID accent and thickens (--accent-dim was a luminance DROP on hover). */
|
||||
.split-handle {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
}
|
||||
.split-handle--row {
|
||||
width: 7px;
|
||||
cursor: col-resize;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.split-handle--col {
|
||||
height: 7px;
|
||||
cursor: row-resize;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.split-handle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: var(--ink-4);
|
||||
}
|
||||
.split-handle--row::after {
|
||||
left: 3px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
}
|
||||
.split-handle--col::after {
|
||||
top: 3px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
}
|
||||
.split-handle:hover::after,
|
||||
.split-handle.dragging::after,
|
||||
.split-handle:focus-visible::after {
|
||||
background: var(--accent);
|
||||
}
|
||||
.split-handle:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.split-handle--row:hover::after,
|
||||
.split-handle--row.dragging::after,
|
||||
.split-handle--row:focus-visible::after {
|
||||
left: 2px;
|
||||
width: 3px;
|
||||
}
|
||||
.split-handle--col:hover::after,
|
||||
.split-handle--col.dragging::after,
|
||||
.split-handle--col:focus-visible::after {
|
||||
top: 2px;
|
||||
height: 3px;
|
||||
}
|
||||
/* a visible-but-unfocused pane's tab — the three tab states must each read:
|
||||
rest (bare) → shown (accent UNDERLINE — a shape, not a fill delta; rhymes
|
||||
with the focused cell's TOP bar) → active (panel fill). Designer-measured:
|
||||
a border/fill-only delta between shown and active was ≤1.1:1. */
|
||||
.tab.shown:not(.active) {
|
||||
border-color: var(--hair-2);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.tab.shown:not(.active)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 2px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
/* 80%: 55% composited to 2.34:1 on the light --panel (sub-3:1 for a
|
||||
load-bearing state mark); 80% = ~3.7:1 light / ~5:1 dark */
|
||||
background: color-mix(in srgb, var(--accent) 80%, transparent);
|
||||
}
|
||||
.pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1219,6 +1427,16 @@
|
||||
.tab {
|
||||
max-width: 48vw;
|
||||
}
|
||||
/* splits are a desktop affordance (a phone viewport is below the cell
|
||||
minimums before the first divider lands) — and with the buttons gone the
|
||||
tail's group fence has nothing to fence */
|
||||
.tb-split {
|
||||
display: none;
|
||||
}
|
||||
.tabbar-right {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rail {
|
||||
|
||||
@@ -510,26 +510,45 @@ async function mountShell() {
|
||||
});
|
||||
pm.onActiveChange(() => setDrawer(false));
|
||||
|
||||
// [+] new-tab (step 7): a shortcut to the persona launcher. The Dashboard pane
|
||||
// hosts the unified coordinator/interactive launcher (a new session needs a task
|
||||
// prompt, so it composes there) — "new session" focuses it. showHome is exposed
|
||||
// by both deployments; openPane is the fallback. Lives in the right-floated tail
|
||||
// slot per the brief. (Auth is the launcher's own concern — it gates each
|
||||
// persona option; focusing it is always safe.)
|
||||
const addTab = make("button", "tab-add");
|
||||
addTab.type = "button";
|
||||
addTab.setAttribute("aria-label", "New session");
|
||||
addTab.title = "New session";
|
||||
addTab.textContent = "+";
|
||||
addTab.addEventListener("click", () => {
|
||||
if (typeof window.showHome === "function") window.showHome();
|
||||
else pm.openPane("dashboard");
|
||||
// Land in the launcher composer so "new session" is immediately typeable —
|
||||
// showHome on the already-active Dashboard is otherwise a no-op.
|
||||
if (window.TS_APP && typeof window.TS_APP.focusLauncher === "function")
|
||||
window.TS_APP.focusLauncher();
|
||||
});
|
||||
shell.tail.append(addTab);
|
||||
// Split controls (the revived split-view): they act on the FOCUSED pane.
|
||||
// Split right / split down open a second cell beside/below it, filled with
|
||||
// the most-recently-used backgrounded tab; Unsplit returns to one pane and
|
||||
// only shows while split. They replaced the old [+] new-session button —
|
||||
// the permanent Dashboard tab IS the launcher, so [+] duplicated one click.
|
||||
// Deliberately NO contextmenu override anywhere (the pre-L-shell split UI
|
||||
// hijacked right-click): these buttons are the whole affordance surface.
|
||||
const tbBtn = (cls, glyph, label) => {
|
||||
const b = make("button", cls);
|
||||
b.type = "button";
|
||||
b.setAttribute("aria-label", label);
|
||||
b.title = label;
|
||||
const g = make("span", "tb-glyph", glyph);
|
||||
g.setAttribute("aria-hidden", "true"); // the button's aria-label speaks
|
||||
b.append(g);
|
||||
return b;
|
||||
};
|
||||
// Denials surface as a toast (space / pane-limit / nothing to show) — the
|
||||
// manager stays chrome-free and just returns the reason.
|
||||
const splitFeedback = (r) => {
|
||||
if (r && !r.ok && r.reason && typeof window.showToast === "function")
|
||||
window.showToast(r.reason, "warning");
|
||||
};
|
||||
const splitRightBtn = tbBtn("tb-split", "◫", "Split right");
|
||||
splitRightBtn.addEventListener("click", () =>
|
||||
splitFeedback(pm.splitFocused("right")),
|
||||
);
|
||||
const splitDownBtn = tbBtn("tb-split tb-split--down", "◫", "Split down");
|
||||
splitDownBtn.addEventListener("click", () =>
|
||||
splitFeedback(pm.splitFocused("down")),
|
||||
);
|
||||
const unsplitBtn = tbBtn("tb-split", "□", "Unsplit — keep the focused pane");
|
||||
unsplitBtn.addEventListener("click", () => pm.unsplit());
|
||||
const syncSplitControls = () => {
|
||||
unsplitBtn.hidden = !pm.isSplit();
|
||||
};
|
||||
pm.onActiveChange(syncSplitControls);
|
||||
syncSplitControls();
|
||||
shell.tail.append(splitRightBtn, splitDownBtn, unsplitBtn);
|
||||
|
||||
// Dashboard pane (step 1): a singleton that ADOPTS the legacy #main so the
|
||||
// console renders unchanged inside the new shell. Real pane types (admin,
|
||||
@@ -873,12 +892,15 @@ async function mountShell() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tier-1 lifecycle → pane signal. The console's ws_closed handler calls this
|
||||
// so an open pane on that session stops its reconnect loop NOW (instead of
|
||||
// 404-polling a session that is gone) and shows the reconnect affordance.
|
||||
// Tier-1 lifecycle → pane signal. The console's ws_closed handler calls
|
||||
// this so an open pane on a CLOSED session closes outright — tab gone, a
|
||||
// split cell collapses onto its sibling. This is the coordinator-closes-
|
||||
// its-child flow (and matches the standalone's pane-auto-close); the dead-
|
||||
// BANNER lane stays for streams that die WITHOUT a ws_closed (node crash,
|
||||
// network) where the session may still be revivable.
|
||||
const notifySessionClosed = (wsId) => {
|
||||
const p = pm.getPane("interactive", wsId);
|
||||
if (p && p._ctl && p._ctl.markDead) p._ctl.markDead();
|
||||
if (p) pm.close(p.id);
|
||||
};
|
||||
// `setRowBadge` lets a classic-script subsystem (the standalone consent badge
|
||||
// in ui/static/app.js) stamp a count chip on a Manage row without importing the
|
||||
|
||||
Reference in New Issue
Block a user