feat: add multi-session desktop workspace

This commit is contained in:
2026-08-13 21:34:04 +02:00
parent 12b9b83d51
commit 475f95ba70
56 changed files with 8998 additions and 3805 deletions
+12 -3
View File
@@ -1,16 +1,25 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createAgentRegistry } from "../src/bridge/agent-registry.js";
import {
createAgentRegistry,
sessionDirectoryPath,
} from "../src/bridge/agent-registry.js";
test("recovers an exited agent with its last Pi session reference", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-recovery-"));
const home = join(root, "home");
const sessionRoot = join(root, "sessions");
await mkdir(home);
const sessionFile = join(sessionRoot, "pi-session.jsonl");
const sessionDir = sessionDirectoryPath(sessionRoot, home);
await mkdir(sessionDir, { recursive: true });
const sessionFile = join(sessionDir, "pi-session.jsonl");
await writeFile(
sessionFile,
`${JSON.stringify({ type: "session", version: 3, id: "recovery", cwd: home })}\n`,
);
const calls = [];
const registry = createAgentRegistry({
homeWorktree: home,
+43 -15
View File
@@ -134,6 +134,10 @@ test("drains a forget still resolving its canonical path during shutdown", async
await stop();
};
const selectedFeature = registry
.listAgents()
.find((agent) => agent.worktreePath === worktrees.feature);
await registry.closeSessionRuntime(selectedFeature.runtimeId);
const forgetting = registry.forgetDirectory(featureAlias);
const stopping = registry.stop();
await Promise.all([forgetting, stopping]);
@@ -170,6 +174,11 @@ test("forgets a directory without deleting its resumable session history", async
`${JSON.stringify({ sessionPath, worktreePath: worktrees.feature })}\n`,
);
await assert.rejects(
registry.forgetDirectory(worktrees.feature),
/runtimes are open/,
);
await registry.closeSessionRuntime(feature.runtimeId);
await registry.forgetDirectory(worktrees.feature);
assert.equal(fixture.calls[1].adapter.stopped, true);
@@ -227,10 +236,14 @@ test("coordinates forgetting with directory creation and active commands", async
const selecting = registry.selectWorktree(worktrees.feature);
await featureStarted;
const forgettingDuringCreation = registry.forgetDirectory(worktrees.feature);
releaseFeatureCreation();
await selecting;
await forgettingDuringCreation;
const selectedDuringCreation = await selecting;
await assert.rejects(
registry.forgetDirectory(worktrees.feature),
/runtimes are open/,
);
await registry.closeSessionRuntime(selectedDuringCreation.runtimeId);
await registry.forgetDirectory(worktrees.feature);
assert.equal(fixture.calls[1].adapter.stopped, true);
assert.equal(
(await registry.listDirectories()).some(
@@ -252,15 +265,18 @@ test("coordinates forgetting with directory creation and active commands", async
await stopGate;
await stop();
};
const firstForget = registry.forgetDirectory(worktrees.feature);
const secondForget = registry.forgetDirectory(worktrees.feature);
await new Promise((resolve) => setImmediate(resolve));
await assert.rejects(
registry.selectWorktree(worktrees.feature),
/being forgotten/,
registry.forgetDirectory(worktrees.feature),
/runtimes are open/,
);
const closing = registry.closeSessionRuntime(
registry.listAgents().find((agent) => agent.worktreePath === worktrees.feature)
.runtimeId,
);
await new Promise((resolve) => setImmediate(resolve));
releaseStop();
await Promise.all([firstForget, secondForget]);
await closing;
await registry.forgetDirectory(worktrees.feature);
assert.equal(stopCalls, 1);
assert.equal(registry.listAgents().length, 1);
@@ -276,15 +292,15 @@ test("coordinates forgetting with directory creation and active commands", async
const prompt = registry.route(feature.id, "prompt", {
message: "Keep working",
});
const forgettingDuringPrompt = registry.forgetDirectory(worktrees.feature);
await new Promise((resolve) => setImmediate(resolve));
await assert.rejects(
registry.forgetDirectory(worktrees.feature),
/runtimes are open/,
);
assert.equal(adapter.stopped, false);
releasePrompt({ type: "response", success: true });
await prompt;
await assert.rejects(forgettingDuringPrompt, /while Pi is working/);
assert.equal(adapter.stopped, false);
options.onEvent({ type: "agent_state", data: { state: "idle" } });
await registry.closeSessionRuntime(feature.runtimeId);
await registry.forgetDirectory(worktrees.feature);
assert.equal(adapter.stopped, true);
@@ -307,14 +323,19 @@ test("coordinates forgetting with directory creation and active commands", async
});
rejectPrompt(new Error("send failed"));
await failedPromptAssertion;
assert.equal(
registry.listAgents().find((entry) => entry.id === failedFeature.id).state,
"streaming",
);
await assert.rejects(
registry.forgetDirectory(worktrees.feature),
/while Pi is working/,
/runtimes are open/,
);
failedFixture.options.onEvent({
type: "agent_state",
data: { state: "idle" },
});
await registry.closeSessionRuntime(failedFeature.runtimeId);
await registry.forgetDirectory(worktrees.feature);
await registry.stop();
});
@@ -389,6 +410,7 @@ test("catalogues managed directories and exposes only their sessions", async ()
}),
/ENOENT|belong/,
);
await registry.closeSessionRuntime(feature.runtimeId);
await registry.stop();
const restored = createAgentRegistry({
@@ -444,11 +466,17 @@ test("routes commands by explicit agent ID and replays only events after the cur
command: "get_commands",
success: true,
});
await registry.route(agent.id, "set_session_name", { name: "Release UI" });
await registry.route(agent.id, "compact", {
customInstructions: "Preserve implementation details",
});
assert.deepEqual(adapter.sent, [
{ type: "get_state" },
{ type: "prompt", message: "Stay in this worktree" },
{ type: "get_session_stats" },
{ type: "get_commands" },
{ type: "set_session_name", name: "Release UI" },
{ type: "compact", customInstructions: "Preserve implementation details" },
]);
options.onEvent({
+62 -1
View File
@@ -18,11 +18,16 @@ function connect(socketPath) {
function request(socket, frame) {
return new Promise((resolve, reject) => {
let buffer = "";
const onError = (error) => {
socket.off("data", onData);
reject(error);
};
const onData = (chunk) => {
buffer += chunk;
const newline = buffer.indexOf("\n");
if (newline === -1) return;
socket.off("data", onData);
socket.off("error", onError);
try {
resolve(JSON.parse(buffer.slice(0, newline)));
} catch (error) {
@@ -30,7 +35,7 @@ function request(socket, frame) {
}
};
socket.on("data", onData);
socket.once("error", reject);
socket.once("error", onError);
socket.write(`${JSON.stringify(frame)}\n`);
});
}
@@ -89,6 +94,32 @@ test("starts the home agent and dispatches local protocol requests to the select
payload: { worktreePath: feature },
});
const featureAgent = selected.result.agent;
const workspace = await request(socket, {
version: "v1",
id: "workspace-1",
op: "get_workspace",
});
const summary = await request(socket, {
version: "v1",
id: "summary-1",
op: "get_workspace_summary",
});
assert.equal(typeof workspace.result.bridgeInstanceId, "string");
assert.equal(workspace.result.directories.length, 2);
assert.equal(summary.result.openCount, 2);
assert.equal(summary.result.directoryCount, 2);
assert.equal(summary.result.resourceWarning, false);
const snapshot = await request(socket, {
version: "v1",
id: "snapshot-1",
op: "get_session_runtime_snapshot",
payload: { runtimeId: featureAgent.runtimeId },
});
assert.equal(snapshot.result.runtime.runtimeId, featureAgent.runtimeId);
assert.equal(
snapshot.result.bridgeInstanceId,
workspace.result.bridgeInstanceId,
);
const prompt = await request(socket, {
version: "v1",
id: "prompt-1",
@@ -98,18 +129,48 @@ test("starts the home agent and dispatches local protocol requests to the select
});
assert.equal(prompt.ok, true);
const named = await request(socket, {
version: "v1",
id: "name-1",
op: "set_session_name",
agentId: featureAgent.id,
payload: { name: "Feature work" },
});
const compacted = await request(socket, {
version: "v1",
id: "compact-1",
op: "compact",
agentId: featureAgent.id,
payload: { customInstructions: "Preserve tests" },
});
assert.equal(named.ok, true);
assert.equal(compacted.ok, true);
assert.deepEqual(
fixture.calls.map(({ options }) => options.cwd),
[home, feature],
);
assert.deepEqual(fixture.calls[1].adapter.sent, [
{ type: "get_state" },
{ type: "get_state" },
{ type: "get_session_stats" },
{ type: "get_messages" },
{ type: "get_commands" },
{ type: "get_available_models" },
{ type: "prompt", message: "Use this worktree" },
{ type: "set_session_name", name: "Feature work" },
{ type: "compact", customInstructions: "Preserve tests" },
]);
fixture.calls[1].options.onEvent({
type: "agent_state",
data: { state: "idle" },
});
const closed = await request(socket, {
version: "v1",
id: "close-1",
op: "close_session_runtime",
payload: { runtimeId: featureAgent.runtimeId },
});
assert.equal(closed.ok, true);
const forgotten = await request(socket, {
version: "v1",
+15 -55
View File
@@ -10,64 +10,24 @@ test("starts a bridge daemon around the local bridge service", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-daemon-"));
const home = join(root, "home");
await mkdir(home);
const daemon = await startBridgeDaemon({
homeWorktree: home,
runtimeDir: join(root, "runtime"),
sessionRoot: join(root, "sessions"),
startAdapter: () => ({
send: async () => ({ type: "response", success: true }),
respondToExtension: () => {},
stop: async () => {},
}),
});
try {
assert.match(daemon.socketPath, /bridge\.sock$/);
assert.equal(daemon.listAgents()[0].worktreePath, home);
} finally {
await daemon.close();
}
const daemon = await startBridgeDaemon({ homeWorktree: home, runtimeDir: join(root, "runtime"), sessionRoot: join(root, "sessions"), startAdapter: () => ({ send: async () => ({ type: "response", success: true }), respondToExtension: () => {}, stop: async () => {} }) });
try { assert.match(daemon.socketPath, /bridge\.sock$/); assert.equal(daemon.listAgents()[0].worktreePath, home); }
finally { await daemon.close(); }
});
test("relays only the selected agent's bridge state into a presentation update", async () => {
let listener;
test("relays aggregate workspace summary as compact Noctalia state", async () => {
const updates = [];
const relay = createNoctaliaStateRelay({
client: {
request: async () => ({ data: { isStreaming: false } }),
subscribe: async (_agentId, _cursor, callback) => {
listener = callback;
return () => {};
},
},
agent: { id: "agent-1", worktreePath: "/worktrees/feature" },
onState: (state) => updates.push(state),
client: { request: async (operation) => { assert.equal(operation, "get_workspace_summary"); return { openCount: 3, workingCount: 1, attentionCount: 2, recoveringCount: 0, errorCount: 1 }; } },
onState: (state) => updates.push(state), pollMs: 250,
});
await relay.start();
listener({ type: "agent_state", data: { state: "streaming" } });
listener({
type: "queue",
data: { event: { steering: ["focus"], followUp: [] } },
});
assert.deepEqual(updates, [
{
state: "idle",
projectLabel: "feature",
attentionCount: 0,
detail: "Idle",
},
{
state: "streaming",
projectLabel: "feature",
attentionCount: 0,
detail: "Streaming",
},
{
state: "streaming",
projectLabel: "feature",
attentionCount: 1,
detail: "Streaming",
},
]);
await relay.start(); relay.stop();
assert.deepEqual(updates, [{ state: "error", projectLabel: "3 sessions", attentionCount: 2, detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1 }]);
});
test("retains agent-scoped relay behavior only when explicitly requested", async () => {
let listener; const updates = [];
const relay = createNoctaliaStateRelay({ client: { request: async () => ({ data: { isStreaming: false } }), subscribe: async (_agentId, _cursor, callback) => { listener = callback; return () => {}; } }, agent: { id: "agent-1", worktreePath: "/worktrees/feature" }, onState: (state) => updates.push(state) });
await relay.start(); listener({ type: "agent_state", data: { state: "streaming" } });
assert.equal(updates.at(-1).state, "streaming"); assert.equal(updates.at(-1).projectLabel, "feature");
});
File diff suppressed because it is too large Load Diff
+8 -23
View File
@@ -2,33 +2,18 @@ import assert from "node:assert/strict";
import test from "node:test";
import { publishNoctaliaState } from "../src/client/noctalia-ipc.js";
test("publishes presentation state through Noctalia's documented IPC command", async () => {
test("publishes aggregate presentation state through Noctalia's documented IPC command", async () => {
const calls = [];
await publishNoctaliaState(
{
state: "streaming",
projectLabel: "feature",
attentionCount: 2,
detail: "Streaming",
state: "streaming", projectLabel: "3 sessions", attentionCount: 2,
detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors",
openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1,
},
{ execute: async (command, args) => calls.push({ command, args }) },
);
assert.deepEqual(calls, [
{
command: "qs",
args: [
"-c",
"noctalia-shell",
"ipc",
"call",
"plugin:pi-status-bridge",
"setBridgeState",
"streaming",
"feature",
"2",
"Streaming",
],
},
]);
assert.deepEqual(calls, [{
command: "qs",
args: ["-c", "noctalia-shell", "ipc", "call", "plugin:pi-status-bridge", "setBridgeState", "streaming", "3 sessions", "2", "3 open · 1 working · 2 attention · 0 recovering · 1 errors", "3", "1", "0", "1"],
}]);
});
+13 -6
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("ships a Noctalia plugin with compact bar and presentation-only panel entry points", async () => {
test("ships a Noctalia aggregate launcher without a primary panel", async () => {
const manifestText = await readFile(
"noctalia-plugin/pi-status-bridge/manifest.json",
"utf8",
@@ -15,9 +15,8 @@ test("ships a Noctalia plugin with compact bar and presentation-only panel entry
error instanceof Error ? error.message : "manifest was not valid JSON",
);
}
const [bar, panel, main] = await Promise.all([
const [bar, main] = await Promise.all([
readFile("noctalia-plugin/pi-status-bridge/BarWidget.qml", "utf8"),
readFile("noctalia-plugin/pi-status-bridge/Panel.qml", "utf8"),
readFile("noctalia-plugin/pi-status-bridge/Main.qml", "utf8"),
]);
@@ -25,10 +24,18 @@ test("ships a Noctalia plugin with compact bar and presentation-only panel entry
assert.deepEqual(manifest.entryPoints, {
main: "Main.qml",
barWidget: "BarWidget.qml",
panel: "Panel.qml",
});
assert.match(bar, /pluginApi\.togglePanel\(root\.screen, visualCapsule\)/);
assert.match(panel, /allowAttach: true/);
assert.match(bar, /systemctl", "--user", "start", "pi-status-bridge\.service/);
assert.match(bar, /PI_STATUS_UI_BINARY/);
assert.match(bar, /pi-status-ui/);
assert.match(bar, /setsid -f env TMPDIR=\/tmp/);
assert.match(bar, /--toggle/);
assert.doesNotMatch(bar, /togglePanel/);
assert.doesNotMatch(bar, /Panel\.qml/);
assert.match(main, /IpcHandler/);
assert.match(main, /setBridgeState/);
assert.match(main, /openCount/);
assert.match(main, /workingCount/);
assert.match(main, /recoveringCount/);
assert.match(main, /errorCount/);
});
+14 -3
View File
@@ -1,8 +1,8 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { access, readFile } from "node:fs/promises";
import test from "node:test";
test("ships a v5 Noctalia compatibility launcher instead of a primary panel", async () => {
test("ships a v5 Noctalia aggregate launcher instead of a primary panel", async () => {
const [manifest, bridge] = await Promise.all([
readFile("noctalia-v5-plugin/pi-status-bridge/plugin.toml", "utf8"),
readFile("noctalia-v5-plugin/pi-status-bridge/bridge.luau", "utf8"),
@@ -19,10 +19,21 @@ test("ships a v5 Noctalia compatibility launcher instead of a primary panel", as
assert.doesNotMatch(bridge, /--show/);
assert.match(bridge, /pi-status-ui\.log/);
assert.doesNotMatch(bridge, /togglePanel/);
assert.match(bridge, /get_workspace_summary/);
assert.match(bridge, /openCount/);
assert.match(bridge, /workingCount/);
assert.match(bridge, /attentionCount/);
assert.match(bridge, /recoveringCount/);
assert.match(bridge, /errorCount/);
assert.match(bridge, /Pi reconnecting/);
assert.match(bridge, /discover_home_agent\(socket\)/);
assert.doesNotMatch(bridge, /discover_home_agent/);
assert.doesNotMatch(bridge, /get_state/);
assert.match(bridge, /systemctl --user start pi-status-bridge\.service/);
assert.match(bridge, /bridge_start_in_flight/);
assert.match(bridge, /pending_ui_open/);
assert.match(bridge, /Bridge is starting/);
await assert.rejects(
access("noctalia-v5-plugin/pi-status-bridge/picker.luau"),
{ code: "ENOENT" },
);
});
+34
View File
@@ -115,6 +115,40 @@ test("requests Pi session statistics for context and token status", async () =>
await adapter.stop();
});
test("forwards native session naming and compaction commands", async () => {
const fixture = createFixture();
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
});
const named = adapter.send({ type: "set_session_name", name: "Release UI" });
const [nameCommand] = fixture.sent();
assert.equal(nameCommand.type, "set_session_name");
assert.equal(nameCommand.name, "Release UI");
fixture.child.stdout.write(
`${JSON.stringify({ type: "response", id: nameCommand.id, command: "set_session_name", success: true })}\n`,
);
await named;
const compacted = adapter.send({
type: "compact",
customInstructions: "Preserve implementation details",
});
const [, compactCommand] = fixture.sent();
assert.equal(compactCommand.type, "compact");
assert.equal(
compactCommand.customInstructions,
"Preserve implementation details",
);
fixture.child.stdout.write(
`${JSON.stringify({ type: "response", id: compactCommand.id, command: "compact", success: true })}\n`,
);
await compacted;
await adapter.stop();
});
test("forwards extension responses without replacing Pi's request ID", async () => {
const fixture = createFixture();
const events = [];
+92
View File
@@ -108,6 +108,98 @@ test("accepts bridge-managed directory and session operations", () => {
);
});
test("accepts additive multi-session runtime operations and numeric workspace cursors", () => {
const worktreePath = "/home/alex/project";
const sessionPath = "/home/alex/project/sessions/one.jsonl";
for (const value of [
{ op: "get_workspace" },
{ op: "get_workspace_summary" },
{ op: "create_session_runtime", payload: { worktreePath } },
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "list_directory_sessions", payload: { worktreePath } },
{
op: "get_session_runtime_snapshot",
payload: { runtimeId: "runtime-1" },
},
{ op: "subscribe_workspace", payload: { cursor: 42 } },
])
assert.equal(parseRequestFrame(request(value)).op, value.op);
assert.throws(
() =>
parseRequestFrame(
request({
op: "subscribe_workspace",
payload: { cursor: "opaque" },
}),
),
/cursor must be a non-negative integer/,
);
});
test("accepts native session naming and manual compaction requests", () => {
assert.deepEqual(
parseRequestFrame(
request({
op: "set_session_name",
agentId: "agent-main",
payload: { name: "Release UI" },
}),
),
{
version: "v1",
id: "request-1",
op: "set_session_name",
agentId: "agent-main",
payload: { name: "Release UI" },
},
);
assert.deepEqual(
parseRequestFrame(request({ op: "compact", agentId: "agent-main" })),
{
version: "v1",
id: "request-1",
op: "compact",
agentId: "agent-main",
},
);
assert.deepEqual(
parseRequestFrame(
request({
op: "compact",
agentId: "agent-main",
payload: { customInstructions: "Preserve implementation details" },
}),
),
{
version: "v1",
id: "request-1",
op: "compact",
agentId: "agent-main",
payload: { customInstructions: "Preserve implementation details" },
},
);
for (const invalidRequest of [
request({
op: "set_session_name",
agentId: "agent-main",
payload: { name: " " },
}),
request({
op: "set_session_name",
agentId: "agent-main",
payload: { name: "Valid", extra: true },
}),
request({
op: "compact",
agentId: "agent-main",
payload: { customInstructions: " " },
}),
request({ op: "compact", agentId: "agent-main", payload: { extra: true } }),
])
assert.throws(() => parseRequestFrame(invalidRequest), ProtocolError);
});
test("accepts an agent session-statistics request", () => {
assert.deepEqual(
parseRequestFrame(
+50
View File
@@ -42,6 +42,56 @@ function collectFrames(socket, count) {
});
}
test("workspace subscription exposes daemon identity, replay boundaries, and globally ordered live events", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-workspace-subscribe-"));
const home = join(root, "home");
await mkdir(home);
const calls = [];
const service = await startBridgeService({
homeWorktree: home,
runtimeDir: join(root, "runtime"),
sessionRoot: join(root, "sessions"),
startAdapter: (options) => {
const adapter = {
send: async () => ({ type: "response", success: true, data: {} }),
respondToExtension: () => {},
stop: async () => {},
};
calls.push({ options, adapter });
return adapter;
},
});
const socket = await connect(service.socketPath);
try {
const frames = collectFrames(socket, 2);
socket.write(
`${JSON.stringify({ version: "v1", id: "workspace-1", op: "subscribe_workspace", payload: { cursor: 0 } })}\n`,
);
setTimeout(() => {
calls[0].options.onEvent({
type: "stream",
data: { event: { type: "message_update", delta: "live" } },
});
}, 10);
const [replay, live] = await frames;
assert.equal(replay.ok, true);
assert.equal(typeof replay.result.bridgeInstanceId, "string");
assert.equal(replay.result.truncated, false);
assert.equal(Number.isSafeInteger(replay.result.firstAvailableSeq), true);
assert.equal(Number.isSafeInteger(replay.result.latestSeq), true);
assert.ok(replay.result.events.length >= 1);
assert.equal(live.type, "event");
assert.equal(live.event.bridgeInstanceId, replay.result.bridgeInstanceId);
assert.equal(live.event.type, "runtime_event");
assert.equal(live.event.data.eventType, "stream");
assert.ok(live.event.seq > replay.result.events.at(-1).seq);
} finally {
socket.destroy();
await service.close();
}
});
test("replays then streams subscribed agent events on the same local socket", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-subscribe-"));
const home = join(root, "home");
+69 -370
View File
@@ -2,385 +2,84 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("renders every Pi RPC extension UI method without replying to fire-and-forget requests", async () => {
const source = await readFile("ui/src/App.tsx", "utf8");
for (const method of [
"select",
"confirm",
"input",
"editor",
"notify",
"setStatus",
"setWidget",
"setTitle",
"set_editor_text",
]) {
assert.match(source, new RegExp(`case "${method}"`));
}
assert.match(source, /setPendingExtension\(extension\)/);
assert.doesNotMatch(source, /This extension UI is unsupported/);
assert.match(source, /function stripAnsi/);
assert.match(source, /stripAnsi\(extension\.statusText\)/);
assert.match(source, /className="pi-controls"/);
assert.match(source, /aria-label="Model and thinking controls"/);
assert.match(source, /const uiScaleKey/);
assert.match(source, /function clampUiScale/);
assert.match(source, /function updateUiScale/);
assert.match(source, /localStorage\.setItem\(uiScaleKey/);
assert.match(source, /className="scale-settings"/);
assert.match(source, /aria-label="Interface scale"/);
assert.match(source, /aria-label="Decrease interface scale"/);
assert.match(source, /aria-label="Increase interface scale"/);
assert.match(source, /type="range"/);
assert.match(source, /style=\{\{ zoom: uiScale \}\}/);
assert.doesNotMatch(
source,
/className="pi-controls"[\s\S]*aria-label="Interface scale"/,
);
assert.doesNotMatch(source, /<details className="pi-controls">/);
assert.doesNotMatch(
source,
/event\.ctrlKey && event\.key\.toLowerCase\(\) === "c"/,
);
assert.match(source, /getCurrentWindow\(\)\s*\.hide\(\)\s*\.catch/);
assert.match(
source,
/window\.addEventListener\("keydown", handleKeydown, true\)/,
);
assert.match(source, /useLayoutEffect/);
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
assert.match(source, /requestAnimationFrame\(scrollTranscriptToLatest\)/);
assert.match(source, /const loadGeneration = useRef\(0\)/);
assert.match(
source,
/const bridgeRetryTimer = useRef<number \| undefined>\(undefined\)/,
);
assert.match(source, /const bridgeRetryAttempt = useRef\(0\)/);
assert.match(source, /function scheduleBridgeReconnect/);
assert.match(source, /Bridge unavailable; retrying in/);
assert.match(
source,
/setStatus\(String\(error\)\);\s*scheduleBridgeReconnect\(\);/,
);
assert.match(source, /bridgeRetryTimer\.current = window\.setTimeout/);
assert.match(source, /void refreshAgents\(preferredPath\)/);
assert.match(source, /bridgeRetryAttempt\.current = 0/);
assert.match(
source,
/const lastEventSequence = useRef<Record<string, number>>/,
);
assert.match(source, /const eventReloadInFlight = useRef\(false\)/);
assert.match(source, /const queuedEventReload = useRef<string \| undefined>/);
assert.match(source, /function reloadAgentAfterEvent\(agentId: string\)/);
assert.match(source, /void reloadAgentAfterEvent\(selectedId\)/);
assert.match(source, /let disposed = false;/);
assert.match(source, /if \(disposed\) stop\(\);/);
assert.match(source, /generation !== loadGeneration\.current/);
assert.match(source, /current\.phase !== "working"/);
assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/);
assert.match(source, /\.toFixed\(1\)/);
assert.match(source, /formatTokens\(stats\.tokens\.input\)/);
assert.match(source, /formatTokens\(stats\.tokens\.output\)/);
assert.match(source, /formatTokens\(cacheTokens\)/);
assert.match(source, /function updateWorkProgress/);
assert.match(
source,
/className=\{`work-progress composer-progress \$\{workProgress\.phase\}`\}/,
);
assert.match(source, /event\.type === "tool"/);
assert.match(source, /event\.type === "queue"/);
assert.match(source, /composer-progress/);
assert.doesNotMatch(
source,
/<section className=\{`work-progress \$\{workProgress\.phase\}`\}/,
);
const sources = Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/workspace/reducer.ts", "utf8"),
readFile("ui/src/workspace/useWorkspace.ts", "utf8"),
readFile("ui/src/workspace/persistence.ts", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
test("wires the desktop to stable runtime workspace APIs", async () => {
const [app, reducer, hook] = await sources;
for (const operation of [
"create_session_runtime",
"open_session_runtime",
"close_session_runtime",
"list_directory_sessions",
"get_session_runtime_snapshot",
"subscribe_workspace",
])
assert.match(`${app}\n${hook}`, new RegExp(operation));
assert.doesNotMatch(app, /switch_session/);
assert.doesNotMatch(app, /new_session/);
assert.match(reducer, /WorkspaceState\["runtimesById"\]/);
assert.match(reducer, /requestGeneration/);
assert.match(reducer, /loadedThroughSeq/);
});
test("exposes accessible directory, session, notification, and extension controls", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
test("keeps presentation persistence allowlisted and agent IDs ephemeral", async () => {
const [, , , persistence] = await sources;
assert.match(persistence, /load_ui_state/);
assert.match(persistence, /save_ui_state/);
assert.match(persistence, /directoryOrder/);
assert.match(persistence, /drafts/);
assert.doesNotMatch(persistence, /agentId/);
assert.doesNotMatch(persistence, /messages/);
assert.doesNotMatch(persistence, /extensions/);
});
test("renders directory sidebar, accessible session tabs, warnings, and reduced motion", async () => {
const [app, , , , css] = await sources;
const [sidebar, tabs] = await Promise.all([
readFile("ui/src/components/DirectorySidebar.tsx", "utf8"),
readFile("ui/src/components/SessionTabs.tsx", "utf8"),
]);
assert.match(component, /aria-expanded=\{view === "settings"\}/);
assert.match(
component,
/aria-label="Managed directories"\s+className="directory-tab-list"\s+role="tablist"/,
);
assert.match(component, /role="tab"/);
assert.match(
component,
/aria-selected=\{directory\.agentId === selectedId\}/,
);
assert.match(component, /aria-controls="directory-workspace"/);
assert.match(component, /onKeyDown=\{\(event\) => handleDirectoryTabKeyDown/);
assert.match(
component,
/aria-current=\{session\.isCurrent \? "page" : undefined\}/,
);
assert.match(component, /role="status" aria-atomic="true"/);
assert.match(component, /aria-hidden="true"/);
assert.match(
component,
/role=\{notice\.notifyType === "error" \? "alert" : "status"\}/,
);
assert.match(component, /role="dialog"/);
assert.match(component, /aria-modal="true"/);
assert.match(component, /aria-labelledby="extension-title"/);
assert.match(component, /const extensionRef = useRef<HTMLElement>\(null\)/);
assert.match(component, /function focusableExtensionControls/);
assert.match(component, /event\.key !== "Tab"/);
assert.match(component, /data-initial-focus/);
assert.match(component, /className="extension-backdrop"/);
assert.match(component, /htmlFor="extension-value"/);
assert.match(component, /Close current session and start a new session/);
assert.match(component, /Restart Pi for the selected directory/);
assert.match(component, /window\.confirm\(/);
assert.match(styles, /button:focus-visible/);
assert.match(styles, /\*::before,\n\*::after/);
assert.match(styles, /\.sr-only/);
assert.match(styles, /\.extension-backdrop \{/);
assert.match(styles, /animation-duration: 0\.01ms/);
assert.match(sidebar, /aria-label="Directories"/);
assert.match(tabs, /role="tablist"/);
assert.match(tabs, /role="tab"/);
assert.match(tabs, /aria-selected/);
assert.match(tabs, /ArrowRight/);
assert.match(app, /Multiple sessions are open in the same directory/);
assert.match(app, /Many Pi sessions are open/);
assert.match(css, /prefers-reduced-motion/);
assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/);
});
test("makes the window draggable from the non-interactive header", async () => {
const [component, styles, capability] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
test("preserves native session naming, compaction, composer, and extension responses", async () => {
const [app] = await sources;
const [conversation, extension] = await Promise.all([
readFile("ui/src/components/ConversationWorkspace.tsx", "utf8"),
readFile("ui/src/components/ExtensionDialog.tsx", "utf8"),
]);
assert.match(component, /startDragging\(\)/);
assert.match(
component,
/closest\(\s*"button, input, select, textarea",?\s*\)/,
);
assert.match(styles, /\.workflow-header \{[\s\S]*cursor: grab/);
assert.match(capability, /core:window:allow-start-dragging/);
assert.match(app, /set_session_name/);
assert.match(app, /compact/);
assert.match(app, /submit_prompt/);
assert.match(app, /respond_to_extension/);
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
assert.match(conversation, /className="todos-pane"/);
assert.match(extension, /role="dialog"/);
assert.match(extension, /aria-modal="true"/);
});
test("opens local follow-up choices for /model and /resume", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /const controlCommands: Command\[\]/);
assert.match(component, /followUp: "model"/);
assert.match(component, /followUp: "resume"/);
assert.match(component, /followUp: "thinking"/);
assert.match(component, /const nativeCommand = controlCommands\.find/);
assert.match(component, /function chooseThinking/);
assert.match(component, /function openCommandFollowUp/);
assert.match(component, /function chooseModel/);
assert.match(component, /className="command-followup"/);
assert.match(component, /New session/);
assert.match(styles, /\.command-followup \{/);
assert.match(styles, /\.command-choice-list \{/);
});
test("shows bridge-managed directory status and sessions for the opened directory", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(
component,
/invoke<\{ directories: Directory\[\] \}>\("list_directories"\)/,
);
assert.match(
component,
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
);
assert.match(component, /function chooseDirectory/);
assert.match(component, /function handleDirectoryTabKeyDown/);
assert.match(component, /function forgetDirectory/);
assert.match(component, /window\.confirm/);
assert.match(component, /"forget_directory"/);
assert.match(component, /bridgeEvent\.type === "agent_state"/);
assert.match(component, /setDirectories\(\(current\) =>/);
assert.match(
component,
/state: loadedState\.isStreaming \? "streaming" : "idle"/,
);
assert.match(component, /className="directory-add"/);
assert.match(
component,
/import \{ open \} from "@tauri-apps\/plugin-dialog"/,
);
assert.match(component, /function browseForFolder/);
assert.match(component, /directory: true/);
assert.match(component, /multiple: false/);
assert.match(component, /Browse…/);
assert.match(component, /const \[isAddingDirectory, setIsAddingDirectory\]/);
assert.match(component, /aria-busy=\{isAddingDirectory\}/);
assert.match(component, /className="directory-add-status" role="status"/);
assert.match(component, /pendingDirectoryPath/);
assert.match(component, /Added \$\{worktreeLabel\(pendingDirectoryPath\)\}/);
assert.match(component, /Add directory/);
assert.doesNotMatch(component, /className="folder-form"/);
assert.doesNotMatch(component, /Remembered folders/);
assert.match(component, /function startNewSession/);
assert.match(component, /function closeCurrentSession/);
assert.match(component, /Use \/resume to reopen it/);
assert.match(component, /function switchSession/);
assert.match(component, /className="session-panel"/);
assert.match(component, /New session/);
assert.match(component, /state\.isStreaming/);
assert.match(styles, /\.directory-add \{/);
assert.match(styles, /\.directory-add-status \{/);
assert.match(styles, /\.directory-add-spinner \{/);
assert.match(styles, /directory-add-spin/);
assert.match(styles, /\.directory-tab \{/);
assert.match(styles, /\.directory-tab-button \{/);
assert.match(styles, /\.directory-close \{/);
assert.match(styles, /\.session-panel \{/);
assert.match(styles, /\.session-list \{/);
});
test("renders todo-plugin state and compact context/token status", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /function currentTodos/);
assert.match(component, /message\.toolName === "todo"/);
assert.match(component, /className="todos-pane"/);
assert.match(component, /className="workflow-main"/);
assert.match(component, /ctx \{formatTokens\(contextTokens\)\}/);
assert.match(component, /tok \{formatTokens\(stats\.tokens\.total\)\}/);
assert.match(styles, /\.workflow-main \{/);
assert.match(styles, /\.todos-pane \{/);
assert.match(styles, /\.status-metric \{/);
});
test("shows an optimistic prompt until the transcript receives it", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /type PendingSubmission/);
assert.match(
component,
/const \[pendingSubmissions, setPendingSubmissions\]/,
);
assert.match(component, /setStatus\("Sending prompt to Pi…"\)/);
assert.match(component, /phase: "sending"/);
assert.match(component, /phase: "sent"/);
assert.match(component, /Sent · waiting for Pi/);
assert.match(component, /receivedMessages\.some/);
assert.match(component, /pending-message/);
assert.match(styles, /\.pending-message \{/);
assert.match(styles, /\.pending-message\.sending/);
});
test("offers a lower-center control when the conversation is above its latest message", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(
component,
/const \[isTranscriptAtBottom, setIsTranscriptAtBottom\]/,
);
assert.match(component, /function updateTranscriptScrollPosition/);
assert.match(component, /function scrollTranscriptToLatest/);
assert.match(component, /onScroll=\{updateTranscriptScrollPosition\}/);
assert.match(component, /!isTranscriptAtBottom/);
assert.match(component, /aria-label="Scroll to latest message"/);
assert.match(component, /className="scroll-to-latest"/);
assert.match(component, /scrollTranscriptToLatest\(\);/);
assert.match(styles, /\.transcript-container \{/);
assert.match(styles, /\.scroll-to-latest \{[\s\S]*left: 50%/);
assert.match(styles, /\.scroll-to-latest \{[\s\S]*bottom: 14px/);
assert.match(styles, /\.scroll-to-latest \{[\s\S]*border-radius: 50%/);
});
test("distinguishes user and assistant messages", async () => {
const styles = await readFile("ui/src/App.css", "utf8");
assert.match(styles, /\.message\.user \{/);
assert.match(styles, /background: #192536/);
assert.match(styles, /\.message\.assistant \{/);
assert.match(styles, /background: #1a241a/);
});
test("keeps an accessible focus treatment for the prompt editor", async () => {
const styles = await readFile("ui/src/App.css", "utf8");
assert.match(styles, /\.composer textarea:focus \{/);
assert.doesNotMatch(styles, /outline: none/);
assert.match(styles, /border-color: #6f8b56/);
assert.match(
styles,
/\.composer textarea:focus \{[^}]*outline: 2px solid #c4ed8b/,
);
assert.match(styles, /\.composer textarea:focus \{[^}]*outline-offset: -2px/);
assert.doesNotMatch(styles, /\.composer textarea:focus \{[^}]*box-shadow:/);
assert.match(styles, /textarea:focus-visible/);
});
test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
const source = await readFile("ui/src/App.tsx", "utf8");
assert.match(source, /onKeyDown=\{\(event\) => \{/);
assert.match(source, /event\.key !== "Enter"/);
assert.match(source, /event\.shiftKey/);
assert.match(source, /event\.nativeEvent\.isComposing/);
assert.match(source, /event\.preventDefault\(\);\s*void submit\(\);/);
assert.match(source, /Enter to send · Shift\+Enter for newline/);
});
test("uses a compact workflow layout that keeps the composer and transcript stable", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /className="workflow-header"/);
assert.match(component, /className="workflow"/);
assert.match(component, /className="workflow-footer"/);
assert.match(component, /className="composer-stack"/);
assert.match(
styles,
/\.workflow \{[\s\S]*grid-template-rows: minmax\(0, 1fr\) auto/,
);
assert.match(styles, /\.transcript,\s*\.settings \{[\s\S]*overflow: auto/);
assert.match(
component,
/className=\{`transcript \$\{pendingExtension \? "with-extension" : ""\} \$\{workProgress\.phase\}`\}/,
);
assert.match(
styles,
/\.transcript\.with-extension \{[\s\S]*padding-bottom: 112px/,
);
assert.match(styles, /\.transcript\.working \{/);
assert.match(styles, /conic-gradient\(/);
assert.match(styles, /transcript-border-orbit/);
assert.match(styles, /prefers-reduced-motion: reduce/);
assert.match(styles, /font-size: 18px/);
assert.match(styles, /\.directory-tab-button \{[\s\S]*font-size: 13px/);
assert.match(styles, /\.message pre \{[\s\S]*font-size: 14px/);
assert.match(
styles,
/\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 250px/,
);
assert.match(
styles,
/\.pi-controls \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/,
);
assert.match(styles, /\.scale-settings \{/);
assert.match(
styles,
/\.scale-adjustment \{[\s\S]*grid-template-columns: auto minmax\(0, 1fr\) auto/,
);
assert.match(styles, /\.scale-adjustment input\[type="range"\] \{/);
assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/);
});
test("configures a larger desktop window and enables the folder dialog", async () => {
const [config, capability, rust] = await Promise.all([
test("configures native window behavior and folder dialog capabilities", async () => {
const [config, capability, cargo] = await Promise.all([
readFile("ui/src-tauri/tauri.conf.json", "utf8"),
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
readFile("ui/src-tauri/src/lib.rs", "utf8"),
readFile("ui/src-tauri/Cargo.toml", "utf8"),
]);
assert.match(config, /"width": 1100/);
assert.match(config, /"height": 840/);
assert.match(config, /"minWidth": 640/);
assert.match(config, /"minHeight": 520/);
assert.match(capability, /"dialog:default"/);
assert.match(rust, /\.plugin\(tauri_plugin_dialog::init\(\)\)/);
assert.match(config, /"width"\s*:\s*1100/);
assert.match(capability, /core:window:allow-start-dragging/);
assert.match(cargo, /tauri-plugin-single-instance/);
assert.match(cargo, /tauri-plugin-dialog/);
});
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import {
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createWorkspaceStore } from "../src/bridge/workspace-store.js";
test("persists an owner-only versioned workspace manifest atomically", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-workspace-store-"));
const store = createWorkspaceStore(join(root, "sessions"));
const first = await store.load();
assert.equal(first.migrating, true);
await store.save({
version: 2,
migrated: true,
runtimes: [
{
runtimeId: "runtime-1",
worktreePath: join(root, "home"),
sessionPath: join(root, "sessions", "agent", "one.jsonl"),
sessionId: "session-1",
},
],
});
const metadata = await stat(store.filePath);
assert.equal(metadata.mode & 0o777, 0o600);
const restored = (await store.load()).manifest;
assert.equal(restored.runtimes[0].runtimeId, "runtime-1");
assert.deepEqual(restored.directories, [join(root, "home")]);
assert.equal((await readFile(store.filePath, "utf8")).endsWith("\n"), true);
});
test("accepts old manifests and persists managed empty directories", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-workspace-directories-"));
const store = createWorkspaceStore(join(root, "sessions"));
await store.save({
version: 2,
migrated: true,
directories: [join(root, "empty")],
runtimes: [],
});
assert.deepEqual((await store.load()).manifest.directories, [
join(root, "empty"),
]);
await writeFile(
store.filePath,
JSON.stringify({ version: 2, migrated: true, runtimes: [] }),
{ mode: 0o600 },
);
assert.deepEqual((await store.load()).manifest.directories, []);
});
test("reports a corrupt manifest without treating it as first migration", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-workspace-corrupt-"));
const store = createWorkspaceStore(join(root, "sessions"));
await store.save({ version: 2, migrated: true, runtimes: [] });
await writeFile(store.filePath, "{broken", { mode: 0o600 });
const loaded = await store.load();
assert.equal(loaded.migrating, false);
assert.equal(loaded.manifest.runtimes.length, 0);
assert.equal(loaded.issue.code, "invalid_workspace_manifest");
});
test("recovers its serialized write queue after a failed save", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-workspace-retry-"));
const sessionRoot = join(root, "sessions");
const store = createWorkspaceStore(sessionRoot);
await mkdir(sessionRoot);
await mkdir(store.filePath);
await assert.rejects(
store.save({ version: 2, migrated: true, runtimes: [] }),
);
await rm(store.filePath, { recursive: true });
await store.save({ version: 2, migrated: true, runtimes: [] });
assert.deepEqual((await store.load()).manifest.runtimes, []);
});