feat(status-ui): improve bridge recovery and desktop controls
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -16,16 +16,58 @@ local function socket_path()
|
||||
return runtime .. "/pi-status-bridge/bridge.sock"
|
||||
end
|
||||
|
||||
local bridge_start_in_flight = false
|
||||
local pending_ui_open = false
|
||||
|
||||
local function current_agent_id()
|
||||
return noctalia.state.get("agent_id")
|
||||
end
|
||||
|
||||
local function open_ui()
|
||||
local binary = shell_quote(ui_binary())
|
||||
local command = "if test -x " .. binary .. "; then setsid -f env TMPDIR=/tmp "
|
||||
.. binary .. " --toggle >/tmp/pi-status-ui.log 2>&1; else exit 1; fi"
|
||||
noctalia.runAsync(command, function(result)
|
||||
if result.exitCode ~= 0 then
|
||||
barWidget.setGlyph("alert-triangle")
|
||||
barWidget.setText("Pi UI unavailable")
|
||||
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function start_bridge(open_after_start)
|
||||
if open_after_start then pending_ui_open = true end
|
||||
if bridge_start_in_flight then return end
|
||||
bridge_start_in_flight = true
|
||||
barWidget.setGlyph("terminal-2")
|
||||
barWidget.setText("Pi starting")
|
||||
barWidget.setTooltip("Bridge is starting")
|
||||
noctalia.runAsync("systemctl --user start pi-status-bridge.service", function(result)
|
||||
bridge_start_in_flight = false
|
||||
if result.exitCode ~= 0 then
|
||||
pending_ui_open = false
|
||||
barWidget.setGlyph("alert-triangle")
|
||||
barWidget.setText("Pi offline")
|
||||
barWidget.setTooltip("Could not start pi-status-bridge.service")
|
||||
return
|
||||
end
|
||||
if pending_ui_open then
|
||||
pending_ui_open = false
|
||||
open_ui()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function discover_home_agent(socket)
|
||||
if request_in_flight then return end
|
||||
request_in_flight = true
|
||||
noctalia.runAsync("pi-status-bridge-client --socket \"" .. socket .. "\" request '{\"op\":\"list_agents\"}'", function(result)
|
||||
request_in_flight = false
|
||||
if result.exitCode ~= 0 then return end
|
||||
if result.exitCode ~= 0 then
|
||||
start_bridge(false)
|
||||
return
|
||||
end
|
||||
local home = noctalia.getenv("HOME")
|
||||
for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do
|
||||
if worktree == home then
|
||||
@@ -42,9 +84,7 @@ function update()
|
||||
local agent_id = current_agent_id()
|
||||
local socket = socket_path()
|
||||
if socket == nil then
|
||||
barWidget.setGlyph("alert-triangle")
|
||||
barWidget.setText("Pi offline")
|
||||
barWidget.setTooltip("Bridge socket is unavailable")
|
||||
start_bridge(false)
|
||||
return
|
||||
end
|
||||
if agent_id == nil then
|
||||
@@ -63,7 +103,7 @@ function update()
|
||||
request_in_flight = false
|
||||
if result.exitCode ~= 0 then
|
||||
noctalia.state.set("agent_id", nil)
|
||||
discover_home_agent(socket)
|
||||
start_bridge(false)
|
||||
barWidget.setGlyph("terminal-2")
|
||||
barWidget.setText("Pi reconnecting")
|
||||
barWidget.setTooltip("Refreshing the Home Pi agent")
|
||||
@@ -77,13 +117,5 @@ function update()
|
||||
end
|
||||
|
||||
function onClick()
|
||||
local command = "setsid -f env TMPDIR=/tmp " .. shell_quote(ui_binary())
|
||||
.. " --show >/tmp/pi-status-ui.log 2>&1"
|
||||
noctalia.runAsync(command, function(result)
|
||||
if result.exitCode ~= 0 then
|
||||
barWidget.setGlyph("alert-triangle")
|
||||
barWidget.setText("Pi UI unavailable")
|
||||
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
|
||||
end
|
||||
end)
|
||||
start_bridge(true)
|
||||
end
|
||||
|
||||
+7
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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;
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -7,6 +7,7 @@ const requestOperations = new Map([
|
||||
["list_agents", { agent: false, payload: "none" }],
|
||||
["list_directories", { agent: false, payload: "none" }],
|
||||
["select_agent", { agent: false, payload: "worktree" }],
|
||||
["forget_directory", { agent: false, payload: "worktree" }],
|
||||
["get_state", { agent: true, payload: "none" }],
|
||||
["get_session_stats", { agent: true, payload: "none" }],
|
||||
["list_sessions", { agent: true, payload: "none" }],
|
||||
|
||||
+254
-2
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -72,6 +72,253 @@ test("starts only the home agent and creates other worktree agents on explicit s
|
||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||
});
|
||||
|
||||
test("stops agents created during shutdown and rejects new selections", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
let releaseFeatureCreation;
|
||||
const featureCreation = new Promise((resolve) => {
|
||||
releaseFeatureCreation = resolve;
|
||||
});
|
||||
let featureCreationStarted;
|
||||
const featureStarted = new Promise((resolve) => {
|
||||
featureCreationStarted = resolve;
|
||||
});
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: (options) => {
|
||||
const adapter = fixture.startAdapter(options);
|
||||
if (options.cwd === worktrees.feature) {
|
||||
const send = adapter.send.bind(adapter);
|
||||
adapter.send = (command) => {
|
||||
if (command.type !== "get_state") return send(command);
|
||||
featureCreationStarted();
|
||||
return featureCreation.then(() => send(command));
|
||||
};
|
||||
}
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
await registry.start();
|
||||
const selecting = registry.selectWorktree(worktrees.feature);
|
||||
await featureStarted;
|
||||
|
||||
const stopping = registry.stop();
|
||||
await assert.rejects(
|
||||
registry.selectWorktree(worktrees.feature),
|
||||
/registry is stopping/,
|
||||
);
|
||||
releaseFeatureCreation();
|
||||
await selecting;
|
||||
await stopping;
|
||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||
});
|
||||
|
||||
test("drains a forget still resolving its canonical path during shutdown", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const featureAlias = join(worktrees.root, "feature-alias");
|
||||
await symlink(worktrees.feature, featureAlias);
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
await registry.selectWorktree(worktrees.feature);
|
||||
const featureAdapter = fixture.calls[1].adapter;
|
||||
const stop = featureAdapter.stop.bind(featureAdapter);
|
||||
let stopCalls = 0;
|
||||
featureAdapter.stop = async () => {
|
||||
stopCalls += 1;
|
||||
await stop();
|
||||
};
|
||||
|
||||
const forgetting = registry.forgetDirectory(featureAlias);
|
||||
const stopping = registry.stop();
|
||||
await Promise.all([forgetting, stopping]);
|
||||
|
||||
assert.equal(stopCalls, 1);
|
||||
assert.equal(
|
||||
registry
|
||||
.listAgents()
|
||||
.some((agent) => agent.worktreePath === worktrees.feature),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("forgets a directory without deleting its resumable session history", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
const home = await registry.start();
|
||||
const feature = await registry.selectWorktree(worktrees.feature);
|
||||
const sessionPath = join(feature.sessionDir, "resumable.jsonl");
|
||||
const sessionContent = `${JSON.stringify({
|
||||
type: "session",
|
||||
id: "resumable",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
cwd: worktrees.feature,
|
||||
})}\n`;
|
||||
await writeFile(sessionPath, sessionContent);
|
||||
await writeFile(
|
||||
join(feature.sessionDir, "bridge-agent.json"),
|
||||
`${JSON.stringify({ sessionPath, worktreePath: worktrees.feature })}\n`,
|
||||
);
|
||||
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
|
||||
assert.equal(fixture.calls[1].adapter.stopped, true);
|
||||
assert.deepEqual(registry.listAgents(), [home]);
|
||||
assert.deepEqual(await registry.listDirectories(), [
|
||||
{
|
||||
worktreePath: worktrees.home,
|
||||
state: "idle",
|
||||
isHome: true,
|
||||
agentId: home.id,
|
||||
},
|
||||
]);
|
||||
assert.equal(await readFile(sessionPath, "utf8"), sessionContent);
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.home),
|
||||
/cannot forget the home directory/,
|
||||
);
|
||||
|
||||
const reopened = await registry.selectWorktree(worktrees.feature);
|
||||
assert.deepEqual(
|
||||
(await registry.listSessions(reopened.id)).map(({ id }) => id),
|
||||
["resumable"],
|
||||
);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("coordinates forgetting with directory creation and active commands", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
let releaseFeatureCreation;
|
||||
const featureCreation = new Promise((resolve) => {
|
||||
releaseFeatureCreation = resolve;
|
||||
});
|
||||
let featureCreationStarted;
|
||||
const featureStarted = new Promise((resolve) => {
|
||||
featureCreationStarted = resolve;
|
||||
});
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: (options) => {
|
||||
const adapter = fixture.startAdapter(options);
|
||||
if (options.cwd === worktrees.feature) {
|
||||
const send = adapter.send.bind(adapter);
|
||||
adapter.send = (command) => {
|
||||
if (command.type !== "get_state") return send(command);
|
||||
featureCreationStarted();
|
||||
return featureCreation.then(() => send(command));
|
||||
};
|
||||
}
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
await registry.start();
|
||||
|
||||
const selecting = registry.selectWorktree(worktrees.feature);
|
||||
await featureStarted;
|
||||
const forgettingDuringCreation = registry.forgetDirectory(worktrees.feature);
|
||||
releaseFeatureCreation();
|
||||
await selecting;
|
||||
await forgettingDuringCreation;
|
||||
assert.equal(fixture.calls[1].adapter.stopped, true);
|
||||
assert.equal(
|
||||
(await registry.listDirectories()).some(
|
||||
(directory) => directory.worktreePath === worktrees.feature,
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
await registry.selectWorktree(worktrees.feature);
|
||||
const concurrentAdapter = fixture.calls[2].adapter;
|
||||
const stop = concurrentAdapter.stop.bind(concurrentAdapter);
|
||||
let releaseStop;
|
||||
const stopGate = new Promise((resolve) => {
|
||||
releaseStop = resolve;
|
||||
});
|
||||
let stopCalls = 0;
|
||||
concurrentAdapter.stop = async () => {
|
||||
stopCalls += 1;
|
||||
await stopGate;
|
||||
await stop();
|
||||
};
|
||||
const firstForget = registry.forgetDirectory(worktrees.feature);
|
||||
const secondForget = registry.forgetDirectory(worktrees.feature);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await assert.rejects(
|
||||
registry.selectWorktree(worktrees.feature),
|
||||
/being forgotten/,
|
||||
);
|
||||
releaseStop();
|
||||
await Promise.all([firstForget, secondForget]);
|
||||
assert.equal(stopCalls, 1);
|
||||
assert.equal(registry.listAgents().length, 1);
|
||||
|
||||
const feature = await registry.selectWorktree(worktrees.feature);
|
||||
const { adapter, options } = fixture.calls[3];
|
||||
const send = adapter.send.bind(adapter);
|
||||
let releasePrompt;
|
||||
const promptResponse = new Promise((resolve) => {
|
||||
releasePrompt = resolve;
|
||||
});
|
||||
adapter.send = (command) =>
|
||||
command.type === "prompt" ? promptResponse : send(command);
|
||||
const prompt = registry.route(feature.id, "prompt", {
|
||||
message: "Keep working",
|
||||
});
|
||||
const forgettingDuringPrompt = registry.forgetDirectory(worktrees.feature);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(adapter.stopped, false);
|
||||
releasePrompt({ type: "response", success: true });
|
||||
await prompt;
|
||||
await assert.rejects(forgettingDuringPrompt, /while Pi is working/);
|
||||
assert.equal(adapter.stopped, false);
|
||||
|
||||
options.onEvent({ type: "agent_state", data: { state: "idle" } });
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
assert.equal(adapter.stopped, true);
|
||||
|
||||
const failedFeature = await registry.selectWorktree(worktrees.feature);
|
||||
const failedFixture = fixture.calls[4];
|
||||
let rejectPrompt;
|
||||
const failedPromptResponse = new Promise((_resolve, reject) => {
|
||||
rejectPrompt = reject;
|
||||
});
|
||||
const failedSend = failedFixture.adapter.send.bind(failedFixture.adapter);
|
||||
failedFixture.adapter.send = (command) =>
|
||||
command.type === "prompt" ? failedPromptResponse : failedSend(command);
|
||||
const failedPrompt = registry.route(failedFeature.id, "prompt", {
|
||||
message: "Start before the response fails",
|
||||
});
|
||||
const failedPromptAssertion = assert.rejects(failedPrompt, /send failed/);
|
||||
failedFixture.options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "streaming" },
|
||||
});
|
||||
rejectPrompt(new Error("send failed"));
|
||||
await failedPromptAssertion;
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/while Pi is working/,
|
||||
);
|
||||
failedFixture.options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "idle" },
|
||||
});
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("catalogues managed directories and exposes only their sessions", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
@@ -151,10 +398,15 @@ test("catalogues managed directories and exposes only their sessions", async ()
|
||||
});
|
||||
await restored.start();
|
||||
assert.deepEqual(await restored.listDirectories(), [
|
||||
{ worktreePath: worktrees.feature, state: "inactive" },
|
||||
{
|
||||
worktreePath: worktrees.feature,
|
||||
state: "inactive",
|
||||
isHome: false,
|
||||
},
|
||||
{
|
||||
worktreePath: worktrees.home,
|
||||
state: "idle",
|
||||
isHome: true,
|
||||
agentId: restored.listAgents()[0].id,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -106,6 +106,20 @@ test("starts the home agent and dispatches local protocol requests to the select
|
||||
{ type: "get_state" },
|
||||
{ type: "prompt", message: "Use this worktree" },
|
||||
]);
|
||||
fixture.calls[1].options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "idle" },
|
||||
});
|
||||
|
||||
const forgotten = await request(socket, {
|
||||
version: "v1",
|
||||
id: "forget-1",
|
||||
op: "forget_directory",
|
||||
payload: { worktreePath: feature },
|
||||
});
|
||||
assert.equal(forgotten.ok, true);
|
||||
assert.equal(forgotten.result.worktreePath, feature);
|
||||
assert.equal((await service.listAgents()).length, 1);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await service.close();
|
||||
|
||||
@@ -13,9 +13,16 @@ test("ships a v5 Noctalia compatibility launcher instead of a primary panel", as
|
||||
assert.match(bridge, /PI_STATUS_UI_BINARY/);
|
||||
assert.match(bridge, /pi-status-ui/);
|
||||
assert.match(bridge, /noctalia\.runAsync/);
|
||||
assert.match(bridge, /test -x/);
|
||||
assert.match(bridge, /setsid -f env TMPDIR=\/tmp/);
|
||||
assert.match(bridge, /--toggle/);
|
||||
assert.doesNotMatch(bridge, /--show/);
|
||||
assert.match(bridge, /pi-status-ui\.log/);
|
||||
assert.doesNotMatch(bridge, /togglePanel/);
|
||||
assert.match(bridge, /Pi reconnecting/);
|
||||
assert.match(bridge, /discover_home_agent\(socket\)/);
|
||||
assert.match(bridge, /systemctl --user start pi-status-bridge\.service/);
|
||||
assert.match(bridge, /bridge_start_in_flight/);
|
||||
assert.match(bridge, /pending_ui_open/);
|
||||
assert.match(bridge, /Bridge is starting/);
|
||||
});
|
||||
|
||||
@@ -47,6 +47,20 @@ test("accepts bridge-managed directory and session operations", () => {
|
||||
id: "request-1",
|
||||
op: "list_directories",
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "forget_directory",
|
||||
payload: { worktreePath: "/projects/feature" },
|
||||
}),
|
||||
),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "forget_directory",
|
||||
payload: { worktreePath: "/projects/feature" },
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(request({ op: "list_sessions", agentId: "agent-main" })),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("documents and exposes simple user-service lifecycle commands", async () => {
|
||||
const [readme, uiReadme, packageJson, uiPackageJson] = await Promise.all([
|
||||
readFile("README.md", "utf8"),
|
||||
readFile("ui/README.md", "utf8"),
|
||||
readFile("package.json", "utf8"),
|
||||
readFile("ui/package.json", "utf8"),
|
||||
]);
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(packageJson);
|
||||
} catch (error) {
|
||||
assert.fail(`package.json must contain valid JSON: ${String(error)}`);
|
||||
}
|
||||
const scripts = manifest.scripts;
|
||||
for (const command of [
|
||||
"install",
|
||||
"start",
|
||||
"restart",
|
||||
"stop",
|
||||
"status",
|
||||
"logs",
|
||||
])
|
||||
assert.equal(
|
||||
typeof scripts[`bridge:service:${command}`],
|
||||
"string",
|
||||
`missing bridge:service:${command}`,
|
||||
);
|
||||
let uiManifest;
|
||||
try {
|
||||
uiManifest = JSON.parse(uiPackageJson);
|
||||
} catch (error) {
|
||||
assert.fail(`ui/package.json must contain valid JSON: ${String(error)}`);
|
||||
}
|
||||
assert.match(readme, /npm run bridge:service:restart/);
|
||||
assert.match(readme, /pi-status-ui --toggle/);
|
||||
assert.match(readme, /Restart Pi.*selected directory/);
|
||||
assert.match(uiReadme, /bridge:service:install/);
|
||||
assert.match(uiReadme, /tauri:release/);
|
||||
assert.equal(uiManifest.scripts["tauri:release"], "tauri build --no-bundle");
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("rebuilds the Tauri app when frontend assets change", async () => {
|
||||
const buildScript = await readFile("ui/src-tauri/build.rs", "utf8");
|
||||
assert.match(buildScript, /cargo:rerun-if-changed=\.\.\/dist/);
|
||||
});
|
||||
+89
-2
@@ -37,6 +37,20 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
|
||||
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
|
||||
assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
|
||||
assert.match(source, /const loadGeneration = useRef\(0\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/const bridgeRetryTimer = useRef<number \| undefined>\(undefined\)/,
|
||||
);
|
||||
assert.match(source, /const bridgeRetryAttempt = useRef\(0\)/);
|
||||
assert.match(source, /function scheduleBridgeReconnect/);
|
||||
assert.match(source, /Bridge unavailable; retrying in/);
|
||||
assert.match(
|
||||
source,
|
||||
/setStatus\(String\(error\)\);\s*scheduleBridgeReconnect\(\);/,
|
||||
);
|
||||
assert.match(source, /bridgeRetryTimer\.current = window\.setTimeout/);
|
||||
assert.match(source, /void refreshAgents\(preferredPath\)/);
|
||||
assert.match(source, /bridgeRetryAttempt\.current = 0/);
|
||||
assert.match(
|
||||
source,
|
||||
/const lastEventSequence = useRef<Record<string, number>>/,
|
||||
@@ -62,6 +76,52 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
|
||||
);
|
||||
});
|
||||
|
||||
test("exposes accessible directory, session, notification, and extension controls", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /aria-expanded=\{view === "settings"\}/);
|
||||
assert.match(
|
||||
component,
|
||||
/aria-label="Managed directories"\s+className="directory-tab-list"\s+role="tablist"/,
|
||||
);
|
||||
assert.match(component, /role="tab"/);
|
||||
assert.match(
|
||||
component,
|
||||
/aria-selected=\{directory\.agentId === selectedId\}/,
|
||||
);
|
||||
assert.match(component, /aria-controls="directory-workspace"/);
|
||||
assert.match(component, /onKeyDown=\{\(event\) => handleDirectoryTabKeyDown/);
|
||||
assert.match(
|
||||
component,
|
||||
/aria-current=\{session\.isCurrent \? "page" : undefined\}/,
|
||||
);
|
||||
assert.match(component, /role="status" aria-atomic="true"/);
|
||||
assert.match(component, /aria-hidden="true"/);
|
||||
assert.match(
|
||||
component,
|
||||
/role=\{notice\.notifyType === "error" \? "alert" : "status"\}/,
|
||||
);
|
||||
assert.match(component, /role="dialog"/);
|
||||
assert.match(component, /aria-modal="true"/);
|
||||
assert.match(component, /aria-labelledby="extension-title"/);
|
||||
assert.match(component, /const extensionRef = useRef<HTMLElement>\(null\)/);
|
||||
assert.match(component, /function focusableExtensionControls/);
|
||||
assert.match(component, /event\.key !== "Tab"/);
|
||||
assert.match(component, /data-initial-focus/);
|
||||
assert.match(component, /className="extension-backdrop"/);
|
||||
assert.match(component, /htmlFor="extension-value"/);
|
||||
assert.match(component, /Close current session and start a new session/);
|
||||
assert.match(component, /Restart Pi for the selected directory/);
|
||||
assert.match(component, /window\.confirm\(/);
|
||||
assert.match(styles, /button:focus-visible/);
|
||||
assert.match(styles, /\*::before,\n\*::after/);
|
||||
assert.match(styles, /\.sr-only/);
|
||||
assert.match(styles, /\.extension-backdrop \{/);
|
||||
assert.match(styles, /animation-duration: 0\.01ms/);
|
||||
});
|
||||
|
||||
test("makes the window draggable from the non-interactive header", async () => {
|
||||
const [component, styles, capability] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
@@ -110,11 +170,31 @@ test("shows bridge-managed directory status and sessions for the opened director
|
||||
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
|
||||
);
|
||||
assert.match(component, /function chooseDirectory/);
|
||||
assert.match(component, /function handleDirectoryTabKeyDown/);
|
||||
assert.match(component, /function forgetDirectory/);
|
||||
assert.match(component, /window\.confirm/);
|
||||
assert.match(component, /"forget_directory"/);
|
||||
assert.match(component, /bridgeEvent\.type === "agent_state"/);
|
||||
assert.match(component, /setDirectories\(\(current\) =>/);
|
||||
assert.match(
|
||||
component,
|
||||
/state: loadedState\.isStreaming \? "streaming" : "idle"/,
|
||||
);
|
||||
assert.match(component, /className="directory-add"/);
|
||||
assert.match(component, /Add directory/);
|
||||
assert.doesNotMatch(component, /className="folder-form"/);
|
||||
assert.doesNotMatch(component, /Remembered folders/);
|
||||
assert.match(component, /function startNewSession/);
|
||||
assert.match(component, /function closeCurrentSession/);
|
||||
assert.match(component, /Use \/resume to reopen it/);
|
||||
assert.match(component, /function switchSession/);
|
||||
assert.match(component, /className="session-panel"/);
|
||||
assert.match(component, /New session/);
|
||||
assert.match(component, /state\.isStreaming/);
|
||||
assert.match(styles, /\.directory-add \{/);
|
||||
assert.match(styles, /\.directory-tab \{/);
|
||||
assert.match(styles, /\.directory-tab-button \{/);
|
||||
assert.match(styles, /\.directory-close \{/);
|
||||
assert.match(styles, /\.session-panel \{/);
|
||||
assert.match(styles, /\.session-list \{/);
|
||||
});
|
||||
@@ -163,11 +243,18 @@ test("distinguishes user and assistant messages", async () => {
|
||||
assert.match(styles, /background: #1a241a/);
|
||||
});
|
||||
|
||||
test("uses a subtle focus treatment for the prompt editor", async () => {
|
||||
test("keeps an accessible focus treatment for the prompt editor", async () => {
|
||||
const styles = await readFile("ui/src/App.css", "utf8");
|
||||
assert.match(styles, /\.composer textarea:focus \{/);
|
||||
assert.match(styles, /outline: none/);
|
||||
assert.doesNotMatch(styles, /outline: none/);
|
||||
assert.match(styles, /border-color: #6f8b56/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.composer textarea:focus \{[^}]*outline: 2px solid #c4ed8b/,
|
||||
);
|
||||
assert.match(styles, /\.composer textarea:focus \{[^}]*outline-offset: -2px/);
|
||||
assert.doesNotMatch(styles, /\.composer textarea:focus \{[^}]*box-shadow:/);
|
||||
assert.match(styles, /textarea:focus-visible/);
|
||||
});
|
||||
|
||||
test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
|
||||
|
||||
+12
-6
@@ -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
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"tauri:release": "tauri build --no-bundle"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=../dist");
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -100,6 +100,16 @@ pub async fn select_worktree(socket_path: &str, worktree_path: &str) -> Result<V
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn forget_directory(socket_path: &str, worktree_path: &str) -> Result<Value, String> {
|
||||
request(
|
||||
socket_path,
|
||||
"forget_directory",
|
||||
None,
|
||||
Some(json!({ "worktreePath": worktree_path })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
||||
request(socket_path, "list_sessions", Some(agent_id), None).await
|
||||
}
|
||||
|
||||
+46
-3
@@ -6,6 +6,22 @@ use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
|
||||
|
||||
struct Subscription(Mutex<Option<JoinHandle<()>>>);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum WindowAction {
|
||||
Hide,
|
||||
ShowAndFocus,
|
||||
}
|
||||
|
||||
fn window_action(args: &[String], is_visible: bool) -> WindowAction {
|
||||
if args.iter().any(|arg| arg == "--hide")
|
||||
|| (args.iter().any(|arg| arg == "--toggle") && is_visible)
|
||||
{
|
||||
WindowAction::Hide
|
||||
} else {
|
||||
WindowAction::ShowAndFocus
|
||||
}
|
||||
}
|
||||
|
||||
fn socket_path() -> Result<String, String> {
|
||||
bridge::default_socket_path()
|
||||
}
|
||||
@@ -25,6 +41,11 @@ async fn select_worktree(worktree_path: String) -> Result<Value, String> {
|
||||
bridge::select_worktree(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn forget_directory(worktree_path: String) -> Result<Value, String> {
|
||||
bridge::forget_directory(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_agent(agent_id: String) -> Result<Value, String> {
|
||||
bridge::load_agent(&socket_path()?, &agent_id).await
|
||||
@@ -121,16 +142,24 @@ fn subscribe_agent(
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.manage(Subscription(Mutex::new(None)))
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "Pi Status UI",
|
||||
"width": 760,
|
||||
"height": 620,
|
||||
"width": 900,
|
||||
"height": 720,
|
||||
"minWidth": 480,
|
||||
"minHeight": 420,
|
||||
"center": true,
|
||||
|
||||
+123
-25
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+391
-127
@@ -1,4 +1,11 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
@@ -8,6 +15,7 @@ type Agent = { id: string; worktreePath: string; state: string };
|
||||
type Directory = {
|
||||
worktreePath: string;
|
||||
state: string;
|
||||
isHome: boolean;
|
||||
agentId?: string;
|
||||
};
|
||||
type Session = {
|
||||
@@ -122,7 +130,6 @@ const thinkingLevels = [
|
||||
"xhigh",
|
||||
"max",
|
||||
];
|
||||
const rememberedKey = "pi-status-ui.remembered-worktrees";
|
||||
const selectedPathKey = "pi-status-ui.selected-worktree";
|
||||
const controlCommands: Command[] = [
|
||||
{
|
||||
@@ -198,12 +205,12 @@ function currentTodos(messages: Message[]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function rememberedPaths() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(rememberedKey) ?? "[]") as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
function focusableExtensionControls(container: HTMLElement) {
|
||||
return [
|
||||
...container.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function App() {
|
||||
@@ -221,6 +228,7 @@ function App() {
|
||||
const [commands, setCommands] = useState<Command[]>([]);
|
||||
const [message, setMessage] = useState("");
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
const [addingDirectory, setAddingDirectory] = useState(false);
|
||||
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
|
||||
const [view, setView] = useState<"conversation" | "settings">("conversation");
|
||||
const [commandFollowUp, setCommandFollowUp] = useState<FollowUpCommand>();
|
||||
@@ -237,8 +245,13 @@ function App() {
|
||||
Record<string, ExtensionWidget>
|
||||
>({});
|
||||
const transcriptRef = useRef<HTMLElement>(null);
|
||||
const extensionRef = useRef<HTMLElement>(null);
|
||||
const directoryTabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const pendingSubmissionId = useRef(0);
|
||||
const loadGeneration = useRef(0);
|
||||
const refreshGeneration = useRef(0);
|
||||
const bridgeRetryTimer = useRef<number | undefined>(undefined);
|
||||
const bridgeRetryAttempt = useRef(0);
|
||||
const lastEventSequence = useRef<Record<string, number>>({});
|
||||
const [workProgress, setWorkProgress] = useState<WorkProgress>({
|
||||
phase: "idle",
|
||||
@@ -247,6 +260,13 @@ function App() {
|
||||
});
|
||||
|
||||
const selected = agents.find((agent) => agent.id === selectedId);
|
||||
const selectedDirectoryIndex = directories.findIndex(
|
||||
(directory) => directory.agentId === selectedId,
|
||||
);
|
||||
const selectedDirectoryTabId =
|
||||
selectedDirectoryIndex >= 0
|
||||
? `directory-tab-${selectedDirectoryIndex}`
|
||||
: undefined;
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!message.startsWith("/")) return [];
|
||||
const uniqueCommands = [...controlCommands, ...commands].filter(
|
||||
@@ -282,6 +302,16 @@ function App() {
|
||||
const loadedCommands = unpack(snapshot.commands);
|
||||
const loadedModels = unpack(snapshot.models);
|
||||
setState(loadedState as AgentState);
|
||||
setDirectories((current) =>
|
||||
current.map((directory) =>
|
||||
directory.agentId === agentId
|
||||
? {
|
||||
...directory,
|
||||
state: loadedState.isStreaming ? "streaming" : "idle",
|
||||
}
|
||||
: directory,
|
||||
),
|
||||
);
|
||||
setStats(loadedStats as SessionStats);
|
||||
setMessages((loadedTranscript.messages as Message[] | undefined) ?? []);
|
||||
setCommands((loadedCommands.commands as Command[] | undefined) ?? []);
|
||||
@@ -304,17 +334,36 @@ function App() {
|
||||
await invoke("subscribe_agent", { agentId, cursor: 0 });
|
||||
} catch (error) {
|
||||
setStatus(String(error));
|
||||
scheduleBridgeReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBridgeReconnect(preferredPath?: string) {
|
||||
if (bridgeRetryTimer.current !== undefined) return;
|
||||
const delay = Math.min(250 * 2 ** bridgeRetryAttempt.current, 2_000);
|
||||
bridgeRetryAttempt.current += 1;
|
||||
setStatus(`Bridge unavailable; retrying in ${delay}ms…`);
|
||||
bridgeRetryTimer.current = window.setTimeout(() => {
|
||||
bridgeRetryTimer.current = undefined;
|
||||
void refreshAgents(preferredPath);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function refreshAgents(
|
||||
preferredPath = localStorage.getItem(selectedPathKey) ?? undefined,
|
||||
) {
|
||||
const generation = ++refreshGeneration.current;
|
||||
try {
|
||||
const [agentResponse, directoryResponse] = await Promise.all([
|
||||
invoke<{ agents: Agent[] }>("list_agents"),
|
||||
invoke<{ directories: Directory[] }>("list_directories"),
|
||||
]);
|
||||
if (generation !== refreshGeneration.current) return;
|
||||
if (bridgeRetryTimer.current !== undefined) {
|
||||
window.clearTimeout(bridgeRetryTimer.current);
|
||||
bridgeRetryTimer.current = undefined;
|
||||
}
|
||||
bridgeRetryAttempt.current = 0;
|
||||
setAgents(agentResponse.agents);
|
||||
setDirectories(directoryResponse.directories);
|
||||
const next =
|
||||
@@ -329,7 +378,8 @@ function App() {
|
||||
await loadAgent(next.id, true);
|
||||
} else setStatus("No Pi agents available");
|
||||
} catch (error) {
|
||||
setStatus(String(error));
|
||||
if (generation !== refreshGeneration.current) return;
|
||||
scheduleBridgeReconnect(preferredPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +396,10 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAgents();
|
||||
return () => {
|
||||
if (bridgeRetryTimer.current !== undefined)
|
||||
window.clearTimeout(bridgeRetryTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -541,6 +595,18 @@ function App() {
|
||||
if (bridgeEvent.type === "extension_ui_request") {
|
||||
handleExtension(bridgeEvent.data?.event as Extension | undefined);
|
||||
} else {
|
||||
if (
|
||||
bridgeEvent.type === "agent_state" &&
|
||||
typeof bridgeEvent.data?.state === "string"
|
||||
) {
|
||||
setDirectories((current) =>
|
||||
current.map((directory) =>
|
||||
directory.agentId === bridgeEvent.agentId
|
||||
? { ...directory, state: bridgeEvent.data?.state as string }
|
||||
: directory,
|
||||
),
|
||||
);
|
||||
}
|
||||
updateWorkProgress(bridgeEvent);
|
||||
if (selectedId) void loadAgent(selectedId);
|
||||
}
|
||||
@@ -551,7 +617,7 @@ function App() {
|
||||
setStatus(
|
||||
event.payload.message ?? "Bridge connection lost; reconnecting…",
|
||||
);
|
||||
window.setTimeout(() => void refreshAgents(), 500);
|
||||
scheduleBridgeReconnect();
|
||||
}).then((stop) => {
|
||||
unlistenError = stop;
|
||||
});
|
||||
@@ -580,7 +646,58 @@ function App() {
|
||||
else await activateWorktree(directory.worktreePath);
|
||||
}
|
||||
|
||||
async function startNewSession() {
|
||||
function handleDirectoryTabKeyDown(
|
||||
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||
index: number,
|
||||
) {
|
||||
let nextIndex: number | undefined;
|
||||
if (event.key === "ArrowRight")
|
||||
nextIndex = (index + 1) % directories.length;
|
||||
else if (event.key === "ArrowLeft")
|
||||
nextIndex = (index - 1 + directories.length) % directories.length;
|
||||
else if (event.key === "Home") nextIndex = 0;
|
||||
else if (event.key === "End") nextIndex = directories.length - 1;
|
||||
if (nextIndex === undefined) return;
|
||||
event.preventDefault();
|
||||
const nextDirectory = directories[nextIndex];
|
||||
directoryTabRefs.current[nextDirectory.worktreePath]?.focus();
|
||||
}
|
||||
|
||||
async function forgetDirectory(directory: Directory) {
|
||||
if (directory.isHome) return;
|
||||
if (directory.state === "streaming") {
|
||||
setStatus("Wait for Pi to finish before forgetting this directory");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
`Forget ${directory.worktreePath}? Its saved sessions will remain available when you add it again.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const closingIndex = directories.findIndex(
|
||||
(candidate) => candidate.worktreePath === directory.worktreePath,
|
||||
);
|
||||
const nextFocusPath =
|
||||
directories[closingIndex + 1]?.worktreePath ??
|
||||
directories[closingIndex - 1]?.worktreePath;
|
||||
await invoke("forget_directory", {
|
||||
worktreePath: directory.worktreePath,
|
||||
});
|
||||
const wasSelected = directory.agentId === selectedId;
|
||||
if (wasSelected) localStorage.removeItem(selectedPathKey);
|
||||
await refreshAgents(wasSelected ? "" : selected?.worktreePath);
|
||||
window.requestAnimationFrame(() => {
|
||||
if (nextFocusPath) directoryTabRefs.current[nextFocusPath]?.focus();
|
||||
});
|
||||
setStatus(`Forgot ${worktreeLabel(directory.worktreePath)}`);
|
||||
} catch (error) {
|
||||
setStatus(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewSession(successMessage = "Started a new Pi session") {
|
||||
if (!selectedId) return;
|
||||
try {
|
||||
const response = await invoke<{ data?: { cancelled?: boolean } }>(
|
||||
@@ -594,12 +711,34 @@ function App() {
|
||||
await loadAgent(selectedId, true);
|
||||
await loadSessions(selectedId);
|
||||
setCommandFollowUp(undefined);
|
||||
setStatus("Started a new Pi session");
|
||||
setStatus(successMessage);
|
||||
} catch (error) {
|
||||
setStatus(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function closeCurrentSession() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Close the current session and start a new one? The current history remains available through /resume.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
await startNewSession(
|
||||
"Closed the current session. Use /resume to reopen it.",
|
||||
);
|
||||
}
|
||||
|
||||
async function restartPi() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Restart Pi for the selected directory? Any in-progress response will be interrupted.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
await invokeAgent("restart");
|
||||
}
|
||||
|
||||
async function switchSession(session: Session) {
|
||||
if (!selectedId || session.isCurrent) return;
|
||||
try {
|
||||
@@ -649,10 +788,9 @@ function App() {
|
||||
const result = await invoke<{ agent: Agent }>("select_worktree", {
|
||||
worktreePath,
|
||||
});
|
||||
const paths = [...new Set([...rememberedPaths(), worktreePath])];
|
||||
localStorage.setItem(rememberedKey, JSON.stringify(paths));
|
||||
localStorage.setItem(selectedPathKey, worktreePath);
|
||||
setFolderPath("");
|
||||
setAddingDirectory(false);
|
||||
setSelectedId(result.agent.id);
|
||||
await refreshAgents(worktreePath);
|
||||
setView("conversation");
|
||||
@@ -724,17 +862,25 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
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<string, unknown>) {
|
||||
if (!selectedId || !pendingExtension?.id) return;
|
||||
@@ -751,6 +897,37 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingExtension) return;
|
||||
const dialog = extensionRef.current;
|
||||
if (!dialog) return;
|
||||
const controls = focusableExtensionControls(dialog);
|
||||
const initial =
|
||||
dialog.querySelector<HTMLElement>("[data-initial-focus]") ?? controls[0];
|
||||
initial?.focus();
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
void respondToExtension({ cancelled: true });
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = focusableExtensionControls(dialog);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [pendingExtension]);
|
||||
|
||||
const extensionMethod = pendingExtension?.method ?? "";
|
||||
const workLabel =
|
||||
workProgress.phase === "recovering"
|
||||
@@ -789,10 +966,13 @@ function App() {
|
||||
{workLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-row" role="status">
|
||||
<span title={status}>{status}</span>
|
||||
<div className="status-row">
|
||||
<span role="status" aria-atomic="true" title={status}>
|
||||
{status}
|
||||
</span>
|
||||
{contextWindow && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="status-metric"
|
||||
title={`Current context: ${formatTokens(contextTokens)} / ${formatTokens(contextWindow)} tokens`}
|
||||
>
|
||||
@@ -802,6 +982,7 @@ function App() {
|
||||
)}
|
||||
{stats.tokens?.total !== undefined && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="status-metric"
|
||||
title={`Session tokens — input: ${formatTokens(stats.tokens.input)}, output: ${formatTokens(stats.tokens.output)}, cache read: ${formatTokens(stats.tokens.cacheRead)}, cache write: ${formatTokens(stats.tokens.cacheWrite)}`}
|
||||
>
|
||||
@@ -819,6 +1000,8 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-controls="directory-workspace"
|
||||
aria-expanded={view === "settings"}
|
||||
className="quiet compact-button"
|
||||
onClick={() =>
|
||||
setView(view === "conversation" ? "settings" : "conversation")
|
||||
@@ -828,23 +1011,86 @@ function App() {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="agents" aria-label="Managed directories">
|
||||
<span className="agent-caption">Directories</span>
|
||||
{directories.map((directory) => (
|
||||
<button
|
||||
className={
|
||||
directory.agentId === selectedId ? "agent selected" : "agent"
|
||||
}
|
||||
key={directory.worktreePath}
|
||||
title={directory.worktreePath}
|
||||
onClick={() => void chooseDirectory(directory)}
|
||||
<section className="directory-tabs" aria-label="Managed directories">
|
||||
<div
|
||||
aria-label="Managed directories"
|
||||
className="directory-tab-list"
|
||||
role="tablist"
|
||||
>
|
||||
<span className="agent-caption">Directories</span>
|
||||
{directories.map((directory, index) => {
|
||||
const isSelected = directory.agentId === selectedId;
|
||||
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>
|
||||
{worktreeLabel(directory.worktreePath) ?? directory.worktreePath}
|
||||
</span>
|
||||
<small>{directory.state}</small>
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
autoFocus
|
||||
value={folderPath}
|
||||
onChange={(event) => setFolderPath(event.currentTarget.value)}
|
||||
placeholder="/absolute/path/to/project"
|
||||
aria-label="Directory path"
|
||||
/>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{extensionNotifications.length > 0 && (
|
||||
@@ -853,6 +1099,7 @@ function App() {
|
||||
<div
|
||||
className={`notification ${notice.notifyType ?? "info"}`}
|
||||
key={`${notice.id ?? "notice"}-${index}`}
|
||||
role={notice.notifyType === "error" ? "alert" : "status"}
|
||||
>
|
||||
{notice.message ?? "Extension notification"}
|
||||
</div>
|
||||
@@ -861,32 +1108,13 @@ function App() {
|
||||
)}
|
||||
|
||||
{view === "settings" ? (
|
||||
<section className="settings">
|
||||
<h2>Agents and session</h2>
|
||||
<div className="folder-form">
|
||||
<input
|
||||
value={folderPath}
|
||||
onChange={(event) => setFolderPath(event.currentTarget.value)}
|
||||
placeholder="/absolute/path/to/project"
|
||||
/>
|
||||
<button onClick={() => void addFolder()}>Add folder</button>
|
||||
</div>
|
||||
<div className="remembered">
|
||||
<p className="muted">Remembered folders</p>
|
||||
{rememberedPaths().length ? (
|
||||
rememberedPaths().map((path) => (
|
||||
<button
|
||||
className="quiet"
|
||||
key={path}
|
||||
onClick={() => void activateWorktree(path)}
|
||||
>
|
||||
{path}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="muted">Home only</p>
|
||||
)}
|
||||
</div>
|
||||
<section
|
||||
aria-labelledby={selectedDirectoryTabId}
|
||||
className="settings"
|
||||
id="directory-workspace"
|
||||
role="tabpanel"
|
||||
>
|
||||
<h2>Session controls</h2>
|
||||
<p className="muted">
|
||||
{state.sessionName ?? state.sessionId ?? "Session"} ·{" "}
|
||||
{state.messageCount ?? 0} messages ·{" "}
|
||||
@@ -904,17 +1132,29 @@ function App() {
|
||||
"Open a directory to view its sessions"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={!selectedId || state.isStreaming}
|
||||
onClick={() => void startNewSession()}
|
||||
>
|
||||
New session
|
||||
</button>
|
||||
<div className="session-actions">
|
||||
<button
|
||||
aria-label="Close current session and start a new session"
|
||||
className="quiet"
|
||||
disabled={!selectedId || state.isStreaming}
|
||||
onClick={() => void closeCurrentSession()}
|
||||
>
|
||||
Close current
|
||||
</button>
|
||||
<button
|
||||
disabled={!selectedId || state.isStreaming}
|
||||
onClick={() => void startNewSession()}
|
||||
>
|
||||
New session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{sessions.length ? (
|
||||
<div className="session-list">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
aria-current={session.isCurrent ? "page" : undefined}
|
||||
aria-label={`${session.name ?? session.firstMessage ?? "Untitled session"}${session.isCurrent ? ", current session" : `, ${session.messageCount} messages`}`}
|
||||
className={
|
||||
session.isCurrent ? "session current" : "session quiet"
|
||||
}
|
||||
@@ -944,8 +1184,9 @@ function App() {
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
aria-label="Restart Pi for the selected directory"
|
||||
className="quiet danger"
|
||||
onClick={() => void invokeAgent("restart")}
|
||||
onClick={() => void restartPi()}
|
||||
>
|
||||
Restart Pi
|
||||
</button>
|
||||
@@ -953,7 +1194,12 @@ function App() {
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<div className="workflow">
|
||||
<div
|
||||
aria-labelledby={selectedDirectoryTabId}
|
||||
className="workflow"
|
||||
id="directory-workspace"
|
||||
role="tabpanel"
|
||||
>
|
||||
<div className="workflow-main">
|
||||
<section
|
||||
className={`transcript ${pendingExtension ? "with-extension" : ""} ${workProgress.phase}`}
|
||||
@@ -1227,61 +1473,79 @@ function App() {
|
||||
)}
|
||||
|
||||
{pendingExtension && (
|
||||
<section className="extension" aria-label="Pi extension request">
|
||||
<h2>{pendingExtension.title ?? "Pi extension request"}</h2>
|
||||
<p>{pendingExtension.message ?? "Choose a response."}</p>
|
||||
{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") && (
|
||||
<>
|
||||
<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 })}
|
||||
<div className="extension-backdrop">
|
||||
<section
|
||||
className="extension"
|
||||
ref={extensionRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="extension-title"
|
||||
aria-describedby="extension-description"
|
||||
>
|
||||
Cancel request
|
||||
</button>
|
||||
</section>
|
||||
<h2 id="extension-title">
|
||||
{pendingExtension.title ?? "Pi extension request"}
|
||||
</h2>
|
||||
<p id="extension-description">
|
||||
{pendingExtension.message ?? "Choose a response."}
|
||||
</p>
|
||||
{extensionMethod === "select" &&
|
||||
pendingExtension.options?.map((option) => (
|
||||
<button
|
||||
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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user