feat(status-ui): improve bridge recovery and desktop controls

This commit is contained in:
2026-07-28 21:15:25 +02:00
parent 7cc15cca92
commit a792c577ef
23 changed files with 1336 additions and 204 deletions
+254 -2
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
@@ -72,6 +72,253 @@ test("starts only the home agent and creates other worktree agents on explicit s
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
});
test("stops agents created during shutdown and rejects new selections", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
let releaseFeatureCreation;
const featureCreation = new Promise((resolve) => {
releaseFeatureCreation = resolve;
});
let featureCreationStarted;
const featureStarted = new Promise((resolve) => {
featureCreationStarted = resolve;
});
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: (options) => {
const adapter = fixture.startAdapter(options);
if (options.cwd === worktrees.feature) {
const send = adapter.send.bind(adapter);
adapter.send = (command) => {
if (command.type !== "get_state") return send(command);
featureCreationStarted();
return featureCreation.then(() => send(command));
};
}
return adapter;
},
});
await registry.start();
const selecting = registry.selectWorktree(worktrees.feature);
await featureStarted;
const stopping = registry.stop();
await assert.rejects(
registry.selectWorktree(worktrees.feature),
/registry is stopping/,
);
releaseFeatureCreation();
await selecting;
await stopping;
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
});
test("drains a forget still resolving its canonical path during shutdown", async () => {
const worktrees = await createWorktrees();
const featureAlias = join(worktrees.root, "feature-alias");
await symlink(worktrees.feature, featureAlias);
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
await registry.start();
await registry.selectWorktree(worktrees.feature);
const featureAdapter = fixture.calls[1].adapter;
const stop = featureAdapter.stop.bind(featureAdapter);
let stopCalls = 0;
featureAdapter.stop = async () => {
stopCalls += 1;
await stop();
};
const forgetting = registry.forgetDirectory(featureAlias);
const stopping = registry.stop();
await Promise.all([forgetting, stopping]);
assert.equal(stopCalls, 1);
assert.equal(
registry
.listAgents()
.some((agent) => agent.worktreePath === worktrees.feature),
false,
);
});
test("forgets a directory without deleting its resumable session history", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
const home = await registry.start();
const feature = await registry.selectWorktree(worktrees.feature);
const sessionPath = join(feature.sessionDir, "resumable.jsonl");
const sessionContent = `${JSON.stringify({
type: "session",
id: "resumable",
timestamp: "2025-01-01T00:00:00.000Z",
cwd: worktrees.feature,
})}\n`;
await writeFile(sessionPath, sessionContent);
await writeFile(
join(feature.sessionDir, "bridge-agent.json"),
`${JSON.stringify({ sessionPath, worktreePath: worktrees.feature })}\n`,
);
await registry.forgetDirectory(worktrees.feature);
assert.equal(fixture.calls[1].adapter.stopped, true);
assert.deepEqual(registry.listAgents(), [home]);
assert.deepEqual(await registry.listDirectories(), [
{
worktreePath: worktrees.home,
state: "idle",
isHome: true,
agentId: home.id,
},
]);
assert.equal(await readFile(sessionPath, "utf8"), sessionContent);
await assert.rejects(
registry.forgetDirectory(worktrees.home),
/cannot forget the home directory/,
);
const reopened = await registry.selectWorktree(worktrees.feature);
assert.deepEqual(
(await registry.listSessions(reopened.id)).map(({ id }) => id),
["resumable"],
);
await registry.stop();
});
test("coordinates forgetting with directory creation and active commands", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
let releaseFeatureCreation;
const featureCreation = new Promise((resolve) => {
releaseFeatureCreation = resolve;
});
let featureCreationStarted;
const featureStarted = new Promise((resolve) => {
featureCreationStarted = resolve;
});
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: (options) => {
const adapter = fixture.startAdapter(options);
if (options.cwd === worktrees.feature) {
const send = adapter.send.bind(adapter);
adapter.send = (command) => {
if (command.type !== "get_state") return send(command);
featureCreationStarted();
return featureCreation.then(() => send(command));
};
}
return adapter;
},
});
await registry.start();
const selecting = registry.selectWorktree(worktrees.feature);
await featureStarted;
const forgettingDuringCreation = registry.forgetDirectory(worktrees.feature);
releaseFeatureCreation();
await selecting;
await forgettingDuringCreation;
assert.equal(fixture.calls[1].adapter.stopped, true);
assert.equal(
(await registry.listDirectories()).some(
(directory) => directory.worktreePath === worktrees.feature,
),
false,
);
await registry.selectWorktree(worktrees.feature);
const concurrentAdapter = fixture.calls[2].adapter;
const stop = concurrentAdapter.stop.bind(concurrentAdapter);
let releaseStop;
const stopGate = new Promise((resolve) => {
releaseStop = resolve;
});
let stopCalls = 0;
concurrentAdapter.stop = async () => {
stopCalls += 1;
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/,
);
releaseStop();
await Promise.all([firstForget, secondForget]);
assert.equal(stopCalls, 1);
assert.equal(registry.listAgents().length, 1);
const feature = await registry.selectWorktree(worktrees.feature);
const { adapter, options } = fixture.calls[3];
const send = adapter.send.bind(adapter);
let releasePrompt;
const promptResponse = new Promise((resolve) => {
releasePrompt = resolve;
});
adapter.send = (command) =>
command.type === "prompt" ? promptResponse : send(command);
const prompt = registry.route(feature.id, "prompt", {
message: "Keep working",
});
const forgettingDuringPrompt = registry.forgetDirectory(worktrees.feature);
await new Promise((resolve) => setImmediate(resolve));
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.forgetDirectory(worktrees.feature);
assert.equal(adapter.stopped, true);
const failedFeature = await registry.selectWorktree(worktrees.feature);
const failedFixture = fixture.calls[4];
let rejectPrompt;
const failedPromptResponse = new Promise((_resolve, reject) => {
rejectPrompt = reject;
});
const failedSend = failedFixture.adapter.send.bind(failedFixture.adapter);
failedFixture.adapter.send = (command) =>
command.type === "prompt" ? failedPromptResponse : failedSend(command);
const failedPrompt = registry.route(failedFeature.id, "prompt", {
message: "Start before the response fails",
});
const failedPromptAssertion = assert.rejects(failedPrompt, /send failed/);
failedFixture.options.onEvent({
type: "agent_state",
data: { state: "streaming" },
});
rejectPrompt(new Error("send failed"));
await failedPromptAssertion;
await assert.rejects(
registry.forgetDirectory(worktrees.feature),
/while Pi is working/,
);
failedFixture.options.onEvent({
type: "agent_state",
data: { state: "idle" },
});
await registry.forgetDirectory(worktrees.feature);
await registry.stop();
});
test("catalogues managed directories and exposes only their sessions", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
@@ -151,10 +398,15 @@ test("catalogues managed directories and exposes only their sessions", async ()
});
await restored.start();
assert.deepEqual(await restored.listDirectories(), [
{ worktreePath: worktrees.feature, state: "inactive" },
{
worktreePath: worktrees.feature,
state: "inactive",
isHome: false,
},
{
worktreePath: worktrees.home,
state: "idle",
isHome: true,
agentId: restored.listAgents()[0].id,
},
]);
+14
View File
@@ -106,6 +106,20 @@ test("starts the home agent and dispatches local protocol requests to the select
{ type: "get_state" },
{ type: "prompt", message: "Use this worktree" },
]);
fixture.calls[1].options.onEvent({
type: "agent_state",
data: { state: "idle" },
});
const forgotten = await request(socket, {
version: "v1",
id: "forget-1",
op: "forget_directory",
payload: { worktreePath: feature },
});
assert.equal(forgotten.ok, true);
assert.equal(forgotten.result.worktreePath, feature);
assert.equal((await service.listAgents()).length, 1);
} finally {
socket.destroy();
await service.close();
+7
View File
@@ -13,9 +13,16 @@ test("ships a v5 Noctalia compatibility launcher instead of a primary panel", as
assert.match(bridge, /PI_STATUS_UI_BINARY/);
assert.match(bridge, /pi-status-ui/);
assert.match(bridge, /noctalia\.runAsync/);
assert.match(bridge, /test -x/);
assert.match(bridge, /setsid -f env TMPDIR=\/tmp/);
assert.match(bridge, /--toggle/);
assert.doesNotMatch(bridge, /--show/);
assert.match(bridge, /pi-status-ui\.log/);
assert.doesNotMatch(bridge, /togglePanel/);
assert.match(bridge, /Pi reconnecting/);
assert.match(bridge, /discover_home_agent\(socket\)/);
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/);
});
+14
View File
@@ -47,6 +47,20 @@ test("accepts bridge-managed directory and session operations", () => {
id: "request-1",
op: "list_directories",
});
assert.deepEqual(
parseRequestFrame(
request({
op: "forget_directory",
payload: { worktreePath: "/projects/feature" },
}),
),
{
version: "v1",
id: "request-1",
op: "forget_directory",
payload: { worktreePath: "/projects/feature" },
},
);
assert.deepEqual(
parseRequestFrame(request({ op: "list_sessions", agentId: "agent-main" })),
{
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("documents and exposes simple user-service lifecycle commands", async () => {
const [readme, uiReadme, packageJson, uiPackageJson] = await Promise.all([
readFile("README.md", "utf8"),
readFile("ui/README.md", "utf8"),
readFile("package.json", "utf8"),
readFile("ui/package.json", "utf8"),
]);
let manifest;
try {
manifest = JSON.parse(packageJson);
} catch (error) {
assert.fail(`package.json must contain valid JSON: ${String(error)}`);
}
const scripts = manifest.scripts;
for (const command of [
"install",
"start",
"restart",
"stop",
"status",
"logs",
])
assert.equal(
typeof scripts[`bridge:service:${command}`],
"string",
`missing bridge:service:${command}`,
);
let uiManifest;
try {
uiManifest = JSON.parse(uiPackageJson);
} catch (error) {
assert.fail(`ui/package.json must contain valid JSON: ${String(error)}`);
}
assert.match(readme, /npm run bridge:service:restart/);
assert.match(readme, /pi-status-ui --toggle/);
assert.match(readme, /Restart Pi.*selected directory/);
assert.match(uiReadme, /bridge:service:install/);
assert.match(uiReadme, /tauri:release/);
assert.equal(uiManifest.scripts["tauri:release"], "tauri build --no-bundle");
});
+8
View File
@@ -0,0 +1,8 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("rebuilds the Tauri app when frontend assets change", async () => {
const buildScript = await readFile("ui/src-tauri/build.rs", "utf8");
assert.match(buildScript, /cargo:rerun-if-changed=\.\.\/dist/);
});
+89 -2
View File
@@ -37,6 +37,20 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
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>>/,
@@ -62,6 +76,52 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
);
});
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"),
]);
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/);
});
test("makes the window draggable from the non-interactive header", async () => {
const [component, styles, capability] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
@@ -110,11 +170,31 @@ test("shows bridge-managed directory status and sessions for the opened director
/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, /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-tab \{/);
assert.match(styles, /\.directory-tab-button \{/);
assert.match(styles, /\.directory-close \{/);
assert.match(styles, /\.session-panel \{/);
assert.match(styles, /\.session-list \{/);
});
@@ -163,11 +243,18 @@ test("distinguishes user and assistant messages", async () => {
assert.match(styles, /background: #1a241a/);
});
test("uses a subtle focus treatment for the prompt editor", async () => {
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.match(styles, /outline: none/);
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 () => {