Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions packages/viewer/e2e/board-sort.e2e.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// End-to-end test for the sidebar board-sort toggle (Name / Created / Updated). Boots dist/server.js,
// pushes three boards so that name order, created order, and updated order are all DIFFERENT, then
// drives the toggle in a real browser and asserts the row order + the "latest" tag + persistence.
//
// node e2e/board-sort.e2e.mjs (run `npm run build` first, or use `npm run test:e2e`)
//
// Exit 0 = all assertions passed, 1 = a failure. No network/cloud needed.
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const HERE = dirname(fileURLToPath(import.meta.url));
const VIEWER = join(HERE, "..");
const PORT = Number(process.env.E2E_PORT ?? 8197);
const TOKEN = "e2e-token";
const BASE = `http://127.0.0.1:${PORT}`;
const WSID = "sort";
const U = `${BASE}/w/${WSID}/`;

const results = [];
const ok = (n, c) => { results.push(c); console.log(`${c ? "PASS" : "FAIL"} ${n}`); };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function push(agent) {
const r = await fetch(`${BASE}/w/${WSID}/push`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` },
body: JSON.stringify({ project: "s", agent, type: "markdown", content: `# ${agent}`, description: agent }),
});
if (!r.ok) throw new Error(`push ${agent} -> ${r.status}`);
}

async function waitForServer(timeoutMs = 15000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try { const r = await fetch(`${BASE}/healthz`); if (r.ok) return; } catch { /* not up */ }
await sleep(200);
}
throw new Error("server did not start");
}

// Read the board rows' scope keys top-to-bottom (excludes the ★ Welcome home button, which is not
// in a .scope-row).
const order = (p) => p.$$eval("#scopes .scope-row .scope", (els) => els.map((e) => e.getAttribute("title")));
// Click a sort segment by its visible label.
const clickSort = async (p, label) => { await p.locator(".scope-sort-btn", { hasText: label }).click(); await p.waitForTimeout(150); };

const srv = spawn("node", [join(VIEWER, "dist", "server.js")], {
env: { ...process.env, PORT: String(PORT), PUSH_TOKEN: TOKEN },
stdio: ["ignore", "ignore", "inherit"],
});

let browser;
try {
await waitForServer();

// Push order sets createdAt; a re-push of bravo at the end makes it the newest UPDATED while it
// stays the oldest CREATED. Sleeps keep the ms-resolution timestamps strictly ordered.
await push("bravo"); await sleep(30); // created: oldest
await push("alpha"); await sleep(30);
await push("charlie"); await sleep(30); // created: newest
await push("bravo"); // updated: newest (createdAt preserved = oldest)

// name : alpha, bravo, charlie (A→Z)
// created : charlie, alpha, bravo (newest-created first)
// updated : bravo, charlie, alpha (newest-updated first)
const byName = ["s/alpha", "s/bravo", "s/charlie"];
const byCreated = ["s/charlie", "s/alpha", "s/bravo"];
const byUpdated = ["s/bravo", "s/charlie", "s/alpha"];

browser = await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] });
const p = await browser.newPage({ viewport: { width: 1100, height: 800 } });
const errors = [];
p.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
p.on("pageerror", (e) => errors.push(String(e)));
await p.goto(U, { waitUntil: "domcontentloaded", timeout: 20000 });
await p.waitForFunction(() => document.querySelectorAll("#scopes .scope-row").length === 3, null, { timeout: 15000 });

ok("sort toggle renders (3 segments)", (await p.locator(".scope-sort-btn").count()) === 3);
ok("each board row shows a relative-time label", (await p.locator("#scopes .scope-row .scope-ago").count()) === 3);

// Default is Name (alphabetical).
ok("default order is by name (A→Z)", JSON.stringify(await order(p)) === JSON.stringify(byName));
ok("no 'latest' tag in name mode", (await p.locator("#scopes .scope-latest").count()) === 0);

await clickSort(p, "Created");
ok("created mode → newest-created first", JSON.stringify(await order(p)) === JSON.stringify(byCreated));
ok("created mode tags exactly one 'latest' (the top row)", (await p.locator("#scopes .scope-latest").count()) === 1);
ok("created 'latest' tag is on the first row", await p.evaluate(() =>
document.querySelector("#scopes .scope-row .scope")?.querySelector(".scope-latest") !== null));

await clickSort(p, "Updated");
ok("updated mode → newest-updated first", JSON.stringify(await order(p)) === JSON.stringify(byUpdated));
ok("updated mode tags exactly one 'latest'", (await p.locator("#scopes .scope-latest").count()) === 1);

// Persistence: reload keeps the chosen mode (localStorage) and its order.
await p.reload({ waitUntil: "domcontentloaded" });
await p.waitForFunction(() => document.querySelectorAll("#scopes .scope-row").length === 3, null, { timeout: 15000 });
ok("chosen sort persists across reload (Updated still active)",
(await p.locator('.scope-sort-btn[aria-pressed="true"]').textContent()) === "Updated");
ok("persisted order is still by updated", JSON.stringify(await order(p)) === JSON.stringify(byUpdated));

ok("no console/page errors", errors.length === 0 || (console.log(" errors:", errors.slice(0, 5)), false));
} catch (e) {
ok(`harness error: ${e.message}`, false);
} finally {
if (browser) await browser.close();
srv.kill("SIGTERM");
}

const failed = results.filter((r) => !r).length;
console.log(`\n=== ${results.length - failed}/${results.length} passed (board-sort e2e) ===`);
process.exit(failed ? 1 : 0);
4 changes: 2 additions & 2 deletions packages/viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"start": "node dist/server.js",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "npm run build && node e2e/rich.e2e.mjs",
"test:e2e:nobuild": "node e2e/rich.e2e.mjs",
"test:e2e": "npm run build && node e2e/rich.e2e.mjs && node e2e/board-sort.e2e.mjs",
"test:e2e:nobuild": "node e2e/rich.e2e.mjs && node e2e/board-sort.e2e.mjs",
"test:overlap": "npm run build && node e2e/overlap.e2e.mjs",
"test:overlap:nobuild": "node e2e/overlap.e2e.mjs",
"test:stress": "npm run build && node e2e/stress.e2e.mjs"
Expand Down
19 changes: 19 additions & 0 deletions packages/viewer/src/client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,25 @@ body {
.scope-home { font-weight: 600; margin-bottom: 6px; border-bottom: 1px solid var(--border); border-radius: 6px; }
.empty-hint { color: var(--muted); font-size: 12px; padding: 6px 8px; margin: 0; }

/* Sort toggle (Name / Created / Updated): a compact segmented control above the board list. */
.scope-sort { display: flex; gap: 2px; margin: 0 0 6px; padding: 2px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; }
.scope-sort-btn {
flex: 1; min-height: 30px; padding: 4px 6px;
background: transparent; border: 1px solid transparent; border-radius: 4px;
color: var(--muted); font: inherit; font-size: 11px; cursor: pointer;
transition: background .15s ease, color .15s ease;
}
.scope-sort-btn:hover { color: var(--fg); }
.scope-sort-btn[aria-pressed="true"] { background: var(--active); color: var(--fg); }
.scope-sort-btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
/* Per-board recency line under the agent name: "latest" tag + relative time. */
.scope-time { display: flex; align-items: center; gap: 5px; margin-top: 2px; font-size: 10px; line-height: 1.3; color: var(--muted); }
.scope-ago { white-space: nowrap; }
.scope-latest {
flex-shrink: 0; padding: 0 5px; border-radius: 999px; font-size: 9px; font-weight: 600;
text-transform: uppercase; letter-spacing: .3px; color: var(--bg); background: var(--accent);
}

main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
header { display: flex; gap: 16px; align-items: center; padding: 8px 12px; border-bottom: 1px solid var(--border); }
/* ≥44px hit area (iPad tap target); the svg stays ~16px, centered. */
Expand Down
88 changes: 84 additions & 4 deletions packages/viewer/src/client/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { INTRO_PANES } from "./intro.js";
import { setActiveScope } from "./interact.js";
import { initConsole, setConsoleScope, onInboxEvent, onInboxRead, onSuggest, resyncConsole } from "./console.js";
import type { PatchOp } from "../flow-patch.js";
import { sortBoards, type BoardSort } from "../state.js";

interface Payload { project: string; agent: string; type: string; content: string; ts: number; }
interface Payload { project: string; agent: string; type: string; content: string; ts: number; createdAt?: number; }
const key = (p: { project: string; agent: string }) => `${p.project}/${p.agent}`;

// Two surfaces share this bundle: the normal workspace `/w/<wsid>/` and a single-board SHARE view
Expand Down Expand Up @@ -48,6 +49,22 @@ const DEMO_INTROS: Record<string, string> = {
};

const $ = (id: string) => document.getElementById(id)!;

// How the board list is ordered: by Name (A→Z, the historical default), Created, or Updated (both
// newest-first). Persisted so the choice survives reloads. The labels for the sidebar toggle.
const SORT_KEY = "tc-board-sort";
const SORT_LABELS: Record<BoardSort, string> = { name: "Name", created: "Created", updated: "Updated" };
function readBoardSort(): BoardSort {
try { const v = localStorage.getItem(SORT_KEY); if (v === "name" || v === "created" || v === "updated") return v; } catch { /* */ }
return "name";
}
let boardSort: BoardSort = readBoardSort();
function setBoardSort(mode: BoardSort): void {
boardSort = mode;
try { localStorage.setItem(SORT_KEY, mode); } catch { /* */ }
renderSidebar();
}

// "Follow the focused board" is a toggle button (aria-pressed). Track the mode in JS and mirror it
// onto the button, instead of reading a checkbox.
let following = true;
Expand Down Expand Up @@ -129,15 +146,23 @@ function renderSidebar() {
home.textContent = "★ Welcome";
home.onclick = () => selectIntro();
nav.appendChild(home);
const keys = [...scopes.keys()].sort();
if (keys.length === 0) {
const ordered = sortBoards(
[...scopes].map(([key, p]) => ({ key, ts: p.ts, createdAt: p.createdAt })),
boardSort,
).map((e) => e.key);
if (ordered.length === 0) {
const hint = document.createElement("p");
hint.className = "empty-hint";
hint.textContent = "no boards yet";
nav.appendChild(hint);
return;
}
for (const k of keys) {
// Offer the sort toggle only once there's something to sort. In created/updated modes the first
// row is the most-recently-touched board, so flag it "latest".
if (ordered.length >= 2) nav.appendChild(buildSortToggle());
const latestKey = boardSort === "name" ? "" : ordered[0];
for (const k of ordered) {
const p = scopes.get(k);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "scope" + (k === current ? " active" : "");
Expand All @@ -160,6 +185,27 @@ function renderSidebar() {
a.className = "scope-agent";
a.textContent = agent;
label.appendChild(a);
// Recency line: the time matching the active sort key (created → createdAt; name/updated → ts),
// kept fresh in place by refreshSidebarTimes(). Tooltip carries both absolute stamps. The single
// most-recent board (created/updated modes) wears a "latest" tag.
if (p) {
const stamp = boardSort === "created" ? (p.createdAt ?? p.ts) : p.ts;
const meta = document.createElement("span");
meta.className = "scope-time";
meta.title = `created ${absTime(p.createdAt ?? p.ts)} · updated ${absTime(p.ts)}`;
if (k === latestKey) {
const tag = document.createElement("span");
tag.className = "scope-latest";
tag.textContent = "latest";
meta.appendChild(tag);
}
const ago = document.createElement("span");
ago.className = "scope-ago";
ago.dataset.t = String(stamp);
ago.textContent = relTime(stamp);
meta.appendChild(ago);
label.appendChild(meta);
}
btn.title = k; // full scope on hover
btn.appendChild(label);

Expand Down Expand Up @@ -221,6 +267,40 @@ function renderSidebar() {
nav.appendChild(all);
}

/** The Name / Created / Updated segmented control shown above the board list. */
function buildSortToggle(): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "scope-sort";
wrap.setAttribute("role", "group");
wrap.setAttribute("aria-label", "Sort boards");
for (const mode of ["name", "created", "updated"] as const) {
const b = document.createElement("button");
b.type = "button";
b.className = "scope-sort-btn";
b.textContent = SORT_LABELS[mode];
b.setAttribute("aria-pressed", boardSort === mode ? "true" : "false");
b.title = mode === "name" ? "Sort A→Z" : `Sort by ${mode} time (newest first)`;
b.onclick = () => setBoardSort(mode);
wrap.appendChild(b);
}
return wrap;
}

/** Absolute timestamp for the recency tooltip. */
function absTime(ts: number): string {
if (!ts) return "unknown";
try { return new Date(ts).toLocaleString(); } catch { return ""; }
}

// Keep the "Xm ago" labels current while the screen sits idle (no push to trigger a re-render).
// Updates text in place — no DOM rebuild, so focus/scroll are untouched.
function refreshSidebarTimes(): void {
document.querySelectorAll<HTMLElement>(".scope-ago[data-t]").forEach((el) => {
el.textContent = relTime(Number(el.dataset.t));
});
}
setInterval(refreshSidebarTimes, 60_000);

function fitScale() {
const pre = $("diagram"); const stage = $("stage");
pre.style.transform = "none";
Expand Down
13 changes: 10 additions & 3 deletions packages/viewer/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,23 @@ const SEED: SeedScope[] = [
/** Populate the demo workspace with its full-scope fixtures (no-op if already populated). */
export function seedDemo(store: Store): void {
if (store.list(DEMO_WSID).length > 0) return;
const ts = Date.now();
for (const s of SEED) {
const now = Date.now();
const HOUR = 3_600_000, MIN = 60_000;
SEED.forEach((s, i) => {
// Demo-only: stagger the timestamps so the sidebar's Name / Created / Updated sort toggle visibly
// reorders the list. `createdAt` ages with seed order (first seeded = oldest); `ts` (updated) is
// permuted ((i*5) mod 12 is a permutation since gcd(5,12)=1) so updated order differs from both.
const createdAt = now - (SEED.length - i) * 5 * HOUR;
const ts = now - ((i * 5) % SEED.length) * 23 * MIN;
const p: Payload = {
project: s.project,
agent: s.agent,
type: s.type,
description: s.description,
content: JSON.stringify(s.content),
ts,
createdAt,
};
store.set(DEMO_WSID, p);
}
});
}
20 changes: 10 additions & 10 deletions packages/viewer/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,9 +440,9 @@ export function createViewerServer(opts: ServerOpts) {
const invalid = validateContent(at.type, at.content);
if (invalid) return send(res, 400, invalid);
const next: Payload = { project: b.project, agent: b.agent, type: at.type, content: at.content, description: at.description || `restored v${b.seq}`, ts: Date.now() };
store.set(wsid, next);
const stored = store.set(wsid, next);
await persistSave(wsid);
fanout(wsid, { event: "update", data: next });
fanout(wsid, { event: "update", data: stored });
await recordAction(wsid, scope, { kind: "restore", ts: next.ts, summary: `restored v${b.seq}`, type: next.type, content: next.content, description: next.description });
return send(res, 204);
}
Expand Down Expand Up @@ -519,7 +519,7 @@ export function createViewerServer(opts: ServerOpts) {
const invalid = validateContent(cur.type, content);
if (invalid) return send(res, 400, invalid);
const next: Payload = { ...cur, content, ts: Date.now() };
store.set(wsid, next);
const stored = store.set(wsid, next);
await persistSave(wsid);
// Broadcast the merged payload PLUS an equivalent `setChecked` op (parsed from the validated
// ref). A viewer already showing this component applies it IN PLACE (React diff, no remount —
Expand All @@ -528,7 +528,7 @@ export function createViewerServer(opts: ServerOpts) {
const sl = (b.ref as string).indexOf("/");
const item = (b.ref as string).slice(sl + 1);
const ops = [{ op: "setChecked", id: (b.ref as string).slice(0, sl), item, value: b.value }];
fanout(wsid, { event: "update", data: { ...next, ops } });
fanout(wsid, { event: "update", data: { ...stored, ops } });
await recordAction(wsid, `${b.project}/${b.agent}`, { kind: "interact", ts: next.ts, summary: `${b.value ? "ticked" : "unticked"} ${item}`, ops });
return send(res, 204);
}
Expand Down Expand Up @@ -607,9 +607,9 @@ export function createViewerServer(opts: ServerOpts) {
if (strict && sev && meetsThreshold(sev, strict))
return send(res, 422, JSON.stringify({ rejected: true, reason: "geometry", maxSeverity: sev, findings, warnings }), "application/json");
if (!(await hydratedForWrite(wsid))) return send(res, 503, "state backend unavailable; refusing to modify to avoid data loss");
store.set(wsid, p);
const stored = store.set(wsid, p);
await persistSave(wsid);
fanout(wsid, { event: "update", data: p });
fanout(wsid, { event: "update", data: stored });
await recordAction(wsid, `${p.project}/${p.agent}`, { kind: "push", ts: p.ts, summary: p.description, type: p.type, content: p.content, description: p.description });
setInboxHeader(res, wsid, `${p.project}/${p.agent}`);
// Non-fatal: the push succeeded; findings are signalled back so the pusher can fix or weigh.
Expand Down Expand Up @@ -652,15 +652,15 @@ export function createViewerServer(opts: ServerOpts) {
const invalid = validateContent(cur.type, content);
if (invalid) return send(res, 400, invalid);
const next: Payload = { ...cur, content, ts: Date.now() };
store.set(wsid, next);
const stored = store.set(wsid, next);
await persistSave(wsid);
// Broadcast the full merged payload (any client renders it as a normal update) PLUS the `ops`,
// so a client already showing this view can apply them IN PLACE: flow updates nodes without
// re-layout/refit; component re-renders the same React root without a remount (preserving
// tab/scroll/focus); panes delegates a `patchPane` to just that sub-pane (the other panes stay
// mounted). A client that can't apply an op falls back to a full re-render. The stored payload
// (above) stays clean — `ops` rides only the wire.
fanout(wsid, { event: "update", data: { ...next, ops: b.ops } });
fanout(wsid, { event: "update", data: { ...stored, ops: b.ops } });
await recordAction(wsid, `${b.project}/${b.agent}`, { kind: "patch", ts: next.ts, summary: summarizeOps(b.ops), ops: b.ops });
setInboxHeader(res, wsid, `${b.project}/${b.agent}`);
const warnings = cur.type === "flow" ? geometryWarnings("flow", content) : [];
Expand All @@ -672,8 +672,8 @@ export function createViewerServer(opts: ServerOpts) {
// `list` = lightweight catalog for discovery; `state` = full spec(s) to pull and iterate on.
if (req.method === "GET" && action === "list") {
await hydratedForRead(wsid);
const catalog = store.list(wsid).map(({ project, agent, type, description, ts }) => ({
project, agent, type, description, ts,
const catalog = store.list(wsid).map(({ project, agent, type, description, ts, createdAt }) => ({
project, agent, type, description, ts, createdAt,
}));
return send(res, 200, JSON.stringify(catalog), "application/json");
}
Expand Down
Loading
Loading