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
22 changes: 22 additions & 0 deletions scripts/bind-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
19 changes: 17 additions & 2 deletions scripts/scope-check.ts
Original file line number Diff line number Diff line change
@@ -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 });

Expand All @@ -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");
11 changes: 7 additions & 4 deletions src/lib/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ??
Expand Down
10 changes: 8 additions & 2 deletions src/lib/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolEvent[]> {
const d = await getDb();
Expand All @@ -111,7 +118,6 @@ export async function listToolEvents(cwd: string, limit = 50): Promise<ToolEvent
Grep: "Searched",
Glob: "Searched",
};
const base = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
return rows.map((r) => {
let tool = "?";
let detail = "";
Expand All @@ -128,7 +134,7 @@ export async function listToolEvents(cwd: string, limit = 50): Promise<ToolEvent
// else verb + filename, else the tool name.
plain =
description ||
(filePath ? `${VERB[tool] ?? tool} ${base(filePath)}` : "") ||
(filePath ? `${VERB[tool] ?? tool} ${basename(filePath)}` : "") ||
(command ? `Ran ${command.slice(0, 60)}` : tool);
} catch {
// keep defaults
Expand Down
Loading