mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
9ad447ca33
Two-reviewer + sanity pass over the attachment previews: - composer audio chip is icon+name+size only; the native <audio> player renders on the sent message, not the staging chip (too heavy at chip scale) - cap sent-message pills (+ in-pill audio/snippet) so they no longer overflow the bubble at narrow widths; player and snippet drop to their own row - clamp the chip filename in shared chat.css so long names ellipsize instead of wrapping (console main + coordinator previously left it unclamped) - merge the duplicated .composer-chip rule; drop unused kind-modifier classes and inert vertical-align / inline-block declarations - fix undefined var(--bg-base) -> var(--bg-surface) thumbnail backing - label the <audio> control (aria-label) and drop the decorative snippet from the a11y tree scripts/livepass.py: add an attachments harness that drives the real createAttachmentController + Pane.addUserMessage so these surfaces render headlessly for review.
920 lines
40 KiB
Python
Executable File
920 lines
40 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Build the livepass harnesses — render real hatch dialogs/shelves headlessly.
|
||
|
||
The livepass is how converted modal surfaces get verified without booting a
|
||
server: a minimal page that symlinks the REAL stylesheets and scripts, embeds
|
||
the REAL markup (extracted fresh from the index files at build time), stubs
|
||
``window.authFetch`` with canned fixtures, and drives surfaces via ``?open=``
|
||
query params — including click-driving submits so dead buttons can't hide
|
||
(the model-Save bug class).
|
||
|
||
Usage:
|
||
python3 scripts/livepass.py # build into /tmp/livepass/
|
||
python3 scripts/livepass.py --out DIR # build elsewhere
|
||
python3 scripts/livepass.py --serve 8950 # build + serve (Ctrl+C stops)
|
||
|
||
Then screenshot states (file:// blocks ES modules — always serve over http;
|
||
the reduced-motion flag is REQUIRED, entrance animations race the capture):
|
||
|
||
google-chrome --headless --disable-gpu --hide-scrollbars \\
|
||
--force-prefers-reduced-motion --window-size=1440,900 \\
|
||
--virtual-time-budget=9000 --screenshot=out.png \\
|
||
"http://localhost:8950/ui/livepass.html?open=new-ws&theme=light"
|
||
|
||
UI harness (?open=): new-ws · new-ws-fork · edit-title · delete-ws ·
|
||
revoke-mcp · ws-delete · ws-delete-results (+ &theme=light, &busy=1)
|
||
Console harness (?open=): schedule-create · schedule-edit · model-create ·
|
||
model-edit · model-save (drives a Save click; document.title becomes
|
||
PUT-OK-<n> on success) · policy · confirm · token
|
||
Plus &tall=1 (90-row users panel — the .admin-content scroll state; the
|
||
synthetic rows wrap to two lines, so judge overflow geometry, not row
|
||
cadence) · &scrolled=1 lands mid-list, &scrolled=bottom shows the 24px
|
||
scroll tail · &focuslast=1 focuses the last shelf-body control (the
|
||
displaced-dock regression probe: only .sh-body may scroll; head/foot stay
|
||
pinned). All combinable with ?open=. The console page wraps the fragment
|
||
in the REAL L-shell chain — pane-pinned height, interior scroller — so
|
||
scroll/dock geometry matches production; keep it that way. Body-level
|
||
dialogs (confirm/install/coord-delete) are injected as riders; a driven
|
||
?open= that ends with no open dialog stamps OPEN-FAILED-<state> into the
|
||
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.
|
||
Attachments harness (/attachments/livepass.html): the composer attachment
|
||
chips + the sent-message attachment pills, both driven through the REAL
|
||
code paths — createAttachmentController.rehydrate() builds the chips and
|
||
Pane.addUserMessage() builds the pills, so the preview nodes (image/pdf
|
||
thumbnail, <audio> player, lazy text snippet) render exactly as in
|
||
production. Committed fixtures cover every kind plus a long filename;
|
||
thumbnails + the audio clip are served by an in-process fixture route
|
||
(--serve only), the text snippet flows through the stubbed authFetch.
|
||
+ &theme=light. document.title stamps ATTACH-READY-<chips>-<pills> on
|
||
success, ATTACH-FAILED-c<n>-p<n> when a surface came up empty. Judge the
|
||
thumbnail crop/size, the native audio-control fit at the constrained
|
||
height, the snippet contrast, and how a long filename behaves at the
|
||
340px chip cap.
|
||
|
||
Rebuild after ANY markup change: the dialog blocks are embedded at build
|
||
time. Assets are symlinked, so CSS/JS edits are live on refresh.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
UI_INDEX = ROOT / "turnstone/ui/static/index.html"
|
||
CONSOLE_INDEX = ROOT / "turnstone/console/static/index.html"
|
||
|
||
|
||
def extract_dialogs(index: Path, only_id: str | None = None) -> list[str]:
|
||
"""Every <dialog class="hatch ..."> block, verbatim from the tree."""
|
||
html = index.read_text(encoding="utf-8")
|
||
blocks = []
|
||
for m in re.finditer(r"[ \t]*<dialog\s[^>]*class=\"[^\"]*\bhatch\b[^\"]*\"", html):
|
||
end = html.index("</dialog>", m.start()) + len("</dialog>")
|
||
block = html[m.start() : end]
|
||
if only_id and f'id="{only_id}"' not in block:
|
||
continue
|
||
blocks.append(block)
|
||
if not blocks:
|
||
raise SystemExit(f"no dialog.hatch blocks found in {index}")
|
||
return blocks
|
||
|
||
|
||
def extract_admin_fragment() -> str:
|
||
"""The console admin pane — the hatch-host all shelves live inside."""
|
||
html = CONSOLE_INDEX.read_text(encoding="utf-8")
|
||
start = html.index('<div id="admin-layout"')
|
||
end = html.index("<!-- /admin-layout -->") + len("<!-- /admin-layout -->")
|
||
return html[start:end]
|
||
|
||
|
||
def inject(template: str, marker: str, payload: str) -> str:
|
||
begin = template.index(f"<!-- {marker}:BEGIN -->") + len(f"<!-- {marker}:BEGIN -->")
|
||
end = template.index(f"<!-- {marker}:END -->")
|
||
return template[:begin] + "\n" + payload + "\n" + template[end:]
|
||
|
||
|
||
def symlink(link: Path, target: Path) -> None:
|
||
if link.is_symlink() or link.exists():
|
||
link.unlink()
|
||
link.symlink_to(target)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# UI harness — the standalone app's dialog tier. Drives the REAL cards.js
|
||
# controller for the batch surfaces so the production code path renders.
|
||
# --------------------------------------------------------------------------
|
||
UI_TEMPLATE = """<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>ui 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" />
|
||
<link rel="stylesheet" href="shared/hatch.css" />
|
||
</head>
|
||
<body>
|
||
<!-- DIALOGS:BEGIN -->
|
||
<!-- DIALOGS:END -->
|
||
<div id="toast" role="status" aria-live="polite"></div>
|
||
<script>
|
||
window.authFetch = function (url) {
|
||
// One canned failure so the results view shows the mixed state.
|
||
var fail = url && url.indexOf("c3d4e5f6a1b2") !== -1;
|
||
return Promise.resolve({
|
||
ok: !fail,
|
||
status: fail ? 409 : 200,
|
||
headers: { get: function () { return "application/json"; } },
|
||
json: function () { return Promise.resolve({}); },
|
||
text: function () {
|
||
return Promise.resolve(
|
||
fail ? '{"error": "workstream is still running"}' : "",
|
||
);
|
||
},
|
||
});
|
||
};
|
||
window.showToast = function (msg) { console.log("toast:", msg); };
|
||
</script>
|
||
<script type="module">
|
||
import { openDialog, setBusy } from "./shared/hatch.js";
|
||
const q = new URLSearchParams(location.search);
|
||
if (q.get("theme") === "light")
|
||
document.documentElement.dataset.theme = "light";
|
||
const open = q.get("open") || "";
|
||
function fill(id, text) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = text;
|
||
}
|
||
if (open === "new-ws" || open === "new-ws-fork") {
|
||
const dlg = document.getElementById("new-ws-dialog");
|
||
const canned = {
|
||
"new-ws-model": ["sonnet-4-6", "gpt-5-2", "qwen3-32b"],
|
||
"new-ws-judge-model": ["sonnet-4-6", "qwen3-32b"],
|
||
"new-ws-skill": ["code-review (default)", "deep-research"],
|
||
};
|
||
for (const id in canned) {
|
||
const s = document.getElementById(id);
|
||
for (const n of canned[id]) {
|
||
const o = document.createElement("option");
|
||
o.value = n;
|
||
o.textContent = n;
|
||
s.appendChild(o);
|
||
}
|
||
}
|
||
if (open === "new-ws-fork") {
|
||
fill("new-ws-title", "Fork workstream");
|
||
fill("new-ws-tag", "WS-FORK");
|
||
document.getElementById("new-ws-submit").textContent = "Fork";
|
||
const skillLabel = document.querySelector('label[for="new-ws-skill"]');
|
||
if (skillLabel) skillLabel.hidden = true;
|
||
document.getElementById("new-ws-skill").hidden = true;
|
||
document.getElementById("new-ws-attach-row").hidden = true;
|
||
}
|
||
openDialog(dlg);
|
||
} else if (open === "edit-title") {
|
||
document.getElementById("edit-title-input").value =
|
||
"lshell renovation pass 3";
|
||
openDialog(document.getElementById("edit-title-dialog"));
|
||
} else if (open === "delete-ws") {
|
||
fill(
|
||
"delete-ws-message",
|
||
'Delete "lshell renovation pass 3"? This cannot be undone.',
|
||
);
|
||
openDialog(document.getElementById("delete-ws-dialog"));
|
||
} else if (open === "revoke-mcp") {
|
||
fill(
|
||
"revoke-mcp-message",
|
||
"Revoke the connection to github? Tools that need this server will require re-consent.",
|
||
);
|
||
openDialog(document.getElementById("revoke-mcp-dialog"));
|
||
} else if (open === "ws-delete" || open === "ws-delete-results") {
|
||
// Drive the REAL shared controller so the dialog renders through
|
||
// the production code path (cards.js confirmSelection/confirm).
|
||
const mod = await import("./shared/cards.js");
|
||
const c = mod.createSavedCardsController({
|
||
idPrefix: "ws-delete",
|
||
buttonId: "ws-delete-btn",
|
||
noun: "workstream",
|
||
activateLabel: (s) => "Resume: " + (s.title || s.ws_id),
|
||
render: () => {},
|
||
buildDeleteRequest: (wsId) => ({
|
||
url: "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete",
|
||
options: { method: "POST" },
|
||
}),
|
||
});
|
||
c.setItems([
|
||
{ ws_id: "a1b2c3d4e5f6", title: "lshell renovation pass 3" },
|
||
{ ws_id: "b2c3d4e5f6a1", title: "canonical trajectory spike" },
|
||
{
|
||
ws_id: "c3d4e5f6a1b2",
|
||
title:
|
||
"a very long workstream title that should wrap " +
|
||
"rather than punch out of the dialog box entirely",
|
||
},
|
||
]);
|
||
c.toggleAll();
|
||
c.confirmSelection();
|
||
if (open === "ws-delete-results") c.confirm();
|
||
}
|
||
if (q.get("busy")) {
|
||
const d = document.querySelector("dialog[open]");
|
||
if (d) setBusy(d, true);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Console harness — the admin pane fragment hosts the shelves (token-created
|
||
# included); dialog-tier markup outside the fragment (confirm/install/
|
||
# coord-delete) is injected via the RIDERS marker in build().
|
||
# model-save click-drives the submit: document.title flips to PUT-OK-<n>.
|
||
# --------------------------------------------------------------------------
|
||
CONSOLE_TEMPLATE = """<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>console livepass</title>
|
||
<link rel="stylesheet" href="shared/base.css" />
|
||
<link rel="stylesheet" href="shared/ui-base.css" />
|
||
<link rel="stylesheet" href="console-static/style.css" />
|
||
<link rel="stylesheet" href="shared/shell.css" />
|
||
<link rel="stylesheet" href="shared/hatch.css" />
|
||
</head>
|
||
<body>
|
||
<!-- The REAL L-shell chain (shell.js buildShell + pane.js DOM, verbatim
|
||
class names) so the harness inherits production scroll geometry:
|
||
.pane-body > #view-admin > .admin-layout height-pin the hatch-host
|
||
and .admin-content is the pane's interior scroller. Never replace
|
||
this with bespoke height overrides — the clipped-pane / displaced-
|
||
shelf regressions were invisible to the harness precisely because
|
||
it used to pin #admin-layout with its own CSS. -->
|
||
<div class="app">
|
||
<aside class="rail" id="shell-rail">
|
||
<div class="rail-brand">
|
||
<button class="brand-home" type="button">
|
||
<div class="brand-mark"></div>
|
||
<span class="brand-name">turnstone</span>
|
||
<span class="brand-sub">console</span>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
<main class="content">
|
||
<div class="tabbar"></div>
|
||
<div class="panes">
|
||
<section class="pane">
|
||
<!-- no .pane-head: PaneManager._mount builds section.pane >
|
||
div.pane-body only -->
|
||
<div class="pane-body">
|
||
<div id="view-admin">
|
||
<!-- FRAGMENT:BEGIN -->
|
||
<!-- FRAGMENT:END -->
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
<!-- Body-level dialog tier (confirm / install / coord-delete): their
|
||
markup sits OUTSIDE #admin-layout in index.html, so the fragment
|
||
extraction misses them — build() injects every hatch dialog the
|
||
fragment does not already contain. -->
|
||
<!-- RIDERS:BEGIN -->
|
||
<!-- RIDERS:END -->
|
||
<div id="toast" role="status" aria-live="polite"></div>
|
||
<script>
|
||
(function () {
|
||
function reply(data) {
|
||
return Promise.resolve({
|
||
ok: true,
|
||
status: 200,
|
||
headers: { get: function () { return "application/json"; } },
|
||
json: function () { return Promise.resolve(data); },
|
||
text: function () { return Promise.resolve(JSON.stringify(data)); },
|
||
});
|
||
}
|
||
var SCHED = {
|
||
task_id: "t1", name: "nightly-digest", description: "Morning digest",
|
||
schedule_type: "cron", cron_expr: "0 6 * * 1,3,5", at_time: "",
|
||
target_mode: "auto", model: "fable-5", skill: "daily-digest",
|
||
initial_message: "Summarize overnight cluster activity.",
|
||
auto_approve: false, enabled: true,
|
||
notify_targets: [{ channel_type: "discord", channel_id: "8675309" }],
|
||
next_run: "2026-06-10T06:00:00",
|
||
};
|
||
var MODEL = {
|
||
definition_id: "def1", alias: "fable-5", model: "claude-fable-5",
|
||
provider: "anthropic", base_url: "", context_window: 200000,
|
||
capabilities: JSON.stringify({ supports_vision: true }),
|
||
enabled: true, temperature: null, max_tokens: null,
|
||
reasoning_effort: null, surface_persisted_reasoning: true,
|
||
replay_reasoning_to_model: false,
|
||
};
|
||
window.__putCount = 0;
|
||
window.authFetch = function (url, opts) {
|
||
var method = (opts && opts.method) || "GET";
|
||
if (method === "PUT" && url.indexOf("/model-definitions/def1") >= 0) {
|
||
window.__putCount++;
|
||
document.title = "PUT-OK-" + window.__putCount;
|
||
return reply({ ok: true });
|
||
}
|
||
if (url.indexOf("/schedules/preview") >= 0)
|
||
return reply({
|
||
valid: true, error: "",
|
||
next: [
|
||
"2026-06-10T06:00:00+00:00",
|
||
"2026-06-12T06:00:00+00:00",
|
||
"2026-06-15T06:00:00+00:00",
|
||
],
|
||
});
|
||
if (url.indexOf("/schedules/t1") >= 0) return reply(SCHED);
|
||
if (url.indexOf("/schedules") >= 0) return reply({ schedules: [SCHED] });
|
||
if (url.indexOf("/model-capabilities/known") >= 0)
|
||
return reply({ models: ["claude-fable-5", "claude-opus-4-8"] });
|
||
if (url.indexOf("/model-capabilities?") >= 0)
|
||
return reply({
|
||
known: true,
|
||
capabilities: {
|
||
context_window: 200000, supports_tools: true,
|
||
supports_streaming: true, supports_vision: true,
|
||
supports_web_search: true, supports_temperature: true,
|
||
supports_effort: true,
|
||
},
|
||
});
|
||
if (url.indexOf("/model-definitions/def1") >= 0) return reply(MODEL);
|
||
if (url.indexOf("/model-definitions") >= 0) return reply({ models: [] });
|
||
if (url.indexOf("/api/models") >= 0)
|
||
return reply({ models: [
|
||
{ alias: "fable-5", model: "claude-fable-5" },
|
||
{ alias: "gpt-5.2", model: "gpt-5.2" },
|
||
] });
|
||
if (url.indexOf("/skills") >= 0)
|
||
return reply({ skills: [{ name: "daily-digest" }, { name: "ops-runbook" }] });
|
||
if (url.indexOf("/policies") >= 0)
|
||
return reply({ policies: [
|
||
{ policy_id: "p1", name: "deny-rm", tool_pattern: "bash*rm*",
|
||
action: "deny", priority: 900, enabled: true },
|
||
{ policy_id: "p2", name: "default-ask", tool_pattern: "*",
|
||
action: "ask", priority: 0, enabled: true },
|
||
] });
|
||
return reply({});
|
||
};
|
||
window.showToast = function (m) {
|
||
console.log("toast:", m);
|
||
var t = document.getElementById("toast");
|
||
t.textContent = m;
|
||
t.classList.add("show");
|
||
};
|
||
})();
|
||
</script>
|
||
<script type="module" src="shared/utils.js"></script>
|
||
<script type="module" src="shared/hatch.js"></script>
|
||
<script src="console-static/admin.js"></script>
|
||
<script src="console-static/governance.js"></script>
|
||
<script>
|
||
window.addEventListener("load", function () {
|
||
var q = new URLSearchParams(location.search);
|
||
if (q.get("theme") === "light")
|
||
document.documentElement.dataset.theme = "light";
|
||
var open = q.get("open") || "";
|
||
// ?tall=1 — the scroll state: one panel visible with enough rows to
|
||
// overflow the pane, so a screenshot shows .admin-content scrolling
|
||
// (and a shelf staying docked above it). Mirrors switchAdminTab's
|
||
// one-panel-visible invariant without booting the tab loaders.
|
||
if (q.get("tall")) {
|
||
var panels = document.querySelectorAll(".admin-panel");
|
||
for (var i = 0; i < panels.length; i++)
|
||
panels[i].style.display =
|
||
panels[i].id === "admin-users" ? "" : "none";
|
||
// No fallback: a fragment rename must fail loudly, not misplace rows.
|
||
var rowHost = document.querySelector("#admin-users [role=list]");
|
||
rowHost.textContent = ""; // drop the static "Loading users…" stub
|
||
for (var r = 0; r < 90; r++) {
|
||
var row = document.createElement("div");
|
||
row.className = "admin-row"; // real row chrome — geometry tracks production
|
||
row.textContent =
|
||
"user-" + String(r).padStart(3, "0") + " \\u00b7 synthetic row";
|
||
rowHost.appendChild(row);
|
||
}
|
||
var content = document.getElementById("admin-content");
|
||
if (content && q.get("scrolled"))
|
||
content.scrollTop =
|
||
q.get("scrolled") === "bottom"
|
||
? content.scrollHeight // the 24px scroll-tail state
|
||
: content.scrollHeight / 2; // land mid-list
|
||
}
|
||
setTimeout(function () {
|
||
if (open === "schedule-create") showCreateScheduleModal();
|
||
else if (open === "schedule-edit") showEditScheduleModal("t1");
|
||
else if (open === "model-create") showCreateModelModal();
|
||
else if (open === "model-edit" || open === "model-save")
|
||
showEditModelModal("def1");
|
||
else if (open === "policy") {
|
||
window._govPolicies && _govPolicies.length === 0 &&
|
||
loadGovPolicies && loadGovPolicies();
|
||
showCreatePolicyModal();
|
||
} else if (open === "confirm")
|
||
showConfirmModal(
|
||
"Delete schedule",
|
||
"Delete nightly-digest? Its run history is removed with it. This cannot be undone.",
|
||
"Delete",
|
||
function () {},
|
||
);
|
||
else if (open === "token")
|
||
showTokenCreatedModal(
|
||
"tsk_9f2e41c7a8b35d60e1f4a2b89c7d3e5f6a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d",
|
||
);
|
||
if (open === "model-save")
|
||
setTimeout(function () {
|
||
document.getElementById("model-create-submit").click();
|
||
}, 900);
|
||
if (q.get("busy"))
|
||
setTimeout(function () {
|
||
var d = document.querySelector("dialog[open]");
|
||
if (d) window.TurnstoneHatch.setBusy(d, true);
|
||
}, 400);
|
||
// A driven state that ends with nothing open must fail LOUDLY in
|
||
// the screenshot pipeline, not render a quietly dialog-less page.
|
||
setTimeout(function () {
|
||
var top = document.querySelector("dialog[open]");
|
||
if (open && !top) document.title = "OPEN-FAILED-" + open;
|
||
// &focuslast=1 — the displaced-dock regression probe: focus the
|
||
// last form control in the shelf BODY (the visually-hidden
|
||
// toggle/radio inputs live there). Only .sh-body may scroll;
|
||
// the head/foot strips must stay pinned in the screenshot.
|
||
if (top && q.get("focuslast")) {
|
||
var els = top.querySelectorAll(
|
||
".sh-body input, .sh-body select, .sh-body textarea",
|
||
);
|
||
if (els.length) els[els.length - 1].focus();
|
||
}
|
||
}, 600);
|
||
}, 150);
|
||
});
|
||
</script>
|
||
</body>
|
||
</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>
|
||
"""
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Attachments harness — the composer attachment chips + the sent-message
|
||
# attachment pills. Both are driven through the REAL code paths so the preview
|
||
# nodes render exactly as production builds them: createAttachmentController's
|
||
# rehydrate() renders the chips (renderChip -> _applyPreview ->
|
||
# buildAttachmentPreview), and Pane.addUserMessage() renders the pills (which
|
||
# call the same window.buildAttachmentPreview). The page frame is harness-only
|
||
# chrome and not under review; the chips row and the pill row are.
|
||
# --------------------------------------------------------------------------
|
||
ATTACH_TEMPLATE = """<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>attachments livepass</title>
|
||
<link rel="stylesheet" href="shared/base.css" />
|
||
<link rel="stylesheet" href="shared/ui-base.css" />
|
||
<link rel="stylesheet" href="shared/interactive.css" />
|
||
<link rel="stylesheet" href="shared/chat.css" />
|
||
<style>
|
||
/* Harness-only framing (NOT under review) — gives the two real surfaces
|
||
a plausible page context at a realistic pane width. */
|
||
body {
|
||
padding: 24px; margin: 0; display: flex; flex-direction: column;
|
||
gap: 28px; background: var(--bg); color: var(--fg);
|
||
font-family: var(--font-sans, system-ui, sans-serif);
|
||
}
|
||
.demo-label {
|
||
font: 11px var(--font-mono, monospace); color: var(--fg-dim);
|
||
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px;
|
||
}
|
||
.demo-frame {
|
||
width: 560px; max-width: 100%; border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm); overflow: hidden;
|
||
background: var(--bg-surface);
|
||
}
|
||
.messages { padding: 16px; }
|
||
/* Static composer chrome for context; the chips row is built by the
|
||
REAL createAttachmentController. */
|
||
.demo-textarea {
|
||
width: 100%; min-height: 44px; resize: none; background: var(--bg-elevated);
|
||
color: var(--fg); border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm); padding: 8px; font: inherit;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div>
|
||
<div class="demo-label">composer — attachment chips (real createAttachmentController)</div>
|
||
<div class="demo-frame">
|
||
<div class="composer">
|
||
<div class="composer-chips" id="chips" role="list" aria-label="Attachments"></div>
|
||
<div class="composer-row">
|
||
<textarea class="demo-textarea" placeholder="Message…"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="demo-label">conversation — sent-message attachment pills (real Pane.addUserMessage)</div>
|
||
<div class="demo-frame">
|
||
<div class="messages" id="messages"></div>
|
||
</div>
|
||
</div>
|
||
<div id="toast" role="status" aria-live="polite"></div>
|
||
<script>
|
||
// Committed-attachment fixtures (no `uploading`, real ids) — one of every
|
||
// kind plus a deliberately long filename to probe chip truncation/wrap.
|
||
window.__ATTACH = [
|
||
{ attachment_id: "att-image", kind: "image",
|
||
filename: "observatory-dome.jpg", size_bytes: 184320 },
|
||
{ attachment_id: "att-pdf", kind: "pdf",
|
||
filename: "q2-cluster-report.pdf", size_bytes: 529408 },
|
||
{ attachment_id: "att-audio", kind: "audio",
|
||
filename: "standup-2026-06-15.m4a", size_bytes: 2202009 },
|
||
{ attachment_id: "att-text", kind: "text",
|
||
filename: "release-notes-1.7.0a2.md", size_bytes: 4317 },
|
||
{ attachment_id: "att-longname", kind: "text",
|
||
filename: "a-deliberately-long-attachment-filename-that-truncates.md",
|
||
size_bytes: 8214 },
|
||
];
|
||
window.__SNIPPET =
|
||
"# Release notes \\u2014 1.7.0a2\\n\\nN-sample consensus voting lands behind " +
|
||
"a flag; reranker-primary retrieval replaces RRF as the default; " +
|
||
"coordinator memory is now keyed per user.";
|
||
// image/pdf thumbnails + the audio clip load via element .src and are
|
||
// served by the livepass fixture route; the text snippet is the only
|
||
// preview that flows through authFetch, so the stub answers .../content.
|
||
// Held under a private name: auth.js's legacy window bridge
|
||
// (Object.assign(window, {authFetch})) runs at module-import time and
|
||
// would clobber a plain window.authFetch — the module reinstates it
|
||
// below, after the imports have evaluated.
|
||
window.__attachFetch = function (url) {
|
||
var path = (url || "").split("?")[0];
|
||
function reply(ok, body, asText) {
|
||
return Promise.resolve({
|
||
ok: ok, status: ok ? 200 : 404,
|
||
json: function () { return Promise.resolve(body || {}); },
|
||
text: function () {
|
||
return Promise.resolve(asText != null ? asText : "");
|
||
},
|
||
});
|
||
}
|
||
if (/\\/attachments$/.test(path))
|
||
return reply(true, { attachments: window.__ATTACH });
|
||
// Any text /content gets the snippet (audio /content is served as
|
||
// bytes by the fixture route, never through authFetch).
|
||
if (path.indexOf("/content") !== -1)
|
||
return reply(true, {}, window.__SNIPPET);
|
||
return reply(true, {});
|
||
};
|
||
window.toast = { error: function (m) { console.log("toast:", m); } };
|
||
</script>
|
||
<script type="module">
|
||
import { createAttachmentController } from "./shared/composer_attachments.js";
|
||
import { InteractivePane } from "./shared/interactive.js";
|
||
const q = new URLSearchParams(location.search);
|
||
if (q.get("theme") === "light")
|
||
document.documentElement.dataset.theme = "light";
|
||
|
||
// Reinstate the fixture fetch now the imports (and auth.js's window
|
||
// bridge) have run — the pills' buildAttachmentPreview reads
|
||
// window.authFetch directly for the text snippet.
|
||
window.authFetch = window.__attachFetch;
|
||
|
||
// composer chips — drive the REAL controller. Pass the stub explicitly
|
||
// so the chip path never depends on window.authFetch timing.
|
||
const ctl = createAttachmentController({
|
||
chipsEl: document.getElementById("chips"),
|
||
getWsId: () => "demo-ws",
|
||
authFetch: window.__attachFetch,
|
||
});
|
||
await ctl.rehydrate();
|
||
|
||
// message pills — drive the REAL Pane.addUserMessage; stub only the
|
||
// host seams (scroll/empty-state/action-row) that need a mounted pane.
|
||
const pane = new InteractivePane("demo-ws");
|
||
pane.messagesEl = document.getElementById("messages");
|
||
pane.removeEmptyState = () => {};
|
||
pane._addUserMsgActions = () => {};
|
||
pane.scrollToBottom = () => {};
|
||
pane.addUserMessage(
|
||
"Please review the attached report, the dome photo, the standup " +
|
||
"recording, and the release notes.",
|
||
window.__ATTACH.slice(0, 4),
|
||
);
|
||
|
||
// Loud failure — a broken harness must not screenshot green.
|
||
setTimeout(function () {
|
||
const chips = document.querySelectorAll("#chips .composer-chip").length;
|
||
const pills = document.querySelectorAll(
|
||
"#messages .msg-user-attach-pill",
|
||
).length;
|
||
document.title =
|
||
chips && pills
|
||
? "ATTACH-READY-" + chips + "-" + pills
|
||
: "ATTACH-FAILED-c" + chips + "-p" + pills;
|
||
}, 800);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
# Fixture media for the attachments harness. image/pdf thumbnails and the
|
||
# audio clip load via element .src (NOT authFetch), so the --serve dev server
|
||
# answers those paths directly with representative bytes: a photo-like image,
|
||
# a document-page-like image for the PDF thumbnail, and a short WAV so the
|
||
# native <audio> control chrome renders against the constrained CSS height.
|
||
_FIXTURE_CACHE: dict[str, bytes] = {}
|
||
|
||
|
||
def _png_photo() -> bytes:
|
||
from io import BytesIO
|
||
|
||
from PIL import Image, ImageDraw
|
||
|
||
img = Image.new("RGB", (320, 320), (24, 27, 31))
|
||
d = ImageDraw.Draw(img)
|
||
for y in range(320): # warm vertical wash so object-fit crop is legible
|
||
t = y / 320
|
||
d.line([(0, y), (320, y)], fill=(int(20 + t * 60), int(18 + t * 40), int(26 + t * 70)))
|
||
d.ellipse([180, 36, 300, 156], fill=(229, 160, 66)) # amber "sun"
|
||
d.polygon([(0, 320), (130, 170), (250, 320)], fill=(38, 66, 58)) # hill
|
||
buf = BytesIO()
|
||
img.save(buf, "PNG")
|
||
return buf.getvalue()
|
||
|
||
|
||
def _png_page() -> bytes:
|
||
from io import BytesIO
|
||
|
||
from PIL import Image, ImageDraw
|
||
|
||
img = Image.new("RGB", (320, 414), (250, 250, 248)) # paper white, A4-ish
|
||
d = ImageDraw.Draw(img)
|
||
d.rectangle([0, 0, 320, 9], fill=(140, 94, 27)) # header rule
|
||
y = 30
|
||
for i, w in enumerate([260, 240, 280, 200, 250, 230, 270, 180, 255, 210, 240]):
|
||
shade = (40, 44, 54) if i == 0 else (150, 154, 164)
|
||
d.rectangle([28, y, 28 + w, y + 10], fill=shade)
|
||
y += 30
|
||
buf = BytesIO()
|
||
img.save(buf, "PNG")
|
||
return buf.getvalue()
|
||
|
||
|
||
def _wav() -> bytes:
|
||
import math
|
||
import struct
|
||
import wave
|
||
from io import BytesIO
|
||
|
||
buf = BytesIO()
|
||
frames = b"".join(
|
||
struct.pack("<h", int(2600 * math.sin(2 * math.pi * 440 * i / 8000))) for i in range(8000)
|
||
)
|
||
with wave.open(buf, "wb") as w:
|
||
w.setnchannels(1)
|
||
w.setsampwidth(2)
|
||
w.setframerate(8000)
|
||
w.writeframes(frames)
|
||
return buf.getvalue()
|
||
|
||
|
||
def _fixture_for(path: str) -> tuple[bytes, str] | None:
|
||
"""Map a media request path to (bytes, content-type), or None to fall through."""
|
||
if path.endswith("/thumbnail"):
|
||
key = "page" if "att-pdf" in path else "photo"
|
||
if key not in _FIXTURE_CACHE:
|
||
_FIXTURE_CACHE[key] = _png_page() if key == "page" else _png_photo()
|
||
return _FIXTURE_CACHE[key], "image/png"
|
||
if path.endswith("/content") and "att-audio" in path:
|
||
if "wav" not in _FIXTURE_CACHE:
|
||
_FIXTURE_CACHE["wav"] = _wav()
|
||
return _FIXTURE_CACHE["wav"], "audio/wav"
|
||
return None
|
||
|
||
|
||
def build(out: Path) -> None:
|
||
ui = out / "ui"
|
||
con = out / "console"
|
||
ui.mkdir(parents=True, exist_ok=True)
|
||
con.mkdir(parents=True, exist_ok=True)
|
||
|
||
symlink(ui / "shared", ROOT / "turnstone/shared_static")
|
||
symlink(ui / "static", ROOT / "turnstone/ui/static")
|
||
blocks = extract_dialogs(UI_INDEX)
|
||
# the coordinator batch dialog shares the cards.js builder — ride along
|
||
blocks += extract_dialogs(CONSOLE_INDEX, only_id="coord-delete-dialog")
|
||
(ui / "livepass.html").write_text(
|
||
inject(UI_TEMPLATE, "DIALOGS", "\n".join(blocks)), encoding="utf-8"
|
||
)
|
||
print(f"{ui}/livepass.html — {len(blocks)} dialogs")
|
||
|
||
symlink(con / "shared", ROOT / "turnstone/shared_static")
|
||
symlink(con / "console-static", ROOT / "turnstone/console/static")
|
||
frag = extract_admin_fragment()
|
||
# Dialog-tier markup living OUTSIDE #admin-layout (confirm, install,
|
||
# coord-delete) would otherwise be silently absent — and ?open=confirm
|
||
# would screenshot a dialog-less page while the gate stayed green.
|
||
riders = [b for b in extract_dialogs(CONSOLE_INDEX) if b not in frag]
|
||
page = inject(CONSOLE_TEMPLATE, "FRAGMENT", frag)
|
||
page = inject(page, "RIDERS", "\n".join(riders))
|
||
(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")
|
||
|
||
att = out / "attachments"
|
||
att.mkdir(parents=True, exist_ok=True)
|
||
symlink(att / "shared", ROOT / "turnstone/shared_static")
|
||
(att / "livepass.html").write_text(ATTACH_TEMPLATE, encoding="utf-8")
|
||
print(f"{att}/livepass.html — composer chips + message attachment pills")
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||
ap.add_argument("--out", type=Path, default=Path("/tmp/livepass"))
|
||
ap.add_argument("--serve", type=int, metavar="PORT")
|
||
args = ap.parse_args()
|
||
build(args.out)
|
||
if args.serve:
|
||
import functools
|
||
import http.server
|
||
|
||
class _FixtureHandler(http.server.SimpleHTTPRequestHandler):
|
||
# The attachments harness loads thumbnails + the audio clip via
|
||
# element .src; serve those from generated fixtures, fall through
|
||
# to static for everything else.
|
||
def do_GET(self) -> None: # noqa: N802 (stdlib casing)
|
||
blob = _fixture_for(self.path.split("?")[0])
|
||
if blob is None:
|
||
super().do_GET()
|
||
return
|
||
data, ctype = blob
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", ctype)
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
|
||
handler = functools.partial(_FixtureHandler, directory=str(args.out))
|
||
print(f"serving {args.out} on http://localhost:{args.serve}/ — Ctrl+C stops")
|
||
http.server.ThreadingHTTPServer(("127.0.0.1", args.serve), handler).serve_forever()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|