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
+20 -7
View File
@@ -81,23 +81,36 @@ node src/client/noctalia-relay-cli.js \
Install the adapter that matches your desktop: [`noctalia-plugin/`](noctalia-plugin/) supports v4 and [`noctalia-v5-plugin/`](noctalia-v5-plugin/) supports v5. Both adapters are presentation-only; neither starts Pi nor approves extension requests.
## Run as a user service
## Keep the bridge running in the background (recommended)
The included [`systemd/pi-status-bridge.service`](systemd/pi-status-bridge.service) is a portable user-service example. Install the bridge CLI, then ensure both `pi-status-bridge` and `pi` are on the user service's `PATH`:
The desktop UI connects on demand; only the bridge needs to run persistently. On Linux systems using systemd, install the bridge command globally once, then use the included user service:
```bash
npm install --global .
command -v pi-status-bridge pi
npm run bridge:service:install
```
mkdir -p ~/.config/systemd/user
cp systemd/pi-status-bridge.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now pi-status-bridge.service
systemctl --user status pi-status-bridge.service
The npm commands below are short, copyable wrappers around `systemctl --user`:
```bash
npm run bridge:service:start # start the bridge
npm run bridge:service:restart # apply bridge changes or recover it
npm run bridge:service:stop # stop the bridge
npm run bridge:service:status # check whether it is healthy
npm run bridge:service:logs # follow bridge logs; Ctrl+C returns to the shell
```
If systemd cannot locate either executable, add a user-service drop-in that sets `PATH` to their containing directories; do not hard-code another user's home path in the tracked unit.
### Opening the desktop UI
- **Development:** run `npm --prefix ui run tauri dev` in a terminal after starting the bridge.
- **Installed UI:** launch it from your application menu or run `pi-status-ui --toggle`. The `--toggle` command shows a hidden window or hides a visible one; it does not restart the bridge.
- **Noctalia:** use the Pi Status launcher as usual; it opens the installed UI.
**Restart Pi** in the UI restarts only the selected directory's Pi agent. `npm run bridge:service:restart` restarts the whole bridge service and all of its active agents.
## Development
```bash
+46 -14
View File
@@ -16,16 +16,58 @@ local function socket_path()
return runtime .. "/pi-status-bridge/bridge.sock"
end
local bridge_start_in_flight = false
local pending_ui_open = false
local function current_agent_id()
return noctalia.state.get("agent_id")
end
local function open_ui()
local binary = shell_quote(ui_binary())
local command = "if test -x " .. binary .. "; then setsid -f env TMPDIR=/tmp "
.. binary .. " --toggle >/tmp/pi-status-ui.log 2>&1; else exit 1; fi"
noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
barWidget.setGlyph("alert-triangle")
barWidget.setText("Pi UI unavailable")
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
end
end)
end
local function start_bridge(open_after_start)
if open_after_start then pending_ui_open = true end
if bridge_start_in_flight then return end
bridge_start_in_flight = true
barWidget.setGlyph("terminal-2")
barWidget.setText("Pi starting")
barWidget.setTooltip("Bridge is starting")
noctalia.runAsync("systemctl --user start pi-status-bridge.service", function(result)
bridge_start_in_flight = false
if result.exitCode ~= 0 then
pending_ui_open = false
barWidget.setGlyph("alert-triangle")
barWidget.setText("Pi offline")
barWidget.setTooltip("Could not start pi-status-bridge.service")
return
end
if pending_ui_open then
pending_ui_open = false
open_ui()
end
end)
end
local function discover_home_agent(socket)
if request_in_flight then return end
request_in_flight = true
noctalia.runAsync("pi-status-bridge-client --socket \"" .. socket .. "\" request '{\"op\":\"list_agents\"}'", function(result)
request_in_flight = false
if result.exitCode ~= 0 then return end
if result.exitCode ~= 0 then
start_bridge(false)
return
end
local home = noctalia.getenv("HOME")
for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do
if worktree == home then
@@ -42,9 +84,7 @@ function update()
local agent_id = current_agent_id()
local socket = socket_path()
if socket == nil then
barWidget.setGlyph("alert-triangle")
barWidget.setText("Pi offline")
barWidget.setTooltip("Bridge socket is unavailable")
start_bridge(false)
return
end
if agent_id == nil then
@@ -63,7 +103,7 @@ function update()
request_in_flight = false
if result.exitCode ~= 0 then
noctalia.state.set("agent_id", nil)
discover_home_agent(socket)
start_bridge(false)
barWidget.setGlyph("terminal-2")
barWidget.setText("Pi reconnecting")
barWidget.setTooltip("Refreshing the Home Pi agent")
@@ -77,13 +117,5 @@ function update()
end
function onClick()
local command = "setsid -f env TMPDIR=/tmp " .. shell_quote(ui_binary())
.. " --show >/tmp/pi-status-ui.log 2>&1"
noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
barWidget.setGlyph("alert-triangle")
barWidget.setText("Pi UI unavailable")
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
end
end)
start_bridge(true)
end
+7 -1
View File
@@ -26,6 +26,12 @@
"scripts": {
"test": "node --test test/*.test.js && npm run test:ui",
"test:ui": "cargo test --manifest-path ui/src-tauri/Cargo.toml",
"check": "node --check src/protocol/index.js && node --check src/client/local-client.js && node --check src/client/noctalia-relay.js && node --check src/client/noctalia-ipc.js && node --check src/client/noctalia-relay-cli.js && node --check src/bridge/daemon.js && node --check src/bridge/cli.js && node --check src/bridge/agent-registry.js && node --check src/bridge/instance-lock.js && node --check src/bridge/pi-rpc-adapter.js && node --check src/bridge/unix-server.js && node --check src/bridge/runtime.js && node --check src/bridge/recovery-supervisor.js && node --check src/bridge/service.js && npm --prefix ui run build"
"check": "node --check src/protocol/index.js && node --check src/client/local-client.js && node --check src/client/noctalia-relay.js && node --check src/client/noctalia-ipc.js && node --check src/client/noctalia-relay-cli.js && node --check src/bridge/daemon.js && node --check src/bridge/cli.js && node --check src/bridge/agent-registry.js && node --check src/bridge/instance-lock.js && node --check src/bridge/pi-rpc-adapter.js && node --check src/bridge/unix-server.js && node --check src/bridge/runtime.js && node --check src/bridge/recovery-supervisor.js && node --check src/bridge/service.js && npm --prefix ui run build",
"bridge:service:install": "mkdir -p \"$HOME/.config/systemd/user\" && cp systemd/pi-status-bridge.service \"$HOME/.config/systemd/user/\" && systemctl --user daemon-reload && systemctl --user enable --now pi-status-bridge.service",
"bridge:service:start": "systemctl --user start pi-status-bridge.service",
"bridge:service:restart": "systemctl --user restart pi-status-bridge.service",
"bridge:service:stop": "systemctl --user stop pi-status-bridge.service",
"bridge:service:status": "systemctl --user status pi-status-bridge.service",
"bridge:service:logs": "journalctl --user -u pi-status-bridge.service -f"
}
}
+76
View File
@@ -0,0 +1,76 @@
---
bug_id: BUG-20260727-widget-toggle
status: fixed
severity: medium
scope: noctalia-v5-launcher
title: Repeated status-bar clicks cannot hide the Pi Status UI
---
# BUG-20260727-widget-toggle: Repeated status-bar clicks cannot hide the Pi Status UI
## Problem
A Noctalia v5 status-bar click launches or focuses Pi Status UI. A second click must hide the same window, so a window that has moved off-screen can be dismissed and restored with a later click. Instead, every click only shows and focuses the existing window.
Reproduce on Noctalia v5:
1. Start Pi Status UI and the v5 status-bar widget.
2. Click the widget once.
3. Click it again while the Pi Status UI window is visible.
4. Observe that the window remains visible instead of hiding.
Security impact: LOW. No security exploit path was identified; the defect affects local window visibility only.
## Root Cause Analysis
### Reproduce
The v5 launcher invokes Pi Status UI with `--show` on every widget click. The desktop app's single-instance handler receives the second launch and always shows/focuses the existing window.
### Isolate
The v4 adapter uses Noctalia's native panel toggle and is not part of this failure. The v5 launcher and the desktop app's single-instance handler form the complete click-to-window path.
### Hypothesize
1. The launcher requests show rather than toggle. Falsification: require `--toggle` in the v5 launcher test.
2. The desktop app ignores second-instance arguments. Falsification: define and test a pure window-action selector for visible and hidden states.
### Verify
The launcher contains `--show`, and the single-instance handler discards its argument list before unconditionally calling show/focus. This confirms both hypotheses and the root cause.
### Follow-up launch regression
After the generated Tauri target directory was cleaned, the configured default release binary did not exist. The launcher detached through `setsid`, so Noctalia saw a successful launcher process even though `env` recorded that the binary was missing. Rebuilding the release binary restores the launcher, and the launcher now checks that its configured binary is executable before detaching.
## Fix Approach
Replace the v5 launch request with `--toggle`. Route `--toggle` in the desktop single-instance handler to hide a visible window or show/focus a hidden window. Preserve `--show` as an explicit show/focus operation for future callers.
Risk level: Low. The change is confined to local launcher/window behavior and does not affect bridge protocol, worktree routing, or approvals.
## TDD Fix Plan
1. **RED**: Require the v5 launcher test to use `--toggle` and reject `--show`.
**GREEN**: Change the launch argument from `--show` to `--toggle`.
**verify**: `node --test test/noctalia-v5-plugin.test.js`
2. **RED**: Add Rust tests that characterize `--toggle` for visible and hidden windows and preserve explicit `--show` behavior.
**GREEN**: Add a pure action selector and invoke it from the single-instance callback.
**verify**: `cargo test --manifest-path ui/src-tauri/Cargo.toml`
**REFACTOR**: Keep argument interpretation independent from Tauri window calls so its behavior remains unit-testable.
## Acceptance Criteria
- [x] A v5 widget click launches Pi Status UI with `--toggle`.
- [x] A second click hides a visible Pi Status UI window.
- [x] A later click shows and focuses the hidden window.
- [x] Explicit `--show` still shows and focuses the window.
- [x] Existing bridge, plugin, and UI tests pass.
- [x] The launcher rejects a missing or non-executable configured binary before detaching.
## Resolution
The v5 widget now launches the desktop app with `--toggle`. The desktop app converts second-instance arguments and current window visibility into an explicit hide or show/focus action. The launcher also verifies its executable before detaching, and the expected release binary has been rebuilt. Focused Node and Rust characterization tests, the full `npm test` suite, and `npm run check` pass.
+9
View File
@@ -0,0 +1,9 @@
bugs:
- bug_id: BUG-20260727-widget-toggle
status: fixed
date: 2026-07-27
severity: medium
priority: high
scope: noctalia-v5-launcher
summary: Repeated status-bar clicks cannot hide the Pi Status UI
file: specs/bugs/BUG-20260727-widget-toggle.md
+150 -6
View File
@@ -5,6 +5,7 @@ import {
readdir,
readFile,
realpath,
rm,
stat,
writeFile,
} from "node:fs/promises";
@@ -141,7 +142,7 @@ async function listSessionSummaries(sessionDir, currentPath) {
.sort((left, right) => right.modified.localeCompare(left.modified));
}
async function listDirectoryCatalog(sessionRoot, agentsByPath) {
async function listDirectoryCatalog(sessionRoot, agentsByPath, homeWorktree) {
let entries;
try {
entries = await readdir(sessionRoot, { withFileTypes: true });
@@ -174,6 +175,7 @@ async function listDirectoryCatalog(sessionRoot, agentsByPath) {
return {
worktreePath,
state: agent?.state ?? "inactive",
isHome: worktreePath === homeWorktree,
...(agent ? { agentId: agent.id } : {}),
};
})
@@ -249,11 +251,19 @@ export function createAgentRegistry({
const agentsByPath = new Map();
const agentsById = new Map();
const inFlight = new Map();
const forgettingPaths = new Map();
const pendingForgetOperations = new Set();
let canonicalHomeWorktree;
let stopping = false;
async function ensureAgent(worktreePath) {
if (stopping) throw new Error("agent registry is stopping");
if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath))
throw new TypeError("worktreePath must be an absolute path");
const canonicalPath = await realpath(worktreePath);
if (stopping) throw new Error("agent registry is stopping");
if (forgettingPaths.has(canonicalPath))
throw new Error("directory is being forgotten");
const existing = agentsByPath.get(canonicalPath);
if (existing) return publicAgent(existing);
const creating = inFlight.get(canonicalPath);
@@ -276,10 +286,14 @@ export function createAgentRegistry({
events: [],
listeners: new Set(),
nextSequence: 1,
stateVersion: 0,
adapter: undefined,
stopped: false,
recoveryPromise: undefined,
supervisor: undefined,
forgetting: false,
activeOperations: 0,
operationWaiters: new Set(),
};
const publish = (type, data) => {
const event = {
@@ -304,12 +318,14 @@ export function createAgentRegistry({
typeof event.data?.state === "string"
) {
agent.state = event.data.state;
agent.stateVersion += 1;
if (event.data.state === "idle") agent.supervisor.markHealthy();
}
publish(event.type, event.data);
},
onError: (error) => {
agent.state = "error";
agent.stateVersion += 1;
publish("agent_state", {
state: "error",
error: { code: error.code ?? "unknown", message: error.message },
@@ -323,6 +339,7 @@ export function createAgentRegistry({
.then((adapterAfterRecovery) => {
if (agent.stopped) return undefined;
agent.state = adapterAfterRecovery ? "idle" : "failed";
agent.stateVersion += 1;
publish("agent_state", { state: agent.state });
return adapterAfterRecovery;
});
@@ -332,6 +349,7 @@ export function createAgentRegistry({
agent.adapter = adapter;
const state = await adapter.send({ type: "get_state" });
agent.state = state?.data?.isStreaming ? "streaming" : "idle";
agent.stateVersion += 1;
const sessionPath = state?.data?.sessionFile;
if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) {
agent.sessionPath = sessionPath;
@@ -367,6 +385,25 @@ export function createAgentRegistry({
return agent;
}
async function withAgentOperation(agent, operation) {
if (agent.forgetting) throw new Error("directory is being forgotten");
agent.activeOperations += 1;
try {
return await operation();
} finally {
agent.activeOperations -= 1;
if (agent.activeOperations === 0) {
for (const resolve of agent.operationWaiters) resolve();
agent.operationWaiters.clear();
}
}
}
function waitForAgentOperations(agent) {
if (agent.activeOperations === 0) return Promise.resolve();
return new Promise((resolve) => agent.operationWaiters.add(resolve));
}
async function refreshAgentSessionReference(agent) {
const state = await agent.adapter.send({ type: "get_state" });
const sessionPath = state?.data?.sessionFile;
@@ -417,9 +454,78 @@ export function createAgentRegistry({
return response;
}
async function forgetDirectory(worktreePath) {
if (stopping) throw new Error("agent registry is stopping");
if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath))
throw new TypeError("worktreePath must be an absolute path");
let finishPendingForget;
const pendingForget = new Promise((resolve) => {
finishPendingForget = resolve;
});
pendingForgetOperations.add(pendingForget);
try {
let canonicalPath = path.normalize(worktreePath);
if (!agentsByPath.has(canonicalPath)) {
try {
canonicalPath = await realpath(worktreePath);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
const homePath = canonicalHomeWorktree ?? (await realpath(homeWorktree));
if (canonicalPath === homePath)
throw new Error("cannot forget the home directory");
const existingForget = forgettingPaths.get(canonicalPath);
if (existingForget) return existingForget;
const forgetting = (async () => {
try {
const creation = inFlight.get(canonicalPath);
if (creation) await creation;
const agent = agentsByPath.get(canonicalPath);
if (agent) {
agent.forgetting = true;
await waitForAgentOperations(agent);
if (agent.state === "streaming")
throw new Error("cannot forget a directory while Pi is working");
agent.stopped = true;
agent.supervisor.stop();
await agent.adapter.stop();
agent.state = "stopped";
agent.stateVersion += 1;
agent.listeners.clear();
agentsByPath.delete(canonicalPath);
agentsById.delete(agent.id);
}
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath);
await rm(path.join(sessionDir, SESSION_REFERENCE_FILE), {
force: true,
});
return { worktreePath: canonicalPath };
} catch (error) {
const agent = agentsByPath.get(canonicalPath);
if (agent) agent.forgetting = false;
throw error;
}
})();
forgettingPaths.set(canonicalPath, forgetting);
try {
return await forgetting;
} finally {
if (forgettingPaths.get(canonicalPath) === forgetting)
forgettingPaths.delete(canonicalPath);
}
} finally {
pendingForgetOperations.delete(pendingForget);
finishPendingForget();
}
}
return {
async start() {
return ensureAgent(homeWorktree);
const agent = await ensureAgent(homeWorktree);
canonicalHomeWorktree = agent.worktreePath;
return agent;
},
async selectWorktree(worktreePath) {
return ensureAgent(worktreePath);
@@ -432,8 +538,13 @@ export function createAgentRegistry({
);
},
async listDirectories() {
return listDirectoryCatalog(sessionRoot, agentsByPath);
return listDirectoryCatalog(
sessionRoot,
agentsByPath,
canonicalHomeWorktree,
);
},
forgetDirectory,
async listSessions(agentId) {
const agent = getAgent(agentId);
return listSessionSummaries(agent.sessionDir, agent.sessionPath);
@@ -459,30 +570,63 @@ export function createAgentRegistry({
},
async retry(agentId) {
const agent = getAgent(agentId);
return withAgentOperation(agent, async () => {
agent.supervisor.markHealthy();
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
await agent.recoveryPromise;
return publicAgent(agent);
});
},
async restart(agentId) {
const agent = getAgent(agentId);
return withAgentOperation(agent, async () => {
await agent.adapter.stop();
return this.retry(agentId);
agent.supervisor.markHealthy();
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
await agent.recoveryPromise;
return publicAgent(agent);
});
},
async route(agentId, operation, payload) {
const agent = getAgent(agentId);
return withAgentOperation(agent, async () => {
if (operation === "switch_session")
return switchSession(agent, payload.sessionPath);
if (operation === "new_session") return newSession(agent);
return routeCommand(agent.adapter, operation, payload, agent.state);
const previousState = agent.state;
const previousStateVersion = agent.stateVersion;
const startsWork =
operation === "prompt" ||
(operation === "submit_prompt" && previousState !== "streaming");
if (startsWork) agent.state = "streaming";
try {
return await routeCommand(
agent.adapter,
operation,
payload,
previousState,
);
} catch (error) {
if (startsWork && agent.stateVersion === previousStateVersion)
agent.state = previousState;
throw error;
}
});
},
async stop() {
stopping = true;
await Promise.allSettled([...inFlight.values()]);
await Promise.all([...pendingForgetOperations]);
const agents = [...agentsById.values()];
for (const agent of agents) agent.forgetting = true;
await Promise.all(agents.map(waitForAgentOperations));
await Promise.all(
[...agentsById.values()].map(async (agent) => {
agents.map(async (agent) => {
agent.stopped = true;
agent.supervisor.stop();
await agent.adapter.stop();
agent.state = "stopped";
agent.stateVersion += 1;
}),
);
},
+2
View File
@@ -30,6 +30,8 @@ export async function startBridgeService({
return {
agent: await registry.selectWorktree(request.payload.worktreePath),
};
case "forget_directory":
return registry.forgetDirectory(request.payload.worktreePath);
case "subscribe":
return {
events: registry.eventsAfter(
+1
View File
@@ -7,6 +7,7 @@ const requestOperations = new Map([
["list_agents", { agent: false, payload: "none" }],
["list_directories", { agent: false, payload: "none" }],
["select_agent", { agent: false, payload: "worktree" }],
["forget_directory", { agent: false, payload: "worktree" }],
["get_state", { agent: true, payload: "none" }],
["get_session_stats", { agent: true, payload: "none" }],
["list_sessions", { agent: true, payload: "none" }],
+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 () => {
+10 -4
View File
@@ -16,19 +16,25 @@ From the repository root:
```bash
npm --prefix ui ci
node src/bridge/cli.js --worktree "$HOME"
npm --prefix ui run tauri dev
```
The second command starts the bridge; run it in a separate terminal. The Tauri app connects to it on startup.
The UI needs a running bridge. For day-to-day use, keep it in the background with the root project's `npm run bridge:service:install` command. For foreground development, run the bridge in a separate terminal:
```bash
node src/bridge/cli.js --worktree "$HOME"
```
The Tauri app connects to the bridge on startup. See the [root README](../README.md#keep-the-bridge-running-in-the-background-recommended) for start, restart, status, and log commands.
## Commands
```bash
npm --prefix ui run dev # Vite frontend only
npm --prefix ui run build # typecheck and build frontend assets
npm --prefix ui run tauri dev # run the desktop client
npm --prefix ui run tauri build # package a desktop bundle
npm --prefix ui run tauri dev # run the desktop client in development
npm --prefix ui run tauri:release # build the production executable used by Noctalia/Mod+Space
npm --prefix ui run tauri build # package installers (requires platform bundle tooling)
```
## Boundaries
+2 -1
View File
@@ -13,7 +13,8 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"tauri:release": "tauri build --no-bundle"
},
"dependencies": {
"react": "^19.1.0",
+1
View File
@@ -1,3 +1,4 @@
fn main() {
println!("cargo:rerun-if-changed=../dist");
tauri_build::build()
}
+10
View File
@@ -100,6 +100,16 @@ pub async fn select_worktree(socket_path: &str, worktree_path: &str) -> Result<V
.await
}
pub async fn forget_directory(socket_path: &str, worktree_path: &str) -> Result<Value, String> {
request(
socket_path,
"forget_directory",
None,
Some(json!({ "worktreePath": worktree_path })),
)
.await
}
pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result<Value, String> {
request(socket_path, "list_sessions", Some(agent_id), None).await
}
+44 -1
View File
@@ -6,6 +6,22 @@ use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
struct Subscription(Mutex<Option<JoinHandle<()>>>);
#[derive(Debug, PartialEq, Eq)]
enum WindowAction {
Hide,
ShowAndFocus,
}
fn window_action(args: &[String], is_visible: bool) -> WindowAction {
if args.iter().any(|arg| arg == "--hide")
|| (args.iter().any(|arg| arg == "--toggle") && is_visible)
{
WindowAction::Hide
} else {
WindowAction::ShowAndFocus
}
}
fn socket_path() -> Result<String, String> {
bridge::default_socket_path()
}
@@ -25,6 +41,11 @@ async fn select_worktree(worktree_path: String) -> Result<Value, String> {
bridge::select_worktree(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn forget_directory(worktree_path: String) -> Result<Value, String> {
bridge::forget_directory(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn load_agent(agent_id: String) -> Result<Value, String> {
bridge::load_agent(&socket_path()?, &agent_id).await
@@ -121,16 +142,24 @@ fn subscribe_agent(
pub fn run() {
let builder = tauri::Builder::default()
.manage(Subscription(Mutex::new(None)))
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
match window_action(&args, window.is_visible().unwrap_or(false)) {
WindowAction::Hide => {
let _ = window.hide();
}
WindowAction::ShowAndFocus => {
let _ = window.show();
let _ = window.set_focus();
}
}
}
}))
.invoke_handler(tauri::generate_handler![
list_agents,
list_directories,
select_worktree,
forget_directory,
load_agent,
list_sessions,
switch_session,
@@ -149,3 +178,17 @@ pub fn run() {
eprintln!("Pi Status UI exited: {error}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
let toggle = vec!["--toggle".to_owned()];
assert_eq!(window_action(&toggle, true), WindowAction::Hide);
assert_eq!(window_action(&toggle, false), WindowAction::ShowAndFocus);
assert_eq!(window_action(&vec!["--show".to_owned()], true), WindowAction::ShowAndFocus);
}
}
+2 -2
View File
@@ -13,8 +13,8 @@
"windows": [
{
"title": "Pi Status UI",
"width": 760,
"height": 620,
"width": 900,
"height": 720,
"minWidth": 480,
"minHeight": 420,
"center": true,
+123 -25
View File
@@ -9,9 +9,22 @@
color: #ebebe8;
background: #101111;
}
* {
*,
*::before,
*::after {
box-sizing: border-box;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
body {
margin: 0;
min-width: 480px;
@@ -37,6 +50,13 @@ button {
button:hover {
background: #f0ffe0;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid #c4ed8b;
outline-offset: 2px;
}
.app-shell {
position: relative;
@@ -70,8 +90,7 @@ button:hover {
.workspace-heading,
.status-row,
.composer-actions,
.settings-actions,
.folder-form {
.settings-actions {
display: flex;
align-items: center;
gap: 8px;
@@ -148,14 +167,20 @@ h1 {
padding: 7px 9px;
}
.agents {
.directory-tabs,
.directory-tab-list {
display: flex;
align-items: center;
gap: 6px;
min-height: 30px;
min-height: 32px;
}
.directory-tabs {
overflow-x: auto;
padding: 1px 0 3px;
}
.directory-tab-list {
gap: 3px;
}
.agent-caption {
flex: 0 0 auto;
color: #70776f;
@@ -164,34 +189,93 @@ h1 {
letter-spacing: 0.08em;
text-transform: uppercase;
}
.agent {
.directory-tab {
display: flex;
flex: 0 0 auto;
align-items: stretch;
}
.directory-tab-button {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 180px;
padding: 5px 8px;
border: 1px solid #303530;
border-right: 0;
border-radius: 7px 0 0 7px;
padding: 5px 8px;
background: #1b1e1b;
color: #aeb4ac;
font-size: 11px;
font-weight: 650;
}
.agent > span {
.directory-tab-button > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent small {
.directory-tab-button small {
color: #70776f;
font-size: 10px;
font-weight: 600;
}
.agent.selected {
.directory-tab-button.selected {
border-color: #7da856;
background: #222b1d;
color: #ecf7df;
}
.directory-tab-button.selected + .directory-close {
border-color: #7da856;
background: #222b1d;
}
.directory-tab-button:only-child {
border-right: 1px solid #303530;
border-radius: 7px;
}
.directory-tab-button.selected:only-child {
border-color: #7da856;
}
.directory-close {
min-width: 24px;
border: 1px solid #303530;
border-radius: 0 7px 7px 0;
padding: 3px 6px;
background: #1b1e1b;
color: #aeb4ac;
font-size: 15px;
line-height: 1;
}
.directory-tab:focus-within .directory-tab-button,
.directory-tab:focus-within .directory-close {
border-color: #c4ed8b;
}
.directory-close:hover {
background: #3a2222;
color: #ffc0c0;
}
.directory-close:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.directory-add-toggle {
flex: 0 0 auto;
padding: 5px 8px;
white-space: nowrap;
}
.directory-add {
display: flex;
flex: 0 0 auto;
align-items: stretch;
gap: 5px;
}
.directory-add input {
width: 220px;
padding: 5px 7px;
font-size: 11px;
}
.directory-add button {
padding: 5px 8px;
}
.notifications {
display: grid;
@@ -500,9 +584,9 @@ textarea {
line-height: 1.35;
}
.composer textarea:focus {
outline: none;
border-color: #6f8b56;
box-shadow: 0 0 0 1px rgba(143, 189, 95, 0.28);
outline: 2px solid #c4ed8b;
outline-offset: -2px;
}
.composer-actions {
justify-content: flex-end;
@@ -657,15 +741,6 @@ h2 {
margin: 0;
font-size: 14px;
}
.folder-form {
align-items: stretch;
}
.remembered {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.settings-actions {
justify-content: flex-start;
}
@@ -684,6 +759,15 @@ h2 {
.session-panel-heading h2 {
margin-bottom: 3px;
}
.session-actions {
display: flex;
flex: 0 0 auto;
gap: 5px;
}
.session-actions button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.session-panel-heading .muted {
max-width: 460px;
overflow: hidden;
@@ -727,9 +811,18 @@ h2 {
opacity: 0.5;
cursor: not-allowed;
}
.extension {
.extension-backdrop {
position: absolute;
inset: auto 14px 14px;
z-index: 10;
inset: 0;
display: flex;
align-items: end;
justify-content: center;
padding: 14px;
background: rgba(0, 0, 0, 0.5);
}
.extension {
width: min(100%, 680px);
max-height: min(70vh, 460px);
overflow: auto;
border: 1px solid #9cc76d;
@@ -746,8 +839,13 @@ h2 {
}
@media (prefers-reduced-motion: reduce) {
.transcript.working {
animation: none;
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+322 -58
View File
@@ -1,4 +1,11 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
type KeyboardEvent as ReactKeyboardEvent,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
@@ -8,6 +15,7 @@ type Agent = { id: string; worktreePath: string; state: string };
type Directory = {
worktreePath: string;
state: string;
isHome: boolean;
agentId?: string;
};
type Session = {
@@ -122,7 +130,6 @@ const thinkingLevels = [
"xhigh",
"max",
];
const rememberedKey = "pi-status-ui.remembered-worktrees";
const selectedPathKey = "pi-status-ui.selected-worktree";
const controlCommands: Command[] = [
{
@@ -198,12 +205,12 @@ function currentTodos(messages: Message[]) {
return [];
}
function rememberedPaths() {
try {
return JSON.parse(localStorage.getItem(rememberedKey) ?? "[]") as string[];
} catch {
return [];
}
function focusableExtensionControls(container: HTMLElement) {
return [
...container.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
),
];
}
function App() {
@@ -221,6 +228,7 @@ function App() {
const [commands, setCommands] = useState<Command[]>([]);
const [message, setMessage] = useState("");
const [folderPath, setFolderPath] = useState("");
const [addingDirectory, setAddingDirectory] = useState(false);
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
const [view, setView] = useState<"conversation" | "settings">("conversation");
const [commandFollowUp, setCommandFollowUp] = useState<FollowUpCommand>();
@@ -237,8 +245,13 @@ function App() {
Record<string, ExtensionWidget>
>({});
const transcriptRef = useRef<HTMLElement>(null);
const extensionRef = useRef<HTMLElement>(null);
const directoryTabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const pendingSubmissionId = useRef(0);
const loadGeneration = useRef(0);
const refreshGeneration = useRef(0);
const bridgeRetryTimer = useRef<number | undefined>(undefined);
const bridgeRetryAttempt = useRef(0);
const lastEventSequence = useRef<Record<string, number>>({});
const [workProgress, setWorkProgress] = useState<WorkProgress>({
phase: "idle",
@@ -247,6 +260,13 @@ function App() {
});
const selected = agents.find((agent) => agent.id === selectedId);
const selectedDirectoryIndex = directories.findIndex(
(directory) => directory.agentId === selectedId,
);
const selectedDirectoryTabId =
selectedDirectoryIndex >= 0
? `directory-tab-${selectedDirectoryIndex}`
: undefined;
const filteredCommands = useMemo(() => {
if (!message.startsWith("/")) return [];
const uniqueCommands = [...controlCommands, ...commands].filter(
@@ -282,6 +302,16 @@ function App() {
const loadedCommands = unpack(snapshot.commands);
const loadedModels = unpack(snapshot.models);
setState(loadedState as AgentState);
setDirectories((current) =>
current.map((directory) =>
directory.agentId === agentId
? {
...directory,
state: loadedState.isStreaming ? "streaming" : "idle",
}
: directory,
),
);
setStats(loadedStats as SessionStats);
setMessages((loadedTranscript.messages as Message[] | undefined) ?? []);
setCommands((loadedCommands.commands as Command[] | undefined) ?? []);
@@ -304,17 +334,36 @@ function App() {
await invoke("subscribe_agent", { agentId, cursor: 0 });
} catch (error) {
setStatus(String(error));
scheduleBridgeReconnect();
}
}
function scheduleBridgeReconnect(preferredPath?: string) {
if (bridgeRetryTimer.current !== undefined) return;
const delay = Math.min(250 * 2 ** bridgeRetryAttempt.current, 2_000);
bridgeRetryAttempt.current += 1;
setStatus(`Bridge unavailable; retrying in ${delay}ms…`);
bridgeRetryTimer.current = window.setTimeout(() => {
bridgeRetryTimer.current = undefined;
void refreshAgents(preferredPath);
}, delay);
}
async function refreshAgents(
preferredPath = localStorage.getItem(selectedPathKey) ?? undefined,
) {
const generation = ++refreshGeneration.current;
try {
const [agentResponse, directoryResponse] = await Promise.all([
invoke<{ agents: Agent[] }>("list_agents"),
invoke<{ directories: Directory[] }>("list_directories"),
]);
if (generation !== refreshGeneration.current) return;
if (bridgeRetryTimer.current !== undefined) {
window.clearTimeout(bridgeRetryTimer.current);
bridgeRetryTimer.current = undefined;
}
bridgeRetryAttempt.current = 0;
setAgents(agentResponse.agents);
setDirectories(directoryResponse.directories);
const next =
@@ -329,7 +378,8 @@ function App() {
await loadAgent(next.id, true);
} else setStatus("No Pi agents available");
} catch (error) {
setStatus(String(error));
if (generation !== refreshGeneration.current) return;
scheduleBridgeReconnect(preferredPath);
}
}
@@ -346,6 +396,10 @@ function App() {
useEffect(() => {
void refreshAgents();
return () => {
if (bridgeRetryTimer.current !== undefined)
window.clearTimeout(bridgeRetryTimer.current);
};
}, []);
useEffect(() => {
@@ -541,6 +595,18 @@ function App() {
if (bridgeEvent.type === "extension_ui_request") {
handleExtension(bridgeEvent.data?.event as Extension | undefined);
} else {
if (
bridgeEvent.type === "agent_state" &&
typeof bridgeEvent.data?.state === "string"
) {
setDirectories((current) =>
current.map((directory) =>
directory.agentId === bridgeEvent.agentId
? { ...directory, state: bridgeEvent.data?.state as string }
: directory,
),
);
}
updateWorkProgress(bridgeEvent);
if (selectedId) void loadAgent(selectedId);
}
@@ -551,7 +617,7 @@ function App() {
setStatus(
event.payload.message ?? "Bridge connection lost; reconnecting…",
);
window.setTimeout(() => void refreshAgents(), 500);
scheduleBridgeReconnect();
}).then((stop) => {
unlistenError = stop;
});
@@ -580,7 +646,58 @@ function App() {
else await activateWorktree(directory.worktreePath);
}
async function startNewSession() {
function handleDirectoryTabKeyDown(
event: ReactKeyboardEvent<HTMLButtonElement>,
index: number,
) {
let nextIndex: number | undefined;
if (event.key === "ArrowRight")
nextIndex = (index + 1) % directories.length;
else if (event.key === "ArrowLeft")
nextIndex = (index - 1 + directories.length) % directories.length;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = directories.length - 1;
if (nextIndex === undefined) return;
event.preventDefault();
const nextDirectory = directories[nextIndex];
directoryTabRefs.current[nextDirectory.worktreePath]?.focus();
}
async function forgetDirectory(directory: Directory) {
if (directory.isHome) return;
if (directory.state === "streaming") {
setStatus("Wait for Pi to finish before forgetting this directory");
return;
}
if (
!window.confirm(
`Forget ${directory.worktreePath}? Its saved sessions will remain available when you add it again.`,
)
)
return;
try {
const closingIndex = directories.findIndex(
(candidate) => candidate.worktreePath === directory.worktreePath,
);
const nextFocusPath =
directories[closingIndex + 1]?.worktreePath ??
directories[closingIndex - 1]?.worktreePath;
await invoke("forget_directory", {
worktreePath: directory.worktreePath,
});
const wasSelected = directory.agentId === selectedId;
if (wasSelected) localStorage.removeItem(selectedPathKey);
await refreshAgents(wasSelected ? "" : selected?.worktreePath);
window.requestAnimationFrame(() => {
if (nextFocusPath) directoryTabRefs.current[nextFocusPath]?.focus();
});
setStatus(`Forgot ${worktreeLabel(directory.worktreePath)}`);
} catch (error) {
setStatus(String(error));
}
}
async function startNewSession(successMessage = "Started a new Pi session") {
if (!selectedId) return;
try {
const response = await invoke<{ data?: { cancelled?: boolean } }>(
@@ -594,12 +711,34 @@ function App() {
await loadAgent(selectedId, true);
await loadSessions(selectedId);
setCommandFollowUp(undefined);
setStatus("Started a new Pi session");
setStatus(successMessage);
} catch (error) {
setStatus(String(error));
}
}
async function closeCurrentSession() {
if (
!window.confirm(
"Close the current session and start a new one? The current history remains available through /resume.",
)
)
return;
await startNewSession(
"Closed the current session. Use /resume to reopen it.",
);
}
async function restartPi() {
if (
!window.confirm(
"Restart Pi for the selected directory? Any in-progress response will be interrupted.",
)
)
return;
await invokeAgent("restart");
}
async function switchSession(session: Session) {
if (!selectedId || session.isCurrent) return;
try {
@@ -649,10 +788,9 @@ function App() {
const result = await invoke<{ agent: Agent }>("select_worktree", {
worktreePath,
});
const paths = [...new Set([...rememberedPaths(), worktreePath])];
localStorage.setItem(rememberedKey, JSON.stringify(paths));
localStorage.setItem(selectedPathKey, worktreePath);
setFolderPath("");
setAddingDirectory(false);
setSelectedId(result.agent.id);
await refreshAgents(worktreePath);
setView("conversation");
@@ -724,17 +862,25 @@ function App() {
useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (event.key !== "Escape") return;
if (pendingExtension) return;
event.preventDefault();
event.stopPropagation();
if (commandFollowUp) {
setCommandFollowUp(undefined);
return;
}
if (addingDirectory) {
setAddingDirectory(false);
return;
}
void getCurrentWindow()
.hide()
.catch((error) => setStatus(String(error)));
}
};
window.addEventListener("keydown", handleKeydown, true);
return () => window.removeEventListener("keydown", handleKeydown, true);
}, []);
}, [addingDirectory, commandFollowUp, pendingExtension]);
async function respondToExtension(response: Record<string, unknown>) {
if (!selectedId || !pendingExtension?.id) return;
@@ -751,6 +897,37 @@ function App() {
}
}
useEffect(() => {
if (!pendingExtension) return;
const dialog = extensionRef.current;
if (!dialog) return;
const controls = focusableExtensionControls(dialog);
const initial =
dialog.querySelector<HTMLElement>("[data-initial-focus]") ?? controls[0];
initial?.focus();
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
void respondToExtension({ cancelled: true });
return;
}
if (event.key !== "Tab") return;
const focusable = focusableExtensionControls(dialog);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [pendingExtension]);
const extensionMethod = pendingExtension?.method ?? "";
const workLabel =
workProgress.phase === "recovering"
@@ -789,10 +966,13 @@ function App() {
{workLabel}
</span>
</div>
<div className="status-row" role="status">
<span title={status}>{status}</span>
<div className="status-row">
<span role="status" aria-atomic="true" title={status}>
{status}
</span>
{contextWindow && (
<span
aria-hidden="true"
className="status-metric"
title={`Current context: ${formatTokens(contextTokens)} / ${formatTokens(contextWindow)} tokens`}
>
@@ -802,6 +982,7 @@ function App() {
)}
{stats.tokens?.total !== undefined && (
<span
aria-hidden="true"
className="status-metric"
title={`Session tokens — input: ${formatTokens(stats.tokens.input)}, output: ${formatTokens(stats.tokens.output)}, cache read: ${formatTokens(stats.tokens.cacheRead)}, cache write: ${formatTokens(stats.tokens.cacheWrite)}`}
>
@@ -819,6 +1000,8 @@ function App() {
</div>
</div>
<button
aria-controls="directory-workspace"
aria-expanded={view === "settings"}
className="quiet compact-button"
onClick={() =>
setView(view === "conversation" ? "settings" : "conversation")
@@ -828,23 +1011,86 @@ function App() {
</button>
</header>
<section className="agents" aria-label="Managed directories">
<section className="directory-tabs" aria-label="Managed directories">
<div
aria-label="Managed directories"
className="directory-tab-list"
role="tablist"
>
<span className="agent-caption">Directories</span>
{directories.map((directory) => (
<button
className={
directory.agentId === selectedId ? "agent selected" : "agent"
}
{directories.map((directory, index) => {
const isSelected = directory.agentId === selectedId;
return (
<div
className="directory-tab"
key={directory.worktreePath}
title={directory.worktreePath}
role="presentation"
>
<button
aria-controls="directory-workspace"
aria-label={`${worktreeLabel(directory.worktreePath) ?? directory.worktreePath}, ${directory.state}${directory.isHome ? ", home directory" : ""}`}
aria-selected={directory.agentId === selectedId}
className={
isSelected
? "directory-tab-button selected"
: "directory-tab-button"
}
id={`directory-tab-${index}`}
onClick={() => void chooseDirectory(directory)}
onKeyDown={(event) => handleDirectoryTabKeyDown(event, index)}
ref={(element) => {
directoryTabRefs.current[directory.worktreePath] = element;
}}
role="tab"
tabIndex={isSelected || (!selectedId && index === 0) ? 0 : -1}
title={directory.worktreePath}
>
<span>
{worktreeLabel(directory.worktreePath) ?? directory.worktreePath}
{worktreeLabel(directory.worktreePath) ??
directory.worktreePath}
</span>
<small>{directory.state}</small>
</button>
))}
{!directory.isHome && (
<button
aria-label={`Forget ${directory.worktreePath}`}
className="directory-close"
disabled={directory.state === "streaming"}
title={`Forget ${directory.worktreePath}`}
onClick={() => void forgetDirectory(directory)}
>
×
</button>
)}
</div>
);
})}
</div>
<button
aria-expanded={addingDirectory}
className="quiet directory-add-toggle"
onClick={() => setAddingDirectory((current) => !current)}
>
{addingDirectory ? "Cancel" : "Add directory"}
</button>
{addingDirectory && (
<form
className="directory-add"
onSubmit={(event) => {
event.preventDefault();
void addFolder();
}}
>
<input
autoFocus
value={folderPath}
onChange={(event) => setFolderPath(event.currentTarget.value)}
placeholder="/absolute/path/to/project"
aria-label="Directory path"
/>
<button type="submit">Add</button>
</form>
)}
</section>
{extensionNotifications.length > 0 && (
@@ -853,6 +1099,7 @@ function App() {
<div
className={`notification ${notice.notifyType ?? "info"}`}
key={`${notice.id ?? "notice"}-${index}`}
role={notice.notifyType === "error" ? "alert" : "status"}
>
{notice.message ?? "Extension notification"}
</div>
@@ -861,32 +1108,13 @@ function App() {
)}
{view === "settings" ? (
<section className="settings">
<h2>Agents and session</h2>
<div className="folder-form">
<input
value={folderPath}
onChange={(event) => setFolderPath(event.currentTarget.value)}
placeholder="/absolute/path/to/project"
/>
<button onClick={() => void addFolder()}>Add folder</button>
</div>
<div className="remembered">
<p className="muted">Remembered folders</p>
{rememberedPaths().length ? (
rememberedPaths().map((path) => (
<button
className="quiet"
key={path}
onClick={() => void activateWorktree(path)}
<section
aria-labelledby={selectedDirectoryTabId}
className="settings"
id="directory-workspace"
role="tabpanel"
>
{path}
</button>
))
) : (
<p className="muted">Home only</p>
)}
</div>
<h2>Session controls</h2>
<p className="muted">
{state.sessionName ?? state.sessionId ?? "Session"} ·{" "}
{state.messageCount ?? 0} messages ·{" "}
@@ -904,6 +1132,15 @@ function App() {
"Open a directory to view its sessions"}
</p>
</div>
<div className="session-actions">
<button
aria-label="Close current session and start a new session"
className="quiet"
disabled={!selectedId || state.isStreaming}
onClick={() => void closeCurrentSession()}
>
Close current
</button>
<button
disabled={!selectedId || state.isStreaming}
onClick={() => void startNewSession()}
@@ -911,10 +1148,13 @@ function App() {
New session
</button>
</div>
</div>
{sessions.length ? (
<div className="session-list">
{sessions.map((session) => (
<button
aria-current={session.isCurrent ? "page" : undefined}
aria-label={`${session.name ?? session.firstMessage ?? "Untitled session"}${session.isCurrent ? ", current session" : `, ${session.messageCount} messages`}`}
className={
session.isCurrent ? "session current" : "session quiet"
}
@@ -944,8 +1184,9 @@ function App() {
Retry
</button>
<button
aria-label="Restart Pi for the selected directory"
className="quiet danger"
onClick={() => void invokeAgent("restart")}
onClick={() => void restartPi()}
>
Restart Pi
</button>
@@ -953,7 +1194,12 @@ function App() {
</section>
) : (
<>
<div className="workflow">
<div
aria-labelledby={selectedDirectoryTabId}
className="workflow"
id="directory-workspace"
role="tabpanel"
>
<div className="workflow-main">
<section
className={`transcript ${pendingExtension ? "with-extension" : ""} ${workProgress.phase}`}
@@ -1227,9 +1473,21 @@ function App() {
)}
{pendingExtension && (
<section className="extension" aria-label="Pi extension request">
<h2>{pendingExtension.title ?? "Pi extension request"}</h2>
<p>{pendingExtension.message ?? "Choose a response."}</p>
<div className="extension-backdrop">
<section
className="extension"
ref={extensionRef}
role="dialog"
aria-modal="true"
aria-labelledby="extension-title"
aria-describedby="extension-description"
>
<h2 id="extension-title">
{pendingExtension.title ?? "Pi extension request"}
</h2>
<p id="extension-description">
{pendingExtension.message ?? "Choose a response."}
</p>
{extensionMethod === "select" &&
pendingExtension.options?.map((option) => (
<button
@@ -1256,7 +1514,11 @@ function App() {
)}
{(extensionMethod === "input" || extensionMethod === "editor") && (
<>
<label className="sr-only" htmlFor="extension-value">
Extension response
</label>
<textarea
id="extension-value"
value={extensionValue}
onChange={(event) =>
setExtensionValue(event.currentTarget.value)
@@ -1276,12 +1538,14 @@ function App() {
</>
)}
<button
data-initial-focus
className="quiet"
onClick={() => void respondToExtension({ cancelled: true })}
>
Cancel request
</button>
</section>
</div>
)}
</main>
);