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. 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 ```bash
npm install --global . npm install --global .
command -v pi-status-bridge pi command -v pi-status-bridge pi
npm run bridge:service:install
```
mkdir -p ~/.config/systemd/user The npm commands below are short, copyable wrappers around `systemctl --user`:
cp systemd/pi-status-bridge.service ~/.config/systemd/user/
systemctl --user daemon-reload ```bash
systemctl --user enable --now pi-status-bridge.service npm run bridge:service:start # start the bridge
systemctl --user status pi-status-bridge.service 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. 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 ## Development
```bash ```bash
+46 -14
View File
@@ -16,16 +16,58 @@ local function socket_path()
return runtime .. "/pi-status-bridge/bridge.sock" return runtime .. "/pi-status-bridge/bridge.sock"
end end
local bridge_start_in_flight = false
local pending_ui_open = false
local function current_agent_id() local function current_agent_id()
return noctalia.state.get("agent_id") return noctalia.state.get("agent_id")
end 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) local function discover_home_agent(socket)
if request_in_flight then return end if request_in_flight then return end
request_in_flight = true request_in_flight = true
noctalia.runAsync("pi-status-bridge-client --socket \"" .. socket .. "\" request '{\"op\":\"list_agents\"}'", function(result) noctalia.runAsync("pi-status-bridge-client --socket \"" .. socket .. "\" request '{\"op\":\"list_agents\"}'", function(result)
request_in_flight = false 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") local home = noctalia.getenv("HOME")
for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do
if worktree == home then if worktree == home then
@@ -42,9 +84,7 @@ function update()
local agent_id = current_agent_id() local agent_id = current_agent_id()
local socket = socket_path() local socket = socket_path()
if socket == nil then if socket == nil then
barWidget.setGlyph("alert-triangle") start_bridge(false)
barWidget.setText("Pi offline")
barWidget.setTooltip("Bridge socket is unavailable")
return return
end end
if agent_id == nil then if agent_id == nil then
@@ -63,7 +103,7 @@ function update()
request_in_flight = false request_in_flight = false
if result.exitCode ~= 0 then if result.exitCode ~= 0 then
noctalia.state.set("agent_id", nil) noctalia.state.set("agent_id", nil)
discover_home_agent(socket) start_bridge(false)
barWidget.setGlyph("terminal-2") barWidget.setGlyph("terminal-2")
barWidget.setText("Pi reconnecting") barWidget.setText("Pi reconnecting")
barWidget.setTooltip("Refreshing the Home Pi agent") barWidget.setTooltip("Refreshing the Home Pi agent")
@@ -77,13 +117,5 @@ function update()
end end
function onClick() function onClick()
local command = "setsid -f env TMPDIR=/tmp " .. shell_quote(ui_binary()) start_bridge(true)
.. " --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)
end end
+7 -1
View File
@@ -26,6 +26,12 @@
"scripts": { "scripts": {
"test": "node --test test/*.test.js && npm run test:ui", "test": "node --test test/*.test.js && npm run test:ui",
"test:ui": "cargo test --manifest-path ui/src-tauri/Cargo.toml", "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
+158 -14
View File
@@ -5,6 +5,7 @@ import {
readdir, readdir,
readFile, readFile,
realpath, realpath,
rm,
stat, stat,
writeFile, writeFile,
} from "node:fs/promises"; } from "node:fs/promises";
@@ -141,7 +142,7 @@ async function listSessionSummaries(sessionDir, currentPath) {
.sort((left, right) => right.modified.localeCompare(left.modified)); .sort((left, right) => right.modified.localeCompare(left.modified));
} }
async function listDirectoryCatalog(sessionRoot, agentsByPath) { async function listDirectoryCatalog(sessionRoot, agentsByPath, homeWorktree) {
let entries; let entries;
try { try {
entries = await readdir(sessionRoot, { withFileTypes: true }); entries = await readdir(sessionRoot, { withFileTypes: true });
@@ -174,6 +175,7 @@ async function listDirectoryCatalog(sessionRoot, agentsByPath) {
return { return {
worktreePath, worktreePath,
state: agent?.state ?? "inactive", state: agent?.state ?? "inactive",
isHome: worktreePath === homeWorktree,
...(agent ? { agentId: agent.id } : {}), ...(agent ? { agentId: agent.id } : {}),
}; };
}) })
@@ -249,11 +251,19 @@ export function createAgentRegistry({
const agentsByPath = new Map(); const agentsByPath = new Map();
const agentsById = new Map(); const agentsById = new Map();
const inFlight = new Map(); const inFlight = new Map();
const forgettingPaths = new Map();
const pendingForgetOperations = new Set();
let canonicalHomeWorktree;
let stopping = false;
async function ensureAgent(worktreePath) { async function ensureAgent(worktreePath) {
if (stopping) throw new Error("agent registry is stopping");
if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath)) if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath))
throw new TypeError("worktreePath must be an absolute path"); throw new TypeError("worktreePath must be an absolute path");
const canonicalPath = await realpath(worktreePath); 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); const existing = agentsByPath.get(canonicalPath);
if (existing) return publicAgent(existing); if (existing) return publicAgent(existing);
const creating = inFlight.get(canonicalPath); const creating = inFlight.get(canonicalPath);
@@ -276,10 +286,14 @@ export function createAgentRegistry({
events: [], events: [],
listeners: new Set(), listeners: new Set(),
nextSequence: 1, nextSequence: 1,
stateVersion: 0,
adapter: undefined, adapter: undefined,
stopped: false, stopped: false,
recoveryPromise: undefined, recoveryPromise: undefined,
supervisor: undefined, supervisor: undefined,
forgetting: false,
activeOperations: 0,
operationWaiters: new Set(),
}; };
const publish = (type, data) => { const publish = (type, data) => {
const event = { const event = {
@@ -304,12 +318,14 @@ export function createAgentRegistry({
typeof event.data?.state === "string" typeof event.data?.state === "string"
) { ) {
agent.state = event.data.state; agent.state = event.data.state;
agent.stateVersion += 1;
if (event.data.state === "idle") agent.supervisor.markHealthy(); if (event.data.state === "idle") agent.supervisor.markHealthy();
} }
publish(event.type, event.data); publish(event.type, event.data);
}, },
onError: (error) => { onError: (error) => {
agent.state = "error"; agent.state = "error";
agent.stateVersion += 1;
publish("agent_state", { publish("agent_state", {
state: "error", state: "error",
error: { code: error.code ?? "unknown", message: error.message }, error: { code: error.code ?? "unknown", message: error.message },
@@ -323,6 +339,7 @@ export function createAgentRegistry({
.then((adapterAfterRecovery) => { .then((adapterAfterRecovery) => {
if (agent.stopped) return undefined; if (agent.stopped) return undefined;
agent.state = adapterAfterRecovery ? "idle" : "failed"; agent.state = adapterAfterRecovery ? "idle" : "failed";
agent.stateVersion += 1;
publish("agent_state", { state: agent.state }); publish("agent_state", { state: agent.state });
return adapterAfterRecovery; return adapterAfterRecovery;
}); });
@@ -332,6 +349,7 @@ export function createAgentRegistry({
agent.adapter = adapter; agent.adapter = adapter;
const state = await adapter.send({ type: "get_state" }); const state = await adapter.send({ type: "get_state" });
agent.state = state?.data?.isStreaming ? "streaming" : "idle"; agent.state = state?.data?.isStreaming ? "streaming" : "idle";
agent.stateVersion += 1;
const sessionPath = state?.data?.sessionFile; const sessionPath = state?.data?.sessionFile;
if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) { if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) {
agent.sessionPath = sessionPath; agent.sessionPath = sessionPath;
@@ -367,6 +385,25 @@ export function createAgentRegistry({
return agent; 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) { async function refreshAgentSessionReference(agent) {
const state = await agent.adapter.send({ type: "get_state" }); const state = await agent.adapter.send({ type: "get_state" });
const sessionPath = state?.data?.sessionFile; const sessionPath = state?.data?.sessionFile;
@@ -417,9 +454,78 @@ export function createAgentRegistry({
return response; 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 { return {
async start() { async start() {
return ensureAgent(homeWorktree); const agent = await ensureAgent(homeWorktree);
canonicalHomeWorktree = agent.worktreePath;
return agent;
}, },
async selectWorktree(worktreePath) { async selectWorktree(worktreePath) {
return ensureAgent(worktreePath); return ensureAgent(worktreePath);
@@ -432,8 +538,13 @@ export function createAgentRegistry({
); );
}, },
async listDirectories() { async listDirectories() {
return listDirectoryCatalog(sessionRoot, agentsByPath); return listDirectoryCatalog(
sessionRoot,
agentsByPath,
canonicalHomeWorktree,
);
}, },
forgetDirectory,
async listSessions(agentId) { async listSessions(agentId) {
const agent = getAgent(agentId); const agent = getAgent(agentId);
return listSessionSummaries(agent.sessionDir, agent.sessionPath); return listSessionSummaries(agent.sessionDir, agent.sessionPath);
@@ -459,30 +570,63 @@ export function createAgentRegistry({
}, },
async retry(agentId) { async retry(agentId) {
const agent = getAgent(agentId); const agent = getAgent(agentId);
agent.supervisor.markHealthy(); return withAgentOperation(agent, async () => {
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit(); agent.supervisor.markHealthy();
await agent.recoveryPromise; agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
return publicAgent(agent); await agent.recoveryPromise;
return publicAgent(agent);
});
}, },
async restart(agentId) { async restart(agentId) {
const agent = getAgent(agentId); const agent = getAgent(agentId);
await agent.adapter.stop(); return withAgentOperation(agent, async () => {
return this.retry(agentId); await agent.adapter.stop();
agent.supervisor.markHealthy();
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
await agent.recoveryPromise;
return publicAgent(agent);
});
}, },
async route(agentId, operation, payload) { async route(agentId, operation, payload) {
const agent = getAgent(agentId); const agent = getAgent(agentId);
if (operation === "switch_session") return withAgentOperation(agent, async () => {
return switchSession(agent, payload.sessionPath); if (operation === "switch_session")
if (operation === "new_session") return newSession(agent); return switchSession(agent, payload.sessionPath);
return routeCommand(agent.adapter, operation, payload, agent.state); if (operation === "new_session") return newSession(agent);
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() { 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( await Promise.all(
[...agentsById.values()].map(async (agent) => { agents.map(async (agent) => {
agent.stopped = true; agent.stopped = true;
agent.supervisor.stop(); agent.supervisor.stop();
await agent.adapter.stop(); await agent.adapter.stop();
agent.state = "stopped"; agent.state = "stopped";
agent.stateVersion += 1;
}), }),
); );
}, },
+2
View File
@@ -30,6 +30,8 @@ export async function startBridgeService({
return { return {
agent: await registry.selectWorktree(request.payload.worktreePath), agent: await registry.selectWorktree(request.payload.worktreePath),
}; };
case "forget_directory":
return registry.forgetDirectory(request.payload.worktreePath);
case "subscribe": case "subscribe":
return { return {
events: registry.eventsAfter( events: registry.eventsAfter(
+1
View File
@@ -7,6 +7,7 @@ const requestOperations = new Map([
["list_agents", { agent: false, payload: "none" }], ["list_agents", { agent: false, payload: "none" }],
["list_directories", { agent: false, payload: "none" }], ["list_directories", { agent: false, payload: "none" }],
["select_agent", { agent: false, payload: "worktree" }], ["select_agent", { agent: false, payload: "worktree" }],
["forget_directory", { agent: false, payload: "worktree" }],
["get_state", { agent: true, payload: "none" }], ["get_state", { agent: true, payload: "none" }],
["get_session_stats", { agent: true, payload: "none" }], ["get_session_stats", { agent: true, payload: "none" }],
["list_sessions", { 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 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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import test from "node:test"; 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)); 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 () => { test("catalogues managed directories and exposes only their sessions", async () => {
const worktrees = await createWorktrees(); const worktrees = await createWorktrees();
const fixture = createAdapterFactory(); const fixture = createAdapterFactory();
@@ -151,10 +398,15 @@ test("catalogues managed directories and exposes only their sessions", async ()
}); });
await restored.start(); await restored.start();
assert.deepEqual(await restored.listDirectories(), [ assert.deepEqual(await restored.listDirectories(), [
{ worktreePath: worktrees.feature, state: "inactive" }, {
worktreePath: worktrees.feature,
state: "inactive",
isHome: false,
},
{ {
worktreePath: worktrees.home, worktreePath: worktrees.home,
state: "idle", state: "idle",
isHome: true,
agentId: restored.listAgents()[0].id, 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: "get_state" },
{ type: "prompt", message: "Use this worktree" }, { 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 { } finally {
socket.destroy(); socket.destroy();
await service.close(); 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_BINARY/);
assert.match(bridge, /pi-status-ui/); assert.match(bridge, /pi-status-ui/);
assert.match(bridge, /noctalia\.runAsync/); assert.match(bridge, /noctalia\.runAsync/);
assert.match(bridge, /test -x/);
assert.match(bridge, /setsid -f env TMPDIR=\/tmp/); assert.match(bridge, /setsid -f env TMPDIR=\/tmp/);
assert.match(bridge, /--toggle/);
assert.doesNotMatch(bridge, /--show/);
assert.match(bridge, /pi-status-ui\.log/); assert.match(bridge, /pi-status-ui\.log/);
assert.doesNotMatch(bridge, /togglePanel/); assert.doesNotMatch(bridge, /togglePanel/);
assert.match(bridge, /Pi reconnecting/); assert.match(bridge, /Pi reconnecting/);
assert.match(bridge, /discover_home_agent\(socket\)/); 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", id: "request-1",
op: "list_directories", 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( assert.deepEqual(
parseRequestFrame(request({ op: "list_sessions", agentId: "agent-main" })), 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, /transcript\.scrollTop = transcript\.scrollHeight/);
assert.match(source, /requestAnimationFrame\(scrollToLatest\)/); assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
assert.match(source, /const loadGeneration = useRef\(0\)/); 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( assert.match(
source, source,
/const lastEventSequence = useRef<Record<string, number>>/, /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 () => { test("makes the window draggable from the non-interactive header", async () => {
const [component, styles, capability] = await Promise.all([ const [component, styles, capability] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"), 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"/, /invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
); );
assert.match(component, /function chooseDirectory/); 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 startNewSession/);
assert.match(component, /function closeCurrentSession/);
assert.match(component, /Use \/resume to reopen it/);
assert.match(component, /function switchSession/); assert.match(component, /function switchSession/);
assert.match(component, /className="session-panel"/); assert.match(component, /className="session-panel"/);
assert.match(component, /New session/); assert.match(component, /New session/);
assert.match(component, /state\.isStreaming/); 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-panel \{/);
assert.match(styles, /\.session-list \{/); assert.match(styles, /\.session-list \{/);
}); });
@@ -163,11 +243,18 @@ test("distinguishes user and assistant messages", async () => {
assert.match(styles, /background: #1a241a/); 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"); const styles = await readFile("ui/src/App.css", "utf8");
assert.match(styles, /\.composer textarea:focus \{/); 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, /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 () => { test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
+12 -6
View File
@@ -16,19 +16,25 @@ From the repository root:
```bash ```bash
npm --prefix ui ci npm --prefix ui ci
node src/bridge/cli.js --worktree "$HOME"
npm --prefix ui run tauri dev 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 ## Commands
```bash ```bash
npm --prefix ui run dev # Vite frontend only npm --prefix ui run dev # Vite frontend only
npm --prefix ui run build # typecheck and build frontend assets 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 dev # run the desktop client in development
npm --prefix ui run tauri build # package a desktop bundle 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 ## Boundaries
+2 -1
View File
@@ -13,7 +13,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri",
"tauri:release": "tauri build --no-bundle"
}, },
"dependencies": { "dependencies": {
"react": "^19.1.0", "react": "^19.1.0",
+1
View File
@@ -1,3 +1,4 @@
fn main() { fn main() {
println!("cargo:rerun-if-changed=../dist");
tauri_build::build() tauri_build::build()
} }
+10
View File
@@ -100,6 +100,16 @@ pub async fn select_worktree(socket_path: &str, worktree_path: &str) -> Result<V
.await .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> { pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result<Value, String> {
request(socket_path, "list_sessions", Some(agent_id), None).await request(socket_path, "list_sessions", Some(agent_id), None).await
} }
+46 -3
View File
@@ -6,6 +6,22 @@ use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
struct Subscription(Mutex<Option<JoinHandle<()>>>); 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> { fn socket_path() -> Result<String, String> {
bridge::default_socket_path() 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 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] #[tauri::command]
async fn load_agent(agent_id: String) -> Result<Value, String> { async fn load_agent(agent_id: String) -> Result<Value, String> {
bridge::load_agent(&socket_path()?, &agent_id).await bridge::load_agent(&socket_path()?, &agent_id).await
@@ -121,16 +142,24 @@ fn subscribe_agent(
pub fn run() { pub fn run() {
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
.manage(Subscription(Mutex::new(None))) .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") { if let Some(window) = app.get_webview_window("main") {
let _ = window.show(); match window_action(&args, window.is_visible().unwrap_or(false)) {
let _ = window.set_focus(); WindowAction::Hide => {
let _ = window.hide();
}
WindowAction::ShowAndFocus => {
let _ = window.show();
let _ = window.set_focus();
}
}
} }
})) }))
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
list_agents, list_agents,
list_directories, list_directories,
select_worktree, select_worktree,
forget_directory,
load_agent, load_agent,
list_sessions, list_sessions,
switch_session, switch_session,
@@ -149,3 +178,17 @@ pub fn run() {
eprintln!("Pi Status UI exited: {error}"); 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": [ "windows": [
{ {
"title": "Pi Status UI", "title": "Pi Status UI",
"width": 760, "width": 900,
"height": 620, "height": 720,
"minWidth": 480, "minWidth": 480,
"minHeight": 420, "minHeight": 420,
"center": true, "center": true,
+123 -25
View File
@@ -9,9 +9,22 @@
color: #ebebe8; color: #ebebe8;
background: #101111; background: #101111;
} }
* { *,
*::before,
*::after {
box-sizing: border-box; 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 { body {
margin: 0; margin: 0;
min-width: 480px; min-width: 480px;
@@ -37,6 +50,13 @@ button {
button:hover { button:hover {
background: #f0ffe0; background: #f0ffe0;
} }
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid #c4ed8b;
outline-offset: 2px;
}
.app-shell { .app-shell {
position: relative; position: relative;
@@ -70,8 +90,7 @@ button:hover {
.workspace-heading, .workspace-heading,
.status-row, .status-row,
.composer-actions, .composer-actions,
.settings-actions, .settings-actions {
.folder-form {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -148,14 +167,20 @@ h1 {
padding: 7px 9px; padding: 7px 9px;
} }
.agents { .directory-tabs,
.directory-tab-list {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-height: 30px; min-height: 32px;
}
.directory-tabs {
overflow-x: auto; overflow-x: auto;
padding: 1px 0 3px; padding: 1px 0 3px;
} }
.directory-tab-list {
gap: 3px;
}
.agent-caption { .agent-caption {
flex: 0 0 auto; flex: 0 0 auto;
color: #70776f; color: #70776f;
@@ -164,34 +189,93 @@ h1 {
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
} }
.agent { .directory-tab {
display: flex;
flex: 0 0 auto;
align-items: stretch;
}
.directory-tab-button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
max-width: 180px; max-width: 180px;
padding: 5px 8px;
border: 1px solid #303530; border: 1px solid #303530;
border-right: 0;
border-radius: 7px 0 0 7px;
padding: 5px 8px;
background: #1b1e1b; background: #1b1e1b;
color: #aeb4ac; color: #aeb4ac;
font-size: 11px; font-size: 11px;
font-weight: 650; font-weight: 650;
} }
.agent > span { .directory-tab-button > span {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.agent small { .directory-tab-button small {
color: #70776f; color: #70776f;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
} }
.agent.selected { .directory-tab-button.selected {
border-color: #7da856; border-color: #7da856;
background: #222b1d; background: #222b1d;
color: #ecf7df; 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 { .notifications {
display: grid; display: grid;
@@ -500,9 +584,9 @@ textarea {
line-height: 1.35; line-height: 1.35;
} }
.composer textarea:focus { .composer textarea:focus {
outline: none;
border-color: #6f8b56; border-color: #6f8b56;
box-shadow: 0 0 0 1px rgba(143, 189, 95, 0.28); outline: 2px solid #c4ed8b;
outline-offset: -2px;
} }
.composer-actions { .composer-actions {
justify-content: flex-end; justify-content: flex-end;
@@ -657,15 +741,6 @@ h2 {
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
} }
.folder-form {
align-items: stretch;
}
.remembered {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.settings-actions { .settings-actions {
justify-content: flex-start; justify-content: flex-start;
} }
@@ -684,6 +759,15 @@ h2 {
.session-panel-heading h2 { .session-panel-heading h2 {
margin-bottom: 3px; 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 { .session-panel-heading .muted {
max-width: 460px; max-width: 460px;
overflow: hidden; overflow: hidden;
@@ -727,9 +811,18 @@ h2 {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.extension { .extension-backdrop {
position: absolute; 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); max-height: min(70vh, 460px);
overflow: auto; overflow: auto;
border: 1px solid #9cc76d; border: 1px solid #9cc76d;
@@ -746,8 +839,13 @@ h2 {
} }
@media (prefers-reduced-motion: reduce) { @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;
} }
} }
+391 -127
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 { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
@@ -8,6 +15,7 @@ type Agent = { id: string; worktreePath: string; state: string };
type Directory = { type Directory = {
worktreePath: string; worktreePath: string;
state: string; state: string;
isHome: boolean;
agentId?: string; agentId?: string;
}; };
type Session = { type Session = {
@@ -122,7 +130,6 @@ const thinkingLevels = [
"xhigh", "xhigh",
"max", "max",
]; ];
const rememberedKey = "pi-status-ui.remembered-worktrees";
const selectedPathKey = "pi-status-ui.selected-worktree"; const selectedPathKey = "pi-status-ui.selected-worktree";
const controlCommands: Command[] = [ const controlCommands: Command[] = [
{ {
@@ -198,12 +205,12 @@ function currentTodos(messages: Message[]) {
return []; return [];
} }
function rememberedPaths() { function focusableExtensionControls(container: HTMLElement) {
try { return [
return JSON.parse(localStorage.getItem(rememberedKey) ?? "[]") as string[]; ...container.querySelectorAll<HTMLElement>(
} catch { 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
return []; ),
} ];
} }
function App() { function App() {
@@ -221,6 +228,7 @@ function App() {
const [commands, setCommands] = useState<Command[]>([]); const [commands, setCommands] = useState<Command[]>([]);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [folderPath, setFolderPath] = useState(""); const [folderPath, setFolderPath] = useState("");
const [addingDirectory, setAddingDirectory] = useState(false);
const [status, setStatus] = useState("Connecting to Pi Status Bridge…"); const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
const [view, setView] = useState<"conversation" | "settings">("conversation"); const [view, setView] = useState<"conversation" | "settings">("conversation");
const [commandFollowUp, setCommandFollowUp] = useState<FollowUpCommand>(); const [commandFollowUp, setCommandFollowUp] = useState<FollowUpCommand>();
@@ -237,8 +245,13 @@ function App() {
Record<string, ExtensionWidget> Record<string, ExtensionWidget>
>({}); >({});
const transcriptRef = useRef<HTMLElement>(null); const transcriptRef = useRef<HTMLElement>(null);
const extensionRef = useRef<HTMLElement>(null);
const directoryTabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const pendingSubmissionId = useRef(0); const pendingSubmissionId = useRef(0);
const loadGeneration = 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 lastEventSequence = useRef<Record<string, number>>({});
const [workProgress, setWorkProgress] = useState<WorkProgress>({ const [workProgress, setWorkProgress] = useState<WorkProgress>({
phase: "idle", phase: "idle",
@@ -247,6 +260,13 @@ function App() {
}); });
const selected = agents.find((agent) => agent.id === selectedId); 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(() => { const filteredCommands = useMemo(() => {
if (!message.startsWith("/")) return []; if (!message.startsWith("/")) return [];
const uniqueCommands = [...controlCommands, ...commands].filter( const uniqueCommands = [...controlCommands, ...commands].filter(
@@ -282,6 +302,16 @@ function App() {
const loadedCommands = unpack(snapshot.commands); const loadedCommands = unpack(snapshot.commands);
const loadedModels = unpack(snapshot.models); const loadedModels = unpack(snapshot.models);
setState(loadedState as AgentState); setState(loadedState as AgentState);
setDirectories((current) =>
current.map((directory) =>
directory.agentId === agentId
? {
...directory,
state: loadedState.isStreaming ? "streaming" : "idle",
}
: directory,
),
);
setStats(loadedStats as SessionStats); setStats(loadedStats as SessionStats);
setMessages((loadedTranscript.messages as Message[] | undefined) ?? []); setMessages((loadedTranscript.messages as Message[] | undefined) ?? []);
setCommands((loadedCommands.commands as Command[] | undefined) ?? []); setCommands((loadedCommands.commands as Command[] | undefined) ?? []);
@@ -304,17 +334,36 @@ function App() {
await invoke("subscribe_agent", { agentId, cursor: 0 }); await invoke("subscribe_agent", { agentId, cursor: 0 });
} catch (error) { } catch (error) {
setStatus(String(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( async function refreshAgents(
preferredPath = localStorage.getItem(selectedPathKey) ?? undefined, preferredPath = localStorage.getItem(selectedPathKey) ?? undefined,
) { ) {
const generation = ++refreshGeneration.current;
try { try {
const [agentResponse, directoryResponse] = await Promise.all([ const [agentResponse, directoryResponse] = await Promise.all([
invoke<{ agents: Agent[] }>("list_agents"), invoke<{ agents: Agent[] }>("list_agents"),
invoke<{ directories: Directory[] }>("list_directories"), 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); setAgents(agentResponse.agents);
setDirectories(directoryResponse.directories); setDirectories(directoryResponse.directories);
const next = const next =
@@ -329,7 +378,8 @@ function App() {
await loadAgent(next.id, true); await loadAgent(next.id, true);
} else setStatus("No Pi agents available"); } else setStatus("No Pi agents available");
} catch (error) { } catch (error) {
setStatus(String(error)); if (generation !== refreshGeneration.current) return;
scheduleBridgeReconnect(preferredPath);
} }
} }
@@ -346,6 +396,10 @@ function App() {
useEffect(() => { useEffect(() => {
void refreshAgents(); void refreshAgents();
return () => {
if (bridgeRetryTimer.current !== undefined)
window.clearTimeout(bridgeRetryTimer.current);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -541,6 +595,18 @@ function App() {
if (bridgeEvent.type === "extension_ui_request") { if (bridgeEvent.type === "extension_ui_request") {
handleExtension(bridgeEvent.data?.event as Extension | undefined); handleExtension(bridgeEvent.data?.event as Extension | undefined);
} else { } 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); updateWorkProgress(bridgeEvent);
if (selectedId) void loadAgent(selectedId); if (selectedId) void loadAgent(selectedId);
} }
@@ -551,7 +617,7 @@ function App() {
setStatus( setStatus(
event.payload.message ?? "Bridge connection lost; reconnecting…", event.payload.message ?? "Bridge connection lost; reconnecting…",
); );
window.setTimeout(() => void refreshAgents(), 500); scheduleBridgeReconnect();
}).then((stop) => { }).then((stop) => {
unlistenError = stop; unlistenError = stop;
}); });
@@ -580,7 +646,58 @@ function App() {
else await activateWorktree(directory.worktreePath); 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; if (!selectedId) return;
try { try {
const response = await invoke<{ data?: { cancelled?: boolean } }>( const response = await invoke<{ data?: { cancelled?: boolean } }>(
@@ -594,12 +711,34 @@ function App() {
await loadAgent(selectedId, true); await loadAgent(selectedId, true);
await loadSessions(selectedId); await loadSessions(selectedId);
setCommandFollowUp(undefined); setCommandFollowUp(undefined);
setStatus("Started a new Pi session"); setStatus(successMessage);
} catch (error) { } catch (error) {
setStatus(String(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) { async function switchSession(session: Session) {
if (!selectedId || session.isCurrent) return; if (!selectedId || session.isCurrent) return;
try { try {
@@ -649,10 +788,9 @@ function App() {
const result = await invoke<{ agent: Agent }>("select_worktree", { const result = await invoke<{ agent: Agent }>("select_worktree", {
worktreePath, worktreePath,
}); });
const paths = [...new Set([...rememberedPaths(), worktreePath])];
localStorage.setItem(rememberedKey, JSON.stringify(paths));
localStorage.setItem(selectedPathKey, worktreePath); localStorage.setItem(selectedPathKey, worktreePath);
setFolderPath(""); setFolderPath("");
setAddingDirectory(false);
setSelectedId(result.agent.id); setSelectedId(result.agent.id);
await refreshAgents(worktreePath); await refreshAgents(worktreePath);
setView("conversation"); setView("conversation");
@@ -724,17 +862,25 @@ function App() {
useEffect(() => { useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key !== "Escape") return;
event.preventDefault(); if (pendingExtension) return;
event.stopPropagation(); event.preventDefault();
void getCurrentWindow() event.stopPropagation();
.hide() if (commandFollowUp) {
.catch((error) => setStatus(String(error))); setCommandFollowUp(undefined);
return;
} }
if (addingDirectory) {
setAddingDirectory(false);
return;
}
void getCurrentWindow()
.hide()
.catch((error) => setStatus(String(error)));
}; };
window.addEventListener("keydown", handleKeydown, true); window.addEventListener("keydown", handleKeydown, true);
return () => window.removeEventListener("keydown", handleKeydown, true); return () => window.removeEventListener("keydown", handleKeydown, true);
}, []); }, [addingDirectory, commandFollowUp, pendingExtension]);
async function respondToExtension(response: Record<string, unknown>) { async function respondToExtension(response: Record<string, unknown>) {
if (!selectedId || !pendingExtension?.id) return; 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 extensionMethod = pendingExtension?.method ?? "";
const workLabel = const workLabel =
workProgress.phase === "recovering" workProgress.phase === "recovering"
@@ -789,10 +966,13 @@ function App() {
{workLabel} {workLabel}
</span> </span>
</div> </div>
<div className="status-row" role="status"> <div className="status-row">
<span title={status}>{status}</span> <span role="status" aria-atomic="true" title={status}>
{status}
</span>
{contextWindow && ( {contextWindow && (
<span <span
aria-hidden="true"
className="status-metric" className="status-metric"
title={`Current context: ${formatTokens(contextTokens)} / ${formatTokens(contextWindow)} tokens`} title={`Current context: ${formatTokens(contextTokens)} / ${formatTokens(contextWindow)} tokens`}
> >
@@ -802,6 +982,7 @@ function App() {
)} )}
{stats.tokens?.total !== undefined && ( {stats.tokens?.total !== undefined && (
<span <span
aria-hidden="true"
className="status-metric" 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)}`} 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>
</div> </div>
<button <button
aria-controls="directory-workspace"
aria-expanded={view === "settings"}
className="quiet compact-button" className="quiet compact-button"
onClick={() => onClick={() =>
setView(view === "conversation" ? "settings" : "conversation") setView(view === "conversation" ? "settings" : "conversation")
@@ -828,23 +1011,86 @@ function App() {
</button> </button>
</header> </header>
<section className="agents" aria-label="Managed directories"> <section className="directory-tabs" aria-label="Managed directories">
<span className="agent-caption">Directories</span> <div
{directories.map((directory) => ( aria-label="Managed directories"
<button className="directory-tab-list"
className={ role="tablist"
directory.agentId === selectedId ? "agent selected" : "agent" >
} <span className="agent-caption">Directories</span>
key={directory.worktreePath} {directories.map((directory, index) => {
title={directory.worktreePath} const isSelected = directory.agentId === selectedId;
onClick={() => void chooseDirectory(directory)} return (
<div
className="directory-tab"
key={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}
</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();
}}
> >
<span> <input
{worktreeLabel(directory.worktreePath) ?? directory.worktreePath} autoFocus
</span> value={folderPath}
<small>{directory.state}</small> onChange={(event) => setFolderPath(event.currentTarget.value)}
</button> placeholder="/absolute/path/to/project"
))} aria-label="Directory path"
/>
<button type="submit">Add</button>
</form>
)}
</section> </section>
{extensionNotifications.length > 0 && ( {extensionNotifications.length > 0 && (
@@ -853,6 +1099,7 @@ function App() {
<div <div
className={`notification ${notice.notifyType ?? "info"}`} className={`notification ${notice.notifyType ?? "info"}`}
key={`${notice.id ?? "notice"}-${index}`} key={`${notice.id ?? "notice"}-${index}`}
role={notice.notifyType === "error" ? "alert" : "status"}
> >
{notice.message ?? "Extension notification"} {notice.message ?? "Extension notification"}
</div> </div>
@@ -861,32 +1108,13 @@ function App() {
)} )}
{view === "settings" ? ( {view === "settings" ? (
<section className="settings"> <section
<h2>Agents and session</h2> aria-labelledby={selectedDirectoryTabId}
<div className="folder-form"> className="settings"
<input id="directory-workspace"
value={folderPath} role="tabpanel"
onChange={(event) => setFolderPath(event.currentTarget.value)} >
placeholder="/absolute/path/to/project" <h2>Session controls</h2>
/>
<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)}
>
{path}
</button>
))
) : (
<p className="muted">Home only</p>
)}
</div>
<p className="muted"> <p className="muted">
{state.sessionName ?? state.sessionId ?? "Session"} ·{" "} {state.sessionName ?? state.sessionId ?? "Session"} ·{" "}
{state.messageCount ?? 0} messages ·{" "} {state.messageCount ?? 0} messages ·{" "}
@@ -904,17 +1132,29 @@ function App() {
"Open a directory to view its sessions"} "Open a directory to view its sessions"}
</p> </p>
</div> </div>
<button <div className="session-actions">
disabled={!selectedId || state.isStreaming} <button
onClick={() => void startNewSession()} aria-label="Close current session and start a new session"
> className="quiet"
New session disabled={!selectedId || state.isStreaming}
</button> onClick={() => void closeCurrentSession()}
>
Close current
</button>
<button
disabled={!selectedId || state.isStreaming}
onClick={() => void startNewSession()}
>
New session
</button>
</div>
</div> </div>
{sessions.length ? ( {sessions.length ? (
<div className="session-list"> <div className="session-list">
{sessions.map((session) => ( {sessions.map((session) => (
<button <button
aria-current={session.isCurrent ? "page" : undefined}
aria-label={`${session.name ?? session.firstMessage ?? "Untitled session"}${session.isCurrent ? ", current session" : `, ${session.messageCount} messages`}`}
className={ className={
session.isCurrent ? "session current" : "session quiet" session.isCurrent ? "session current" : "session quiet"
} }
@@ -944,8 +1184,9 @@ function App() {
Retry Retry
</button> </button>
<button <button
aria-label="Restart Pi for the selected directory"
className="quiet danger" className="quiet danger"
onClick={() => void invokeAgent("restart")} onClick={() => void restartPi()}
> >
Restart Pi Restart Pi
</button> </button>
@@ -953,7 +1194,12 @@ function App() {
</section> </section>
) : ( ) : (
<> <>
<div className="workflow"> <div
aria-labelledby={selectedDirectoryTabId}
className="workflow"
id="directory-workspace"
role="tabpanel"
>
<div className="workflow-main"> <div className="workflow-main">
<section <section
className={`transcript ${pendingExtension ? "with-extension" : ""} ${workProgress.phase}`} className={`transcript ${pendingExtension ? "with-extension" : ""} ${workProgress.phase}`}
@@ -1227,61 +1473,79 @@ function App() {
)} )}
{pendingExtension && ( {pendingExtension && (
<section className="extension" aria-label="Pi extension request"> <div className="extension-backdrop">
<h2>{pendingExtension.title ?? "Pi extension request"}</h2> <section
<p>{pendingExtension.message ?? "Choose a response."}</p> className="extension"
{extensionMethod === "select" && ref={extensionRef}
pendingExtension.options?.map((option) => ( role="dialog"
<button aria-modal="true"
key={option} aria-labelledby="extension-title"
onClick={() => void respondToExtension({ value: option })} aria-describedby="extension-description"
>
{option}
</button>
))}
{extensionMethod === "confirm" && (
<div className="composer-actions">
<button
onClick={() => void respondToExtension({ confirmed: true })}
>
Confirm
</button>
<button
className="quiet"
onClick={() => void respondToExtension({ confirmed: false })}
>
Decline
</button>
</div>
)}
{(extensionMethod === "input" || extensionMethod === "editor") && (
<>
<textarea
value={extensionValue}
onChange={(event) =>
setExtensionValue(event.currentTarget.value)
}
placeholder={
pendingExtension.placeholder ?? "Extension response"
}
rows={4}
/>
<button
onClick={() =>
void respondToExtension({ value: extensionValue })
}
>
Submit
</button>
</>
)}
<button
className="quiet"
onClick={() => void respondToExtension({ cancelled: true })}
> >
Cancel request <h2 id="extension-title">
</button> {pendingExtension.title ?? "Pi extension request"}
</section> </h2>
<p id="extension-description">
{pendingExtension.message ?? "Choose a response."}
</p>
{extensionMethod === "select" &&
pendingExtension.options?.map((option) => (
<button
key={option}
onClick={() => void respondToExtension({ value: option })}
>
{option}
</button>
))}
{extensionMethod === "confirm" && (
<div className="composer-actions">
<button
onClick={() => void respondToExtension({ confirmed: true })}
>
Confirm
</button>
<button
className="quiet"
onClick={() => void respondToExtension({ confirmed: false })}
>
Decline
</button>
</div>
)}
{(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)
}
placeholder={
pendingExtension.placeholder ?? "Extension response"
}
rows={4}
/>
<button
onClick={() =>
void respondToExtension({ value: extensionValue })
}
>
Submit
</button>
</>
)}
<button
data-initial-focus
className="quiet"
onClick={() => void respondToExtension({ cancelled: true })}
>
Cancel request
</button>
</section>
</div>
)} )}
</main> </main>
); );