From a792c577ef06a24117ea9a3b4f8688ee57107934 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Tue, 28 Jul 2026 21:15:25 +0200 Subject: [PATCH] feat(status-ui): improve bridge recovery and desktop controls --- README.md | 27 +- .../pi-status-bridge/bridge.luau | 60 +- package.json | 8 +- specs/bugs/BUG-20260727-widget-toggle.md | 76 +++ specs/bugs/registry.yaml | 9 + src/bridge/agent-registry.js | 172 +++++- src/bridge/service.js | 2 + src/protocol/index.js | 1 + test/agent-registry.test.js | 256 ++++++++- test/bridge-service.test.js | 14 + test/noctalia-v5-plugin.test.js | 7 + test/protocol.test.js | 14 + test/startup-docs.test.js | 44 ++ test/tauri-build.test.js | 8 + test/tauri-ui.test.js | 91 ++- ui/README.md | 18 +- ui/package.json | 3 +- ui/src-tauri/build.rs | 1 + ui/src-tauri/src/bridge.rs | 10 + ui/src-tauri/src/lib.rs | 49 +- ui/src-tauri/tauri.conf.json | 4 +- ui/src/App.css | 148 ++++- ui/src/App.tsx | 518 +++++++++++++----- 23 files changed, 1336 insertions(+), 204 deletions(-) create mode 100644 specs/bugs/BUG-20260727-widget-toggle.md create mode 100644 specs/bugs/registry.yaml create mode 100644 test/startup-docs.test.js create mode 100644 test/tauri-build.test.js diff --git a/README.md b/README.md index 974e0ac..5335fc8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/noctalia-v5-plugin/pi-status-bridge/bridge.luau b/noctalia-v5-plugin/pi-status-bridge/bridge.luau index b0e7348..2052f43 100644 --- a/noctalia-v5-plugin/pi-status-bridge/bridge.luau +++ b/noctalia-v5-plugin/pi-status-bridge/bridge.luau @@ -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 diff --git a/package.json b/package.json index d014668..e079a12 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/specs/bugs/BUG-20260727-widget-toggle.md b/specs/bugs/BUG-20260727-widget-toggle.md new file mode 100644 index 0000000..58695f1 --- /dev/null +++ b/specs/bugs/BUG-20260727-widget-toggle.md @@ -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. diff --git a/specs/bugs/registry.yaml b/specs/bugs/registry.yaml new file mode 100644 index 0000000..a3ea646 --- /dev/null +++ b/specs/bugs/registry.yaml @@ -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 diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js index e55fee7..5e3518b 100644 --- a/src/bridge/agent-registry.js +++ b/src/bridge/agent-registry.js @@ -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); - agent.supervisor.markHealthy(); - agent.recoveryPromise = agent.supervisor.handleUnexpectedExit(); - await agent.recoveryPromise; - return publicAgent(agent); + 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); - await agent.adapter.stop(); - return this.retry(agentId); + return withAgentOperation(agent, async () => { + await agent.adapter.stop(); + agent.supervisor.markHealthy(); + agent.recoveryPromise = agent.supervisor.handleUnexpectedExit(); + await agent.recoveryPromise; + return publicAgent(agent); + }); }, async route(agentId, operation, payload) { const agent = getAgent(agentId); - if (operation === "switch_session") - return switchSession(agent, payload.sessionPath); - if (operation === "new_session") return newSession(agent); - return routeCommand(agent.adapter, operation, payload, agent.state); + return withAgentOperation(agent, async () => { + if (operation === "switch_session") + return switchSession(agent, payload.sessionPath); + 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() { + 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; }), ); }, diff --git a/src/bridge/service.js b/src/bridge/service.js index f920907..710fa7e 100644 --- a/src/bridge/service.js +++ b/src/bridge/service.js @@ -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( diff --git a/src/protocol/index.js b/src/protocol/index.js index ee00cbc..0fdea25 100644 --- a/src/protocol/index.js +++ b/src/protocol/index.js @@ -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" }], diff --git a/test/agent-registry.test.js b/test/agent-registry.test.js index 1328c20..1c400d6 100644 --- a/test/agent-registry.test.js +++ b/test/agent-registry.test.js @@ -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, }, ]); diff --git a/test/bridge-service.test.js b/test/bridge-service.test.js index d187a90..7bf7aac 100644 --- a/test/bridge-service.test.js +++ b/test/bridge-service.test.js @@ -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(); diff --git a/test/noctalia-v5-plugin.test.js b/test/noctalia-v5-plugin.test.js index 7db3cba..36f5b71 100644 --- a/test/noctalia-v5-plugin.test.js +++ b/test/noctalia-v5-plugin.test.js @@ -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/); }); diff --git a/test/protocol.test.js b/test/protocol.test.js index 8cb72cb..7087aa6 100644 --- a/test/protocol.test.js +++ b/test/protocol.test.js @@ -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" })), { diff --git a/test/startup-docs.test.js b/test/startup-docs.test.js new file mode 100644 index 0000000..7fcb58a --- /dev/null +++ b/test/startup-docs.test.js @@ -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"); +}); diff --git a/test/tauri-build.test.js b/test/tauri-build.test.js new file mode 100644 index 0000000..f7a53bd --- /dev/null +++ b/test/tauri-build.test.js @@ -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/); +}); diff --git a/test/tauri-ui.test.js b/test/tauri-ui.test.js index bba00b0..bafbbde 100644 --- a/test/tauri-ui.test.js +++ b/test/tauri-ui.test.js @@ -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\(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>/, @@ -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\(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 () => { diff --git a/ui/README.md b/ui/README.md index 0d9de14..ed7b570 100644 --- a/ui/README.md +++ b/ui/README.md @@ -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 dev # Vite frontend only +npm --prefix ui run build # typecheck and build frontend assets +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 diff --git a/ui/package.json b/ui/package.json index 146ee02..c732c1f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src-tauri/build.rs b/ui/src-tauri/build.rs index d860e1e..6190642 100644 --- a/ui/src-tauri/build.rs +++ b/ui/src-tauri/build.rs @@ -1,3 +1,4 @@ fn main() { + println!("cargo:rerun-if-changed=../dist"); tauri_build::build() } diff --git a/ui/src-tauri/src/bridge.rs b/ui/src-tauri/src/bridge.rs index de1e632..f9c5dc5 100644 --- a/ui/src-tauri/src/bridge.rs +++ b/ui/src-tauri/src/bridge.rs @@ -100,6 +100,16 @@ pub async fn select_worktree(socket_path: &str, worktree_path: &str) -> Result Result { + request( + socket_path, + "forget_directory", + None, + Some(json!({ "worktreePath": worktree_path })), + ) + .await +} + pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result { request(socket_path, "list_sessions", Some(agent_id), None).await } diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs index c3e6fcc..25eb0f4 100644 --- a/ui/src-tauri/src/lib.rs +++ b/ui/src-tauri/src/lib.rs @@ -6,6 +6,22 @@ use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State}; struct Subscription(Mutex>>); +#[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 { bridge::default_socket_path() } @@ -25,6 +41,11 @@ async fn select_worktree(worktree_path: String) -> Result { bridge::select_worktree(&socket_path()?, &worktree_path).await } +#[tauri::command] +async fn forget_directory(worktree_path: String) -> Result { + bridge::forget_directory(&socket_path()?, &worktree_path).await +} + #[tauri::command] async fn load_agent(agent_id: String) -> Result { 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") { - let _ = window.show(); - let _ = window.set_focus(); + 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); + } +} diff --git a/ui/src-tauri/tauri.conf.json b/ui/src-tauri/tauri.conf.json index cc3ba3c..7bc8072 100644 --- a/ui/src-tauri/tauri.conf.json +++ b/ui/src-tauri/tauri.conf.json @@ -13,8 +13,8 @@ "windows": [ { "title": "Pi Status UI", - "width": 760, - "height": 620, + "width": 900, + "height": 720, "minWidth": 480, "minHeight": 420, "center": true, diff --git a/ui/src/App.css b/ui/src/App.css index 547b032..4781fb7 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -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; } } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b58f662..fc7438f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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( + '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([]); 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(); @@ -237,8 +245,13 @@ function App() { Record >({}); const transcriptRef = useRef(null); + const extensionRef = useRef(null); + const directoryTabRefs = useRef>({}); const pendingSubmissionId = useRef(0); const loadGeneration = useRef(0); + const refreshGeneration = useRef(0); + const bridgeRetryTimer = useRef(undefined); + const bridgeRetryAttempt = useRef(0); const lastEventSequence = useRef>({}); const [workProgress, setWorkProgress] = useState({ 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, + 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") { - event.preventDefault(); - event.stopPropagation(); - void getCurrentWindow() - .hide() - .catch((error) => setStatus(String(error))); + 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) { 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("[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} -
- {status} +
+ + {status} + {contextWindow && (
-
- Directories - {directories.map((directory) => ( - + {!directory.isHome && ( + + )} + + ); + })} + + + {addingDirectory && ( +
{ + event.preventDefault(); + void addFolder(); + }} > - - {worktreeLabel(directory.worktreePath) ?? directory.worktreePath} - - {directory.state} - - ))} + setFolderPath(event.currentTarget.value)} + placeholder="/absolute/path/to/project" + aria-label="Directory path" + /> + +
+ )}
{extensionNotifications.length > 0 && ( @@ -853,6 +1099,7 @@ function App() {
{notice.message ?? "Extension notification"}
@@ -861,32 +1108,13 @@ function App() { )} {view === "settings" ? ( -
-

Agents and session

-
- setFolderPath(event.currentTarget.value)} - placeholder="/absolute/path/to/project" - /> - -
-
-

Remembered folders

- {rememberedPaths().length ? ( - rememberedPaths().map((path) => ( - - )) - ) : ( -

Home only

- )} -
+
+

Session controls

{state.sessionName ?? state.sessionId ?? "Session"} ·{" "} {state.messageCount ?? 0} messages ·{" "} @@ -904,17 +1132,29 @@ function App() { "Open a directory to view its sessions"}

- +
+ + +
{sessions.length ? (
{sessions.map((session) => ( @@ -953,7 +1194,12 @@ function App() {
) : ( <> -
+
-

{pendingExtension.title ?? "Pi extension request"}

-

{pendingExtension.message ?? "Choose a response."}

- {extensionMethod === "select" && - pendingExtension.options?.map((option) => ( - - ))} - {extensionMethod === "confirm" && ( -
- - -
- )} - {(extensionMethod === "input" || extensionMethod === "editor") && ( - <> -