diff --git a/scripts/bind-check.ts b/scripts/bind-check.ts index 5640f13..6f69c99 100644 --- a/scripts/bind-check.ts +++ b/scripts/bind-check.ts @@ -78,6 +78,28 @@ assert.equal( null, "a rootless session hijacked the active tab" ); +// Same hazard on Windows: a drive root is as rootless as `/`. +for (const root of ["C:\\", "C:/", "C:", "\\", "d:\\"]) { + assert.equal( + bind(ev(), two, { active: "tab-1", projectKey: root }), + null, + `a rootless session (${root}) hijacked the active tab` + ); +} +// ...but a real Windows project must still bind. Over-rejecting here would +// silently stop every Windows session from binding — a worse bug, and a quieter +// one, than the drive root this guard exists to catch. +const win = "C:\\Users\\x\\dev\\proj"; +assert.equal( + bind(ev(), [tab("tab-1", win)], { projectKey: win }), + "tab-1", + "root guard over-rejected a real Windows project path" +); +assert.equal( + bind(ev(), [tab("tab-1", "C:/Users/x/dev/proj")], { projectKey: "C:/Users/x/dev/proj" }), + "tab-1", + "root guard over-rejected a forward-slashed Windows path" +); // A dead tab is never a fallback target. assert.equal( bind(ev(), [{ id: "tab-1", cwd: "/Users/x/dev/other", status: "exited" }], { active: "tab-1" }), diff --git a/scripts/scope-check.ts b/scripts/scope-check.ts index e74389c..f4bcf08 100644 --- a/scripts/scope-check.ts +++ b/scripts/scope-check.ts @@ -1,6 +1,7 @@ -// Self-check for panel session scoping. Run: npm run scope:check +// Self-check for panel row shaping: session scoping + the Accomplished +// panel's filename. Run: npm run scope:check import { strict as assert } from "node:assert"; -import { scopeBySession } from "../src/lib/repo"; +import { basename, scopeBySession } from "../src/lib/repo"; const row = (id: number, sessionId: string) => ({ id, session_id: sessionId }); @@ -25,4 +26,18 @@ assert.deepEqual(scopeBySession(rows, "s-unrelated"), [], "unrelated session mat // fallback, same behavior as before this fix existed. assert.equal(scopeBySession(rows, null).length, 4, "null session id should fall back to the unfiltered list"); +// --- Accomplished panel filename, either separator. --- +// The backslash case is the bug: splitting on `/` alone left the path whole and +// the panel rendered "Edited C:\Users\x\dev\proj\main.rs". +assert.equal(basename("C:\\Users\\x\\dev\\proj\\main.rs"), "main.rs", "backslash path not shortened"); +assert.equal(basename("/Users/x/dev/proj/main.rs"), "main.rs", "posix path not shortened"); +assert.equal(basename("C:/Users/x/dev/proj/main.rs"), "main.rs", "mixed-separator path not shortened"); +// Trailing separator: filter(Boolean) drops the empty tail, so a directory-ish +// path still names its last real segment rather than returning "". +assert.equal(basename("/Users/x/dev/proj/"), "proj"); +// Already bare, and the degenerate inputs — never return "" to the panel. +assert.equal(basename("main.rs"), "main.rs"); +assert.equal(basename("/"), "/"); +assert.equal(basename(""), ""); + console.log("scope-check: all assertions passed"); diff --git a/src/lib/ingest.ts b/src/lib/ingest.ts index f5f834c..2a9e153 100644 --- a/src/lib/ingest.ts +++ b/src/lib/ingest.ts @@ -115,10 +115,13 @@ export function bindSession( } const { boundTabIds, activeTabId, projectKey } = opts; if (!projectKey) return null; - // `/` is never a real project: it means the session was started somewhere with - // no meaningful cwd. Binding it would overwrite the tab's cwd with a key that - // matches nothing, blanking the panel. Untethered + rootless = not ours. - if (projectKey === "/") return null; + // A filesystem root is never a real project: it means the session was started + // somewhere with no meaningful cwd. Binding it would overwrite the tab's cwd + // with a key that matches nothing, blanking the panel. Untethered + rootless = + // not ours. A Windows drive root (`C:\`, `C:/`, bare `C:`) is the same hazard. + // Deliberately anchored and exact — a prefix test would reject `/Users/x` and + // `C:\dev\proj` too, which fails far more quietly than the bug it fixes. + if (/^(?:[/\\]|[A-Za-z]:[/\\]?)$/.test(projectKey)) return null; return ( tabs.find((t) => t.cwd === projectKey && !boundTabIds.has(t.id))?.id ?? tabs.find((t) => t.cwd === projectKey)?.id ?? diff --git a/src/lib/repo.ts b/src/lib/repo.ts index 946d7e9..1fdc8fb 100644 --- a/src/lib/repo.ts +++ b/src/lib/repo.ts @@ -94,6 +94,13 @@ export async function addEvent(sessionId: string, type: string, payloadJson: str ); } +/** Last path segment, for either separator. Agent payloads carry native paths, + * so a Windows `file_path` arrives backslashed — splitting on `/` alone returned + * the whole path and the Accomplished panel printed it verbatim. */ +export function basename(p: string): string { + return p.split(/[\\/]/).filter(Boolean).pop() ?? p; +} + /** Accomplished panel: recent tool uses for a project, straight off the events table. */ export async function listToolEvents(cwd: string, limit = 50): Promise { const d = await getDb(); @@ -111,7 +118,6 @@ export async function listToolEvents(cwd: string, limit = 50): Promise p.split("/").filter(Boolean).pop() ?? p; return rows.map((r) => { let tool = "?"; let detail = ""; @@ -128,7 +134,7 @@ export async function listToolEvents(cwd: string, limit = 50): Promise