feat(status-ui): improve bridge recovery and desktop controls
This commit is contained in:
+254
-2
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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" })),
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user