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.
|
Install the adapter that matches your desktop: [`noctalia-plugin/`](noctalia-plugin/) supports v4 and [`noctalia-v5-plugin/`](noctalia-v5-plugin/) supports v5. Both adapters are presentation-only; neither starts Pi nor approves extension requests.
|
||||||
|
|
||||||
## Run as a user service
|
## Keep the bridge running in the background (recommended)
|
||||||
|
|
||||||
The included [`systemd/pi-status-bridge.service`](systemd/pi-status-bridge.service) is a portable user-service example. Install the bridge CLI, then ensure both `pi-status-bridge` and `pi` are on the user service's `PATH`:
|
The desktop UI connects on demand; only the bridge needs to run persistently. On Linux systems using systemd, install the bridge command globally once, then use the included user service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install --global .
|
npm install --global .
|
||||||
command -v pi-status-bridge pi
|
command -v pi-status-bridge pi
|
||||||
|
npm run bridge:service:install
|
||||||
|
```
|
||||||
|
|
||||||
mkdir -p ~/.config/systemd/user
|
The npm commands below are short, copyable wrappers around `systemctl --user`:
|
||||||
cp systemd/pi-status-bridge.service ~/.config/systemd/user/
|
|
||||||
systemctl --user daemon-reload
|
```bash
|
||||||
systemctl --user enable --now pi-status-bridge.service
|
npm run bridge:service:start # start the bridge
|
||||||
systemctl --user status pi-status-bridge.service
|
npm run bridge:service:restart # apply bridge changes or recover it
|
||||||
|
npm run bridge:service:stop # stop the bridge
|
||||||
|
npm run bridge:service:status # check whether it is healthy
|
||||||
|
npm run bridge:service:logs # follow bridge logs; Ctrl+C returns to the shell
|
||||||
```
|
```
|
||||||
|
|
||||||
If systemd cannot locate either executable, add a user-service drop-in that sets `PATH` to their containing directories; do not hard-code another user's home path in the tracked unit.
|
If systemd cannot locate either executable, add a user-service drop-in that sets `PATH` to their containing directories; do not hard-code another user's home path in the tracked unit.
|
||||||
|
|
||||||
|
### Opening the desktop UI
|
||||||
|
|
||||||
|
- **Development:** run `npm --prefix ui run tauri dev` in a terminal after starting the bridge.
|
||||||
|
- **Installed UI:** launch it from your application menu or run `pi-status-ui --toggle`. The `--toggle` command shows a hidden window or hides a visible one; it does not restart the bridge.
|
||||||
|
- **Noctalia:** use the Pi Status launcher as usual; it opens the installed UI.
|
||||||
|
|
||||||
|
**Restart Pi** in the UI restarts only the selected directory's Pi agent. `npm run bridge:service:restart` restarts the whole bridge service and all of its active agents.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -16,16 +16,58 @@ local function socket_path()
|
|||||||
return runtime .. "/pi-status-bridge/bridge.sock"
|
return runtime .. "/pi-status-bridge/bridge.sock"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local bridge_start_in_flight = false
|
||||||
|
local pending_ui_open = false
|
||||||
|
|
||||||
local function current_agent_id()
|
local function current_agent_id()
|
||||||
return noctalia.state.get("agent_id")
|
return noctalia.state.get("agent_id")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function open_ui()
|
||||||
|
local binary = shell_quote(ui_binary())
|
||||||
|
local command = "if test -x " .. binary .. "; then setsid -f env TMPDIR=/tmp "
|
||||||
|
.. binary .. " --toggle >/tmp/pi-status-ui.log 2>&1; else exit 1; fi"
|
||||||
|
noctalia.runAsync(command, function(result)
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
barWidget.setGlyph("alert-triangle")
|
||||||
|
barWidget.setText("Pi UI unavailable")
|
||||||
|
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start_bridge(open_after_start)
|
||||||
|
if open_after_start then pending_ui_open = true end
|
||||||
|
if bridge_start_in_flight then return end
|
||||||
|
bridge_start_in_flight = true
|
||||||
|
barWidget.setGlyph("terminal-2")
|
||||||
|
barWidget.setText("Pi starting")
|
||||||
|
barWidget.setTooltip("Bridge is starting")
|
||||||
|
noctalia.runAsync("systemctl --user start pi-status-bridge.service", function(result)
|
||||||
|
bridge_start_in_flight = false
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
pending_ui_open = false
|
||||||
|
barWidget.setGlyph("alert-triangle")
|
||||||
|
barWidget.setText("Pi offline")
|
||||||
|
barWidget.setTooltip("Could not start pi-status-bridge.service")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if pending_ui_open then
|
||||||
|
pending_ui_open = false
|
||||||
|
open_ui()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
local function discover_home_agent(socket)
|
local function discover_home_agent(socket)
|
||||||
if request_in_flight then return end
|
if request_in_flight then return end
|
||||||
request_in_flight = true
|
request_in_flight = true
|
||||||
noctalia.runAsync("pi-status-bridge-client --socket \"" .. socket .. "\" request '{\"op\":\"list_agents\"}'", function(result)
|
noctalia.runAsync("pi-status-bridge-client --socket \"" .. socket .. "\" request '{\"op\":\"list_agents\"}'", function(result)
|
||||||
request_in_flight = false
|
request_in_flight = false
|
||||||
if result.exitCode ~= 0 then return end
|
if result.exitCode ~= 0 then
|
||||||
|
start_bridge(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
local home = noctalia.getenv("HOME")
|
local home = noctalia.getenv("HOME")
|
||||||
for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do
|
for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do
|
||||||
if worktree == home then
|
if worktree == home then
|
||||||
@@ -42,9 +84,7 @@ function update()
|
|||||||
local agent_id = current_agent_id()
|
local agent_id = current_agent_id()
|
||||||
local socket = socket_path()
|
local socket = socket_path()
|
||||||
if socket == nil then
|
if socket == nil then
|
||||||
barWidget.setGlyph("alert-triangle")
|
start_bridge(false)
|
||||||
barWidget.setText("Pi offline")
|
|
||||||
barWidget.setTooltip("Bridge socket is unavailable")
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if agent_id == nil then
|
if agent_id == nil then
|
||||||
@@ -63,7 +103,7 @@ function update()
|
|||||||
request_in_flight = false
|
request_in_flight = false
|
||||||
if result.exitCode ~= 0 then
|
if result.exitCode ~= 0 then
|
||||||
noctalia.state.set("agent_id", nil)
|
noctalia.state.set("agent_id", nil)
|
||||||
discover_home_agent(socket)
|
start_bridge(false)
|
||||||
barWidget.setGlyph("terminal-2")
|
barWidget.setGlyph("terminal-2")
|
||||||
barWidget.setText("Pi reconnecting")
|
barWidget.setText("Pi reconnecting")
|
||||||
barWidget.setTooltip("Refreshing the Home Pi agent")
|
barWidget.setTooltip("Refreshing the Home Pi agent")
|
||||||
@@ -77,13 +117,5 @@ function update()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function onClick()
|
function onClick()
|
||||||
local command = "setsid -f env TMPDIR=/tmp " .. shell_quote(ui_binary())
|
start_bridge(true)
|
||||||
.. " --show >/tmp/pi-status-ui.log 2>&1"
|
|
||||||
noctalia.runAsync(command, function(result)
|
|
||||||
if result.exitCode ~= 0 then
|
|
||||||
barWidget.setGlyph("alert-triangle")
|
|
||||||
barWidget.setText("Pi UI unavailable")
|
|
||||||
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|||||||
+7
-1
@@ -26,6 +26,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test test/*.test.js && npm run test:ui",
|
"test": "node --test test/*.test.js && npm run test:ui",
|
||||||
"test:ui": "cargo test --manifest-path ui/src-tauri/Cargo.toml",
|
"test:ui": "cargo test --manifest-path ui/src-tauri/Cargo.toml",
|
||||||
"check": "node --check src/protocol/index.js && node --check src/client/local-client.js && node --check src/client/noctalia-relay.js && node --check src/client/noctalia-ipc.js && node --check src/client/noctalia-relay-cli.js && node --check src/bridge/daemon.js && node --check src/bridge/cli.js && node --check src/bridge/agent-registry.js && node --check src/bridge/instance-lock.js && node --check src/bridge/pi-rpc-adapter.js && node --check src/bridge/unix-server.js && node --check src/bridge/runtime.js && node --check src/bridge/recovery-supervisor.js && node --check src/bridge/service.js && npm --prefix ui run build"
|
"check": "node --check src/protocol/index.js && node --check src/client/local-client.js && node --check src/client/noctalia-relay.js && node --check src/client/noctalia-ipc.js && node --check src/client/noctalia-relay-cli.js && node --check src/bridge/daemon.js && node --check src/bridge/cli.js && node --check src/bridge/agent-registry.js && node --check src/bridge/instance-lock.js && node --check src/bridge/pi-rpc-adapter.js && node --check src/bridge/unix-server.js && node --check src/bridge/runtime.js && node --check src/bridge/recovery-supervisor.js && node --check src/bridge/service.js && npm --prefix ui run build",
|
||||||
|
"bridge:service:install": "mkdir -p \"$HOME/.config/systemd/user\" && cp systemd/pi-status-bridge.service \"$HOME/.config/systemd/user/\" && systemctl --user daemon-reload && systemctl --user enable --now pi-status-bridge.service",
|
||||||
|
"bridge:service:start": "systemctl --user start pi-status-bridge.service",
|
||||||
|
"bridge:service:restart": "systemctl --user restart pi-status-bridge.service",
|
||||||
|
"bridge:service:stop": "systemctl --user stop pi-status-bridge.service",
|
||||||
|
"bridge:service:status": "systemctl --user status pi-status-bridge.service",
|
||||||
|
"bridge:service:logs": "journalctl --user -u pi-status-bridge.service -f"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
readdir,
|
||||||
readFile,
|
readFile,
|
||||||
realpath,
|
realpath,
|
||||||
|
rm,
|
||||||
stat,
|
stat,
|
||||||
writeFile,
|
writeFile,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
@@ -141,7 +142,7 @@ async function listSessionSummaries(sessionDir, currentPath) {
|
|||||||
.sort((left, right) => right.modified.localeCompare(left.modified));
|
.sort((left, right) => right.modified.localeCompare(left.modified));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listDirectoryCatalog(sessionRoot, agentsByPath) {
|
async function listDirectoryCatalog(sessionRoot, agentsByPath, homeWorktree) {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
entries = await readdir(sessionRoot, { withFileTypes: true });
|
entries = await readdir(sessionRoot, { withFileTypes: true });
|
||||||
@@ -174,6 +175,7 @@ async function listDirectoryCatalog(sessionRoot, agentsByPath) {
|
|||||||
return {
|
return {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
state: agent?.state ?? "inactive",
|
state: agent?.state ?? "inactive",
|
||||||
|
isHome: worktreePath === homeWorktree,
|
||||||
...(agent ? { agentId: agent.id } : {}),
|
...(agent ? { agentId: agent.id } : {}),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -249,11 +251,19 @@ export function createAgentRegistry({
|
|||||||
const agentsByPath = new Map();
|
const agentsByPath = new Map();
|
||||||
const agentsById = new Map();
|
const agentsById = new Map();
|
||||||
const inFlight = new Map();
|
const inFlight = new Map();
|
||||||
|
const forgettingPaths = new Map();
|
||||||
|
const pendingForgetOperations = new Set();
|
||||||
|
let canonicalHomeWorktree;
|
||||||
|
let stopping = false;
|
||||||
|
|
||||||
async function ensureAgent(worktreePath) {
|
async function ensureAgent(worktreePath) {
|
||||||
|
if (stopping) throw new Error("agent registry is stopping");
|
||||||
if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath))
|
if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath))
|
||||||
throw new TypeError("worktreePath must be an absolute path");
|
throw new TypeError("worktreePath must be an absolute path");
|
||||||
const canonicalPath = await realpath(worktreePath);
|
const canonicalPath = await realpath(worktreePath);
|
||||||
|
if (stopping) throw new Error("agent registry is stopping");
|
||||||
|
if (forgettingPaths.has(canonicalPath))
|
||||||
|
throw new Error("directory is being forgotten");
|
||||||
const existing = agentsByPath.get(canonicalPath);
|
const existing = agentsByPath.get(canonicalPath);
|
||||||
if (existing) return publicAgent(existing);
|
if (existing) return publicAgent(existing);
|
||||||
const creating = inFlight.get(canonicalPath);
|
const creating = inFlight.get(canonicalPath);
|
||||||
@@ -276,10 +286,14 @@ export function createAgentRegistry({
|
|||||||
events: [],
|
events: [],
|
||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
nextSequence: 1,
|
nextSequence: 1,
|
||||||
|
stateVersion: 0,
|
||||||
adapter: undefined,
|
adapter: undefined,
|
||||||
stopped: false,
|
stopped: false,
|
||||||
recoveryPromise: undefined,
|
recoveryPromise: undefined,
|
||||||
supervisor: undefined,
|
supervisor: undefined,
|
||||||
|
forgetting: false,
|
||||||
|
activeOperations: 0,
|
||||||
|
operationWaiters: new Set(),
|
||||||
};
|
};
|
||||||
const publish = (type, data) => {
|
const publish = (type, data) => {
|
||||||
const event = {
|
const event = {
|
||||||
@@ -304,12 +318,14 @@ export function createAgentRegistry({
|
|||||||
typeof event.data?.state === "string"
|
typeof event.data?.state === "string"
|
||||||
) {
|
) {
|
||||||
agent.state = event.data.state;
|
agent.state = event.data.state;
|
||||||
|
agent.stateVersion += 1;
|
||||||
if (event.data.state === "idle") agent.supervisor.markHealthy();
|
if (event.data.state === "idle") agent.supervisor.markHealthy();
|
||||||
}
|
}
|
||||||
publish(event.type, event.data);
|
publish(event.type, event.data);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
agent.state = "error";
|
agent.state = "error";
|
||||||
|
agent.stateVersion += 1;
|
||||||
publish("agent_state", {
|
publish("agent_state", {
|
||||||
state: "error",
|
state: "error",
|
||||||
error: { code: error.code ?? "unknown", message: error.message },
|
error: { code: error.code ?? "unknown", message: error.message },
|
||||||
@@ -323,6 +339,7 @@ export function createAgentRegistry({
|
|||||||
.then((adapterAfterRecovery) => {
|
.then((adapterAfterRecovery) => {
|
||||||
if (agent.stopped) return undefined;
|
if (agent.stopped) return undefined;
|
||||||
agent.state = adapterAfterRecovery ? "idle" : "failed";
|
agent.state = adapterAfterRecovery ? "idle" : "failed";
|
||||||
|
agent.stateVersion += 1;
|
||||||
publish("agent_state", { state: agent.state });
|
publish("agent_state", { state: agent.state });
|
||||||
return adapterAfterRecovery;
|
return adapterAfterRecovery;
|
||||||
});
|
});
|
||||||
@@ -332,6 +349,7 @@ export function createAgentRegistry({
|
|||||||
agent.adapter = adapter;
|
agent.adapter = adapter;
|
||||||
const state = await adapter.send({ type: "get_state" });
|
const state = await adapter.send({ type: "get_state" });
|
||||||
agent.state = state?.data?.isStreaming ? "streaming" : "idle";
|
agent.state = state?.data?.isStreaming ? "streaming" : "idle";
|
||||||
|
agent.stateVersion += 1;
|
||||||
const sessionPath = state?.data?.sessionFile;
|
const sessionPath = state?.data?.sessionFile;
|
||||||
if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) {
|
if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) {
|
||||||
agent.sessionPath = sessionPath;
|
agent.sessionPath = sessionPath;
|
||||||
@@ -367,6 +385,25 @@ export function createAgentRegistry({
|
|||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withAgentOperation(agent, operation) {
|
||||||
|
if (agent.forgetting) throw new Error("directory is being forgotten");
|
||||||
|
agent.activeOperations += 1;
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
agent.activeOperations -= 1;
|
||||||
|
if (agent.activeOperations === 0) {
|
||||||
|
for (const resolve of agent.operationWaiters) resolve();
|
||||||
|
agent.operationWaiters.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForAgentOperations(agent) {
|
||||||
|
if (agent.activeOperations === 0) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => agent.operationWaiters.add(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshAgentSessionReference(agent) {
|
async function refreshAgentSessionReference(agent) {
|
||||||
const state = await agent.adapter.send({ type: "get_state" });
|
const state = await agent.adapter.send({ type: "get_state" });
|
||||||
const sessionPath = state?.data?.sessionFile;
|
const sessionPath = state?.data?.sessionFile;
|
||||||
@@ -417,9 +454,78 @@ export function createAgentRegistry({
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function forgetDirectory(worktreePath) {
|
||||||
|
if (stopping) throw new Error("agent registry is stopping");
|
||||||
|
if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath))
|
||||||
|
throw new TypeError("worktreePath must be an absolute path");
|
||||||
|
let finishPendingForget;
|
||||||
|
const pendingForget = new Promise((resolve) => {
|
||||||
|
finishPendingForget = resolve;
|
||||||
|
});
|
||||||
|
pendingForgetOperations.add(pendingForget);
|
||||||
|
try {
|
||||||
|
let canonicalPath = path.normalize(worktreePath);
|
||||||
|
if (!agentsByPath.has(canonicalPath)) {
|
||||||
|
try {
|
||||||
|
canonicalPath = await realpath(worktreePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const homePath = canonicalHomeWorktree ?? (await realpath(homeWorktree));
|
||||||
|
if (canonicalPath === homePath)
|
||||||
|
throw new Error("cannot forget the home directory");
|
||||||
|
|
||||||
|
const existingForget = forgettingPaths.get(canonicalPath);
|
||||||
|
if (existingForget) return existingForget;
|
||||||
|
const forgetting = (async () => {
|
||||||
|
try {
|
||||||
|
const creation = inFlight.get(canonicalPath);
|
||||||
|
if (creation) await creation;
|
||||||
|
const agent = agentsByPath.get(canonicalPath);
|
||||||
|
if (agent) {
|
||||||
|
agent.forgetting = true;
|
||||||
|
await waitForAgentOperations(agent);
|
||||||
|
if (agent.state === "streaming")
|
||||||
|
throw new Error("cannot forget a directory while Pi is working");
|
||||||
|
agent.stopped = true;
|
||||||
|
agent.supervisor.stop();
|
||||||
|
await agent.adapter.stop();
|
||||||
|
agent.state = "stopped";
|
||||||
|
agent.stateVersion += 1;
|
||||||
|
agent.listeners.clear();
|
||||||
|
agentsByPath.delete(canonicalPath);
|
||||||
|
agentsById.delete(agent.id);
|
||||||
|
}
|
||||||
|
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath);
|
||||||
|
await rm(path.join(sessionDir, SESSION_REFERENCE_FILE), {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
return { worktreePath: canonicalPath };
|
||||||
|
} catch (error) {
|
||||||
|
const agent = agentsByPath.get(canonicalPath);
|
||||||
|
if (agent) agent.forgetting = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
forgettingPaths.set(canonicalPath, forgetting);
|
||||||
|
try {
|
||||||
|
return await forgetting;
|
||||||
|
} finally {
|
||||||
|
if (forgettingPaths.get(canonicalPath) === forgetting)
|
||||||
|
forgettingPaths.delete(canonicalPath);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingForgetOperations.delete(pendingForget);
|
||||||
|
finishPendingForget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async start() {
|
async start() {
|
||||||
return ensureAgent(homeWorktree);
|
const agent = await ensureAgent(homeWorktree);
|
||||||
|
canonicalHomeWorktree = agent.worktreePath;
|
||||||
|
return agent;
|
||||||
},
|
},
|
||||||
async selectWorktree(worktreePath) {
|
async selectWorktree(worktreePath) {
|
||||||
return ensureAgent(worktreePath);
|
return ensureAgent(worktreePath);
|
||||||
@@ -432,8 +538,13 @@ export function createAgentRegistry({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
async listDirectories() {
|
async listDirectories() {
|
||||||
return listDirectoryCatalog(sessionRoot, agentsByPath);
|
return listDirectoryCatalog(
|
||||||
|
sessionRoot,
|
||||||
|
agentsByPath,
|
||||||
|
canonicalHomeWorktree,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
forgetDirectory,
|
||||||
async listSessions(agentId) {
|
async listSessions(agentId) {
|
||||||
const agent = getAgent(agentId);
|
const agent = getAgent(agentId);
|
||||||
return listSessionSummaries(agent.sessionDir, agent.sessionPath);
|
return listSessionSummaries(agent.sessionDir, agent.sessionPath);
|
||||||
@@ -459,30 +570,63 @@ export function createAgentRegistry({
|
|||||||
},
|
},
|
||||||
async retry(agentId) {
|
async retry(agentId) {
|
||||||
const agent = getAgent(agentId);
|
const agent = getAgent(agentId);
|
||||||
agent.supervisor.markHealthy();
|
return withAgentOperation(agent, async () => {
|
||||||
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
|
agent.supervisor.markHealthy();
|
||||||
await agent.recoveryPromise;
|
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
|
||||||
return publicAgent(agent);
|
await agent.recoveryPromise;
|
||||||
|
return publicAgent(agent);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async restart(agentId) {
|
async restart(agentId) {
|
||||||
const agent = getAgent(agentId);
|
const agent = getAgent(agentId);
|
||||||
await agent.adapter.stop();
|
return withAgentOperation(agent, async () => {
|
||||||
return this.retry(agentId);
|
await agent.adapter.stop();
|
||||||
|
agent.supervisor.markHealthy();
|
||||||
|
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
|
||||||
|
await agent.recoveryPromise;
|
||||||
|
return publicAgent(agent);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async route(agentId, operation, payload) {
|
async route(agentId, operation, payload) {
|
||||||
const agent = getAgent(agentId);
|
const agent = getAgent(agentId);
|
||||||
if (operation === "switch_session")
|
return withAgentOperation(agent, async () => {
|
||||||
return switchSession(agent, payload.sessionPath);
|
if (operation === "switch_session")
|
||||||
if (operation === "new_session") return newSession(agent);
|
return switchSession(agent, payload.sessionPath);
|
||||||
return routeCommand(agent.adapter, operation, payload, agent.state);
|
if (operation === "new_session") return newSession(agent);
|
||||||
|
const previousState = agent.state;
|
||||||
|
const previousStateVersion = agent.stateVersion;
|
||||||
|
const startsWork =
|
||||||
|
operation === "prompt" ||
|
||||||
|
(operation === "submit_prompt" && previousState !== "streaming");
|
||||||
|
if (startsWork) agent.state = "streaming";
|
||||||
|
try {
|
||||||
|
return await routeCommand(
|
||||||
|
agent.adapter,
|
||||||
|
operation,
|
||||||
|
payload,
|
||||||
|
previousState,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (startsWork && agent.stateVersion === previousStateVersion)
|
||||||
|
agent.state = previousState;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async stop() {
|
async stop() {
|
||||||
|
stopping = true;
|
||||||
|
await Promise.allSettled([...inFlight.values()]);
|
||||||
|
await Promise.all([...pendingForgetOperations]);
|
||||||
|
const agents = [...agentsById.values()];
|
||||||
|
for (const agent of agents) agent.forgetting = true;
|
||||||
|
await Promise.all(agents.map(waitForAgentOperations));
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...agentsById.values()].map(async (agent) => {
|
agents.map(async (agent) => {
|
||||||
agent.stopped = true;
|
agent.stopped = true;
|
||||||
agent.supervisor.stop();
|
agent.supervisor.stop();
|
||||||
await agent.adapter.stop();
|
await agent.adapter.stop();
|
||||||
agent.state = "stopped";
|
agent.state = "stopped";
|
||||||
|
agent.stateVersion += 1;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export async function startBridgeService({
|
|||||||
return {
|
return {
|
||||||
agent: await registry.selectWorktree(request.payload.worktreePath),
|
agent: await registry.selectWorktree(request.payload.worktreePath),
|
||||||
};
|
};
|
||||||
|
case "forget_directory":
|
||||||
|
return registry.forgetDirectory(request.payload.worktreePath);
|
||||||
case "subscribe":
|
case "subscribe":
|
||||||
return {
|
return {
|
||||||
events: registry.eventsAfter(
|
events: registry.eventsAfter(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const requestOperations = new Map([
|
|||||||
["list_agents", { agent: false, payload: "none" }],
|
["list_agents", { agent: false, payload: "none" }],
|
||||||
["list_directories", { agent: false, payload: "none" }],
|
["list_directories", { agent: false, payload: "none" }],
|
||||||
["select_agent", { agent: false, payload: "worktree" }],
|
["select_agent", { agent: false, payload: "worktree" }],
|
||||||
|
["forget_directory", { agent: false, payload: "worktree" }],
|
||||||
["get_state", { agent: true, payload: "none" }],
|
["get_state", { agent: true, payload: "none" }],
|
||||||
["get_session_stats", { agent: true, payload: "none" }],
|
["get_session_stats", { agent: true, payload: "none" }],
|
||||||
["list_sessions", { agent: true, payload: "none" }],
|
["list_sessions", { agent: true, payload: "none" }],
|
||||||
|
|||||||
+254
-2
@@ -1,5 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
@@ -72,6 +72,253 @@ test("starts only the home agent and creates other worktree agents on explicit s
|
|||||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stops agents created during shutdown and rejects new selections", async () => {
|
||||||
|
const worktrees = await createWorktrees();
|
||||||
|
const fixture = createAdapterFactory();
|
||||||
|
let releaseFeatureCreation;
|
||||||
|
const featureCreation = new Promise((resolve) => {
|
||||||
|
releaseFeatureCreation = resolve;
|
||||||
|
});
|
||||||
|
let featureCreationStarted;
|
||||||
|
const featureStarted = new Promise((resolve) => {
|
||||||
|
featureCreationStarted = resolve;
|
||||||
|
});
|
||||||
|
const registry = createAgentRegistry({
|
||||||
|
homeWorktree: worktrees.home,
|
||||||
|
sessionRoot: worktrees.sessionRoot,
|
||||||
|
startAdapter: (options) => {
|
||||||
|
const adapter = fixture.startAdapter(options);
|
||||||
|
if (options.cwd === worktrees.feature) {
|
||||||
|
const send = adapter.send.bind(adapter);
|
||||||
|
adapter.send = (command) => {
|
||||||
|
if (command.type !== "get_state") return send(command);
|
||||||
|
featureCreationStarted();
|
||||||
|
return featureCreation.then(() => send(command));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await registry.start();
|
||||||
|
const selecting = registry.selectWorktree(worktrees.feature);
|
||||||
|
await featureStarted;
|
||||||
|
|
||||||
|
const stopping = registry.stop();
|
||||||
|
await assert.rejects(
|
||||||
|
registry.selectWorktree(worktrees.feature),
|
||||||
|
/registry is stopping/,
|
||||||
|
);
|
||||||
|
releaseFeatureCreation();
|
||||||
|
await selecting;
|
||||||
|
await stopping;
|
||||||
|
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drains a forget still resolving its canonical path during shutdown", async () => {
|
||||||
|
const worktrees = await createWorktrees();
|
||||||
|
const featureAlias = join(worktrees.root, "feature-alias");
|
||||||
|
await symlink(worktrees.feature, featureAlias);
|
||||||
|
const fixture = createAdapterFactory();
|
||||||
|
const registry = createAgentRegistry({
|
||||||
|
homeWorktree: worktrees.home,
|
||||||
|
sessionRoot: worktrees.sessionRoot,
|
||||||
|
startAdapter: fixture.startAdapter,
|
||||||
|
});
|
||||||
|
await registry.start();
|
||||||
|
await registry.selectWorktree(worktrees.feature);
|
||||||
|
const featureAdapter = fixture.calls[1].adapter;
|
||||||
|
const stop = featureAdapter.stop.bind(featureAdapter);
|
||||||
|
let stopCalls = 0;
|
||||||
|
featureAdapter.stop = async () => {
|
||||||
|
stopCalls += 1;
|
||||||
|
await stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const forgetting = registry.forgetDirectory(featureAlias);
|
||||||
|
const stopping = registry.stop();
|
||||||
|
await Promise.all([forgetting, stopping]);
|
||||||
|
|
||||||
|
assert.equal(stopCalls, 1);
|
||||||
|
assert.equal(
|
||||||
|
registry
|
||||||
|
.listAgents()
|
||||||
|
.some((agent) => agent.worktreePath === worktrees.feature),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forgets a directory without deleting its resumable session history", async () => {
|
||||||
|
const worktrees = await createWorktrees();
|
||||||
|
const fixture = createAdapterFactory();
|
||||||
|
const registry = createAgentRegistry({
|
||||||
|
homeWorktree: worktrees.home,
|
||||||
|
sessionRoot: worktrees.sessionRoot,
|
||||||
|
startAdapter: fixture.startAdapter,
|
||||||
|
});
|
||||||
|
const home = await registry.start();
|
||||||
|
const feature = await registry.selectWorktree(worktrees.feature);
|
||||||
|
const sessionPath = join(feature.sessionDir, "resumable.jsonl");
|
||||||
|
const sessionContent = `${JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
id: "resumable",
|
||||||
|
timestamp: "2025-01-01T00:00:00.000Z",
|
||||||
|
cwd: worktrees.feature,
|
||||||
|
})}\n`;
|
||||||
|
await writeFile(sessionPath, sessionContent);
|
||||||
|
await writeFile(
|
||||||
|
join(feature.sessionDir, "bridge-agent.json"),
|
||||||
|
`${JSON.stringify({ sessionPath, worktreePath: worktrees.feature })}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await registry.forgetDirectory(worktrees.feature);
|
||||||
|
|
||||||
|
assert.equal(fixture.calls[1].adapter.stopped, true);
|
||||||
|
assert.deepEqual(registry.listAgents(), [home]);
|
||||||
|
assert.deepEqual(await registry.listDirectories(), [
|
||||||
|
{
|
||||||
|
worktreePath: worktrees.home,
|
||||||
|
state: "idle",
|
||||||
|
isHome: true,
|
||||||
|
agentId: home.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(await readFile(sessionPath, "utf8"), sessionContent);
|
||||||
|
await assert.rejects(
|
||||||
|
registry.forgetDirectory(worktrees.home),
|
||||||
|
/cannot forget the home directory/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const reopened = await registry.selectWorktree(worktrees.feature);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await registry.listSessions(reopened.id)).map(({ id }) => id),
|
||||||
|
["resumable"],
|
||||||
|
);
|
||||||
|
await registry.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coordinates forgetting with directory creation and active commands", async () => {
|
||||||
|
const worktrees = await createWorktrees();
|
||||||
|
const fixture = createAdapterFactory();
|
||||||
|
let releaseFeatureCreation;
|
||||||
|
const featureCreation = new Promise((resolve) => {
|
||||||
|
releaseFeatureCreation = resolve;
|
||||||
|
});
|
||||||
|
let featureCreationStarted;
|
||||||
|
const featureStarted = new Promise((resolve) => {
|
||||||
|
featureCreationStarted = resolve;
|
||||||
|
});
|
||||||
|
const registry = createAgentRegistry({
|
||||||
|
homeWorktree: worktrees.home,
|
||||||
|
sessionRoot: worktrees.sessionRoot,
|
||||||
|
startAdapter: (options) => {
|
||||||
|
const adapter = fixture.startAdapter(options);
|
||||||
|
if (options.cwd === worktrees.feature) {
|
||||||
|
const send = adapter.send.bind(adapter);
|
||||||
|
adapter.send = (command) => {
|
||||||
|
if (command.type !== "get_state") return send(command);
|
||||||
|
featureCreationStarted();
|
||||||
|
return featureCreation.then(() => send(command));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await registry.start();
|
||||||
|
|
||||||
|
const selecting = registry.selectWorktree(worktrees.feature);
|
||||||
|
await featureStarted;
|
||||||
|
const forgettingDuringCreation = registry.forgetDirectory(worktrees.feature);
|
||||||
|
releaseFeatureCreation();
|
||||||
|
await selecting;
|
||||||
|
await forgettingDuringCreation;
|
||||||
|
assert.equal(fixture.calls[1].adapter.stopped, true);
|
||||||
|
assert.equal(
|
||||||
|
(await registry.listDirectories()).some(
|
||||||
|
(directory) => directory.worktreePath === worktrees.feature,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await registry.selectWorktree(worktrees.feature);
|
||||||
|
const concurrentAdapter = fixture.calls[2].adapter;
|
||||||
|
const stop = concurrentAdapter.stop.bind(concurrentAdapter);
|
||||||
|
let releaseStop;
|
||||||
|
const stopGate = new Promise((resolve) => {
|
||||||
|
releaseStop = resolve;
|
||||||
|
});
|
||||||
|
let stopCalls = 0;
|
||||||
|
concurrentAdapter.stop = async () => {
|
||||||
|
stopCalls += 1;
|
||||||
|
await stopGate;
|
||||||
|
await stop();
|
||||||
|
};
|
||||||
|
const firstForget = registry.forgetDirectory(worktrees.feature);
|
||||||
|
const secondForget = registry.forgetDirectory(worktrees.feature);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await assert.rejects(
|
||||||
|
registry.selectWorktree(worktrees.feature),
|
||||||
|
/being forgotten/,
|
||||||
|
);
|
||||||
|
releaseStop();
|
||||||
|
await Promise.all([firstForget, secondForget]);
|
||||||
|
assert.equal(stopCalls, 1);
|
||||||
|
assert.equal(registry.listAgents().length, 1);
|
||||||
|
|
||||||
|
const feature = await registry.selectWorktree(worktrees.feature);
|
||||||
|
const { adapter, options } = fixture.calls[3];
|
||||||
|
const send = adapter.send.bind(adapter);
|
||||||
|
let releasePrompt;
|
||||||
|
const promptResponse = new Promise((resolve) => {
|
||||||
|
releasePrompt = resolve;
|
||||||
|
});
|
||||||
|
adapter.send = (command) =>
|
||||||
|
command.type === "prompt" ? promptResponse : send(command);
|
||||||
|
const prompt = registry.route(feature.id, "prompt", {
|
||||||
|
message: "Keep working",
|
||||||
|
});
|
||||||
|
const forgettingDuringPrompt = registry.forgetDirectory(worktrees.feature);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(adapter.stopped, false);
|
||||||
|
releasePrompt({ type: "response", success: true });
|
||||||
|
await prompt;
|
||||||
|
await assert.rejects(forgettingDuringPrompt, /while Pi is working/);
|
||||||
|
assert.equal(adapter.stopped, false);
|
||||||
|
|
||||||
|
options.onEvent({ type: "agent_state", data: { state: "idle" } });
|
||||||
|
await registry.forgetDirectory(worktrees.feature);
|
||||||
|
assert.equal(adapter.stopped, true);
|
||||||
|
|
||||||
|
const failedFeature = await registry.selectWorktree(worktrees.feature);
|
||||||
|
const failedFixture = fixture.calls[4];
|
||||||
|
let rejectPrompt;
|
||||||
|
const failedPromptResponse = new Promise((_resolve, reject) => {
|
||||||
|
rejectPrompt = reject;
|
||||||
|
});
|
||||||
|
const failedSend = failedFixture.adapter.send.bind(failedFixture.adapter);
|
||||||
|
failedFixture.adapter.send = (command) =>
|
||||||
|
command.type === "prompt" ? failedPromptResponse : failedSend(command);
|
||||||
|
const failedPrompt = registry.route(failedFeature.id, "prompt", {
|
||||||
|
message: "Start before the response fails",
|
||||||
|
});
|
||||||
|
const failedPromptAssertion = assert.rejects(failedPrompt, /send failed/);
|
||||||
|
failedFixture.options.onEvent({
|
||||||
|
type: "agent_state",
|
||||||
|
data: { state: "streaming" },
|
||||||
|
});
|
||||||
|
rejectPrompt(new Error("send failed"));
|
||||||
|
await failedPromptAssertion;
|
||||||
|
await assert.rejects(
|
||||||
|
registry.forgetDirectory(worktrees.feature),
|
||||||
|
/while Pi is working/,
|
||||||
|
);
|
||||||
|
failedFixture.options.onEvent({
|
||||||
|
type: "agent_state",
|
||||||
|
data: { state: "idle" },
|
||||||
|
});
|
||||||
|
await registry.forgetDirectory(worktrees.feature);
|
||||||
|
await registry.stop();
|
||||||
|
});
|
||||||
|
|
||||||
test("catalogues managed directories and exposes only their sessions", async () => {
|
test("catalogues managed directories and exposes only their sessions", async () => {
|
||||||
const worktrees = await createWorktrees();
|
const worktrees = await createWorktrees();
|
||||||
const fixture = createAdapterFactory();
|
const fixture = createAdapterFactory();
|
||||||
@@ -151,10 +398,15 @@ test("catalogues managed directories and exposes only their sessions", async ()
|
|||||||
});
|
});
|
||||||
await restored.start();
|
await restored.start();
|
||||||
assert.deepEqual(await restored.listDirectories(), [
|
assert.deepEqual(await restored.listDirectories(), [
|
||||||
{ worktreePath: worktrees.feature, state: "inactive" },
|
{
|
||||||
|
worktreePath: worktrees.feature,
|
||||||
|
state: "inactive",
|
||||||
|
isHome: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
worktreePath: worktrees.home,
|
worktreePath: worktrees.home,
|
||||||
state: "idle",
|
state: "idle",
|
||||||
|
isHome: true,
|
||||||
agentId: restored.listAgents()[0].id,
|
agentId: restored.listAgents()[0].id,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -106,6 +106,20 @@ test("starts the home agent and dispatches local protocol requests to the select
|
|||||||
{ type: "get_state" },
|
{ type: "get_state" },
|
||||||
{ type: "prompt", message: "Use this worktree" },
|
{ type: "prompt", message: "Use this worktree" },
|
||||||
]);
|
]);
|
||||||
|
fixture.calls[1].options.onEvent({
|
||||||
|
type: "agent_state",
|
||||||
|
data: { state: "idle" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const forgotten = await request(socket, {
|
||||||
|
version: "v1",
|
||||||
|
id: "forget-1",
|
||||||
|
op: "forget_directory",
|
||||||
|
payload: { worktreePath: feature },
|
||||||
|
});
|
||||||
|
assert.equal(forgotten.ok, true);
|
||||||
|
assert.equal(forgotten.result.worktreePath, feature);
|
||||||
|
assert.equal((await service.listAgents()).length, 1);
|
||||||
} finally {
|
} finally {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
await service.close();
|
await service.close();
|
||||||
|
|||||||
@@ -13,9 +13,16 @@ test("ships a v5 Noctalia compatibility launcher instead of a primary panel", as
|
|||||||
assert.match(bridge, /PI_STATUS_UI_BINARY/);
|
assert.match(bridge, /PI_STATUS_UI_BINARY/);
|
||||||
assert.match(bridge, /pi-status-ui/);
|
assert.match(bridge, /pi-status-ui/);
|
||||||
assert.match(bridge, /noctalia\.runAsync/);
|
assert.match(bridge, /noctalia\.runAsync/);
|
||||||
|
assert.match(bridge, /test -x/);
|
||||||
assert.match(bridge, /setsid -f env TMPDIR=\/tmp/);
|
assert.match(bridge, /setsid -f env TMPDIR=\/tmp/);
|
||||||
|
assert.match(bridge, /--toggle/);
|
||||||
|
assert.doesNotMatch(bridge, /--show/);
|
||||||
assert.match(bridge, /pi-status-ui\.log/);
|
assert.match(bridge, /pi-status-ui\.log/);
|
||||||
assert.doesNotMatch(bridge, /togglePanel/);
|
assert.doesNotMatch(bridge, /togglePanel/);
|
||||||
assert.match(bridge, /Pi reconnecting/);
|
assert.match(bridge, /Pi reconnecting/);
|
||||||
assert.match(bridge, /discover_home_agent\(socket\)/);
|
assert.match(bridge, /discover_home_agent\(socket\)/);
|
||||||
|
assert.match(bridge, /systemctl --user start pi-status-bridge\.service/);
|
||||||
|
assert.match(bridge, /bridge_start_in_flight/);
|
||||||
|
assert.match(bridge, /pending_ui_open/);
|
||||||
|
assert.match(bridge, /Bridge is starting/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ test("accepts bridge-managed directory and session operations", () => {
|
|||||||
id: "request-1",
|
id: "request-1",
|
||||||
op: "list_directories",
|
op: "list_directories",
|
||||||
});
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
parseRequestFrame(
|
||||||
|
request({
|
||||||
|
op: "forget_directory",
|
||||||
|
payload: { worktreePath: "/projects/feature" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
version: "v1",
|
||||||
|
id: "request-1",
|
||||||
|
op: "forget_directory",
|
||||||
|
payload: { worktreePath: "/projects/feature" },
|
||||||
|
},
|
||||||
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
parseRequestFrame(request({ op: "list_sessions", agentId: "agent-main" })),
|
parseRequestFrame(request({ op: "list_sessions", agentId: "agent-main" })),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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, /transcript\.scrollTop = transcript\.scrollHeight/);
|
||||||
assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
|
assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
|
||||||
assert.match(source, /const loadGeneration = useRef\(0\)/);
|
assert.match(source, /const loadGeneration = useRef\(0\)/);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/const bridgeRetryTimer = useRef<number \| undefined>\(undefined\)/,
|
||||||
|
);
|
||||||
|
assert.match(source, /const bridgeRetryAttempt = useRef\(0\)/);
|
||||||
|
assert.match(source, /function scheduleBridgeReconnect/);
|
||||||
|
assert.match(source, /Bridge unavailable; retrying in/);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/setStatus\(String\(error\)\);\s*scheduleBridgeReconnect\(\);/,
|
||||||
|
);
|
||||||
|
assert.match(source, /bridgeRetryTimer\.current = window\.setTimeout/);
|
||||||
|
assert.match(source, /void refreshAgents\(preferredPath\)/);
|
||||||
|
assert.match(source, /bridgeRetryAttempt\.current = 0/);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/const lastEventSequence = useRef<Record<string, number>>/,
|
/const lastEventSequence = useRef<Record<string, number>>/,
|
||||||
@@ -62,6 +76,52 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("exposes accessible directory, session, notification, and extension controls", async () => {
|
||||||
|
const [component, styles] = await Promise.all([
|
||||||
|
readFile("ui/src/App.tsx", "utf8"),
|
||||||
|
readFile("ui/src/App.css", "utf8"),
|
||||||
|
]);
|
||||||
|
assert.match(component, /aria-expanded=\{view === "settings"\}/);
|
||||||
|
assert.match(
|
||||||
|
component,
|
||||||
|
/aria-label="Managed directories"\s+className="directory-tab-list"\s+role="tablist"/,
|
||||||
|
);
|
||||||
|
assert.match(component, /role="tab"/);
|
||||||
|
assert.match(
|
||||||
|
component,
|
||||||
|
/aria-selected=\{directory\.agentId === selectedId\}/,
|
||||||
|
);
|
||||||
|
assert.match(component, /aria-controls="directory-workspace"/);
|
||||||
|
assert.match(component, /onKeyDown=\{\(event\) => handleDirectoryTabKeyDown/);
|
||||||
|
assert.match(
|
||||||
|
component,
|
||||||
|
/aria-current=\{session\.isCurrent \? "page" : undefined\}/,
|
||||||
|
);
|
||||||
|
assert.match(component, /role="status" aria-atomic="true"/);
|
||||||
|
assert.match(component, /aria-hidden="true"/);
|
||||||
|
assert.match(
|
||||||
|
component,
|
||||||
|
/role=\{notice\.notifyType === "error" \? "alert" : "status"\}/,
|
||||||
|
);
|
||||||
|
assert.match(component, /role="dialog"/);
|
||||||
|
assert.match(component, /aria-modal="true"/);
|
||||||
|
assert.match(component, /aria-labelledby="extension-title"/);
|
||||||
|
assert.match(component, /const extensionRef = useRef<HTMLElement>\(null\)/);
|
||||||
|
assert.match(component, /function focusableExtensionControls/);
|
||||||
|
assert.match(component, /event\.key !== "Tab"/);
|
||||||
|
assert.match(component, /data-initial-focus/);
|
||||||
|
assert.match(component, /className="extension-backdrop"/);
|
||||||
|
assert.match(component, /htmlFor="extension-value"/);
|
||||||
|
assert.match(component, /Close current session and start a new session/);
|
||||||
|
assert.match(component, /Restart Pi for the selected directory/);
|
||||||
|
assert.match(component, /window\.confirm\(/);
|
||||||
|
assert.match(styles, /button:focus-visible/);
|
||||||
|
assert.match(styles, /\*::before,\n\*::after/);
|
||||||
|
assert.match(styles, /\.sr-only/);
|
||||||
|
assert.match(styles, /\.extension-backdrop \{/);
|
||||||
|
assert.match(styles, /animation-duration: 0\.01ms/);
|
||||||
|
});
|
||||||
|
|
||||||
test("makes the window draggable from the non-interactive header", async () => {
|
test("makes the window draggable from the non-interactive header", async () => {
|
||||||
const [component, styles, capability] = await Promise.all([
|
const [component, styles, capability] = await Promise.all([
|
||||||
readFile("ui/src/App.tsx", "utf8"),
|
readFile("ui/src/App.tsx", "utf8"),
|
||||||
@@ -110,11 +170,31 @@ test("shows bridge-managed directory status and sessions for the opened director
|
|||||||
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
|
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
|
||||||
);
|
);
|
||||||
assert.match(component, /function chooseDirectory/);
|
assert.match(component, /function chooseDirectory/);
|
||||||
|
assert.match(component, /function handleDirectoryTabKeyDown/);
|
||||||
|
assert.match(component, /function forgetDirectory/);
|
||||||
|
assert.match(component, /window\.confirm/);
|
||||||
|
assert.match(component, /"forget_directory"/);
|
||||||
|
assert.match(component, /bridgeEvent\.type === "agent_state"/);
|
||||||
|
assert.match(component, /setDirectories\(\(current\) =>/);
|
||||||
|
assert.match(
|
||||||
|
component,
|
||||||
|
/state: loadedState\.isStreaming \? "streaming" : "idle"/,
|
||||||
|
);
|
||||||
|
assert.match(component, /className="directory-add"/);
|
||||||
|
assert.match(component, /Add directory/);
|
||||||
|
assert.doesNotMatch(component, /className="folder-form"/);
|
||||||
|
assert.doesNotMatch(component, /Remembered folders/);
|
||||||
assert.match(component, /function startNewSession/);
|
assert.match(component, /function startNewSession/);
|
||||||
|
assert.match(component, /function closeCurrentSession/);
|
||||||
|
assert.match(component, /Use \/resume to reopen it/);
|
||||||
assert.match(component, /function switchSession/);
|
assert.match(component, /function switchSession/);
|
||||||
assert.match(component, /className="session-panel"/);
|
assert.match(component, /className="session-panel"/);
|
||||||
assert.match(component, /New session/);
|
assert.match(component, /New session/);
|
||||||
assert.match(component, /state\.isStreaming/);
|
assert.match(component, /state\.isStreaming/);
|
||||||
|
assert.match(styles, /\.directory-add \{/);
|
||||||
|
assert.match(styles, /\.directory-tab \{/);
|
||||||
|
assert.match(styles, /\.directory-tab-button \{/);
|
||||||
|
assert.match(styles, /\.directory-close \{/);
|
||||||
assert.match(styles, /\.session-panel \{/);
|
assert.match(styles, /\.session-panel \{/);
|
||||||
assert.match(styles, /\.session-list \{/);
|
assert.match(styles, /\.session-list \{/);
|
||||||
});
|
});
|
||||||
@@ -163,11 +243,18 @@ test("distinguishes user and assistant messages", async () => {
|
|||||||
assert.match(styles, /background: #1a241a/);
|
assert.match(styles, /background: #1a241a/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses a subtle focus treatment for the prompt editor", async () => {
|
test("keeps an accessible focus treatment for the prompt editor", async () => {
|
||||||
const styles = await readFile("ui/src/App.css", "utf8");
|
const styles = await readFile("ui/src/App.css", "utf8");
|
||||||
assert.match(styles, /\.composer textarea:focus \{/);
|
assert.match(styles, /\.composer textarea:focus \{/);
|
||||||
assert.match(styles, /outline: none/);
|
assert.doesNotMatch(styles, /outline: none/);
|
||||||
assert.match(styles, /border-color: #6f8b56/);
|
assert.match(styles, /border-color: #6f8b56/);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.composer textarea:focus \{[^}]*outline: 2px solid #c4ed8b/,
|
||||||
|
);
|
||||||
|
assert.match(styles, /\.composer textarea:focus \{[^}]*outline-offset: -2px/);
|
||||||
|
assert.doesNotMatch(styles, /\.composer textarea:focus \{[^}]*box-shadow:/);
|
||||||
|
assert.match(styles, /textarea:focus-visible/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
|
test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
|
||||||
|
|||||||
+12
-6
@@ -16,19 +16,25 @@ From the repository root:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm --prefix ui ci
|
npm --prefix ui ci
|
||||||
node src/bridge/cli.js --worktree "$HOME"
|
|
||||||
npm --prefix ui run tauri dev
|
npm --prefix ui run tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The second command starts the bridge; run it in a separate terminal. The Tauri app connects to it on startup.
|
The UI needs a running bridge. For day-to-day use, keep it in the background with the root project's `npm run bridge:service:install` command. For foreground development, run the bridge in a separate terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node src/bridge/cli.js --worktree "$HOME"
|
||||||
|
```
|
||||||
|
|
||||||
|
The Tauri app connects to the bridge on startup. See the [root README](../README.md#keep-the-bridge-running-in-the-background-recommended) for start, restart, status, and log commands.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm --prefix ui run dev # Vite frontend only
|
npm --prefix ui run dev # Vite frontend only
|
||||||
npm --prefix ui run build # typecheck and build frontend assets
|
npm --prefix ui run build # typecheck and build frontend assets
|
||||||
npm --prefix ui run tauri dev # run the desktop client
|
npm --prefix ui run tauri dev # run the desktop client in development
|
||||||
npm --prefix ui run tauri build # package a desktop bundle
|
npm --prefix ui run tauri:release # build the production executable used by Noctalia/Mod+Space
|
||||||
|
npm --prefix ui run tauri build # package installers (requires platform bundle tooling)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"tauri:release": "tauri build --no-bundle"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=../dist");
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ pub async fn select_worktree(socket_path: &str, worktree_path: &str) -> Result<V
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn forget_directory(socket_path: &str, worktree_path: &str) -> Result<Value, String> {
|
||||||
|
request(
|
||||||
|
socket_path,
|
||||||
|
"forget_directory",
|
||||||
|
None,
|
||||||
|
Some(json!({ "worktreePath": worktree_path })),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
||||||
request(socket_path, "list_sessions", Some(agent_id), None).await
|
request(socket_path, "list_sessions", Some(agent_id), None).await
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-3
@@ -6,6 +6,22 @@ use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
|
|||||||
|
|
||||||
struct Subscription(Mutex<Option<JoinHandle<()>>>);
|
struct Subscription(Mutex<Option<JoinHandle<()>>>);
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum WindowAction {
|
||||||
|
Hide,
|
||||||
|
ShowAndFocus,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_action(args: &[String], is_visible: bool) -> WindowAction {
|
||||||
|
if args.iter().any(|arg| arg == "--hide")
|
||||||
|
|| (args.iter().any(|arg| arg == "--toggle") && is_visible)
|
||||||
|
{
|
||||||
|
WindowAction::Hide
|
||||||
|
} else {
|
||||||
|
WindowAction::ShowAndFocus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn socket_path() -> Result<String, String> {
|
fn socket_path() -> Result<String, String> {
|
||||||
bridge::default_socket_path()
|
bridge::default_socket_path()
|
||||||
}
|
}
|
||||||
@@ -25,6 +41,11 @@ async fn select_worktree(worktree_path: String) -> Result<Value, String> {
|
|||||||
bridge::select_worktree(&socket_path()?, &worktree_path).await
|
bridge::select_worktree(&socket_path()?, &worktree_path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn forget_directory(worktree_path: String) -> Result<Value, String> {
|
||||||
|
bridge::forget_directory(&socket_path()?, &worktree_path).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn load_agent(agent_id: String) -> Result<Value, String> {
|
async fn load_agent(agent_id: String) -> Result<Value, String> {
|
||||||
bridge::load_agent(&socket_path()?, &agent_id).await
|
bridge::load_agent(&socket_path()?, &agent_id).await
|
||||||
@@ -121,16 +142,24 @@ fn subscribe_agent(
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
let builder = tauri::Builder::default()
|
let builder = tauri::Builder::default()
|
||||||
.manage(Subscription(Mutex::new(None)))
|
.manage(Subscription(Mutex::new(None)))
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.show();
|
match window_action(&args, window.is_visible().unwrap_or(false)) {
|
||||||
let _ = window.set_focus();
|
WindowAction::Hide => {
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
WindowAction::ShowAndFocus => {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
list_agents,
|
list_agents,
|
||||||
list_directories,
|
list_directories,
|
||||||
select_worktree,
|
select_worktree,
|
||||||
|
forget_directory,
|
||||||
load_agent,
|
load_agent,
|
||||||
list_sessions,
|
list_sessions,
|
||||||
switch_session,
|
switch_session,
|
||||||
@@ -149,3 +178,17 @@ pub fn run() {
|
|||||||
eprintln!("Pi Status UI exited: {error}");
|
eprintln!("Pi Status UI exited: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
|
||||||
|
let toggle = vec!["--toggle".to_owned()];
|
||||||
|
|
||||||
|
assert_eq!(window_action(&toggle, true), WindowAction::Hide);
|
||||||
|
assert_eq!(window_action(&toggle, false), WindowAction::ShowAndFocus);
|
||||||
|
assert_eq!(window_action(&vec!["--show".to_owned()], true), WindowAction::ShowAndFocus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Pi Status UI",
|
"title": "Pi Status UI",
|
||||||
"width": 760,
|
"width": 900,
|
||||||
"height": 620,
|
"height": 720,
|
||||||
"minWidth": 480,
|
"minWidth": 480,
|
||||||
"minHeight": 420,
|
"minHeight": 420,
|
||||||
"center": true,
|
"center": true,
|
||||||
|
|||||||
+123
-25
@@ -9,9 +9,22 @@
|
|||||||
color: #ebebe8;
|
color: #ebebe8;
|
||||||
background: #101111;
|
background: #101111;
|
||||||
}
|
}
|
||||||
* {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 480px;
|
min-width: 480px;
|
||||||
@@ -37,6 +50,13 @@ button {
|
|||||||
button:hover {
|
button:hover {
|
||||||
background: #f0ffe0;
|
background: #f0ffe0;
|
||||||
}
|
}
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 3px solid #c4ed8b;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -70,8 +90,7 @@ button:hover {
|
|||||||
.workspace-heading,
|
.workspace-heading,
|
||||||
.status-row,
|
.status-row,
|
||||||
.composer-actions,
|
.composer-actions,
|
||||||
.settings-actions,
|
.settings-actions {
|
||||||
.folder-form {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -148,14 +167,20 @@ h1 {
|
|||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agents {
|
.directory-tabs,
|
||||||
|
.directory-tab-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-height: 30px;
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
.directory-tabs {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 1px 0 3px;
|
padding: 1px 0 3px;
|
||||||
}
|
}
|
||||||
|
.directory-tab-list {
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
.agent-caption {
|
.agent-caption {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: #70776f;
|
color: #70776f;
|
||||||
@@ -164,34 +189,93 @@ h1 {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.agent {
|
.directory-tab {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.directory-tab-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 180px;
|
max-width: 180px;
|
||||||
padding: 5px 8px;
|
|
||||||
border: 1px solid #303530;
|
border: 1px solid #303530;
|
||||||
|
border-right: 0;
|
||||||
|
border-radius: 7px 0 0 7px;
|
||||||
|
padding: 5px 8px;
|
||||||
background: #1b1e1b;
|
background: #1b1e1b;
|
||||||
color: #aeb4ac;
|
color: #aeb4ac;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
.agent > span {
|
.directory-tab-button > span {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.agent small {
|
.directory-tab-button small {
|
||||||
color: #70776f;
|
color: #70776f;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.agent.selected {
|
.directory-tab-button.selected {
|
||||||
border-color: #7da856;
|
border-color: #7da856;
|
||||||
background: #222b1d;
|
background: #222b1d;
|
||||||
color: #ecf7df;
|
color: #ecf7df;
|
||||||
}
|
}
|
||||||
|
.directory-tab-button.selected + .directory-close {
|
||||||
|
border-color: #7da856;
|
||||||
|
background: #222b1d;
|
||||||
|
}
|
||||||
|
.directory-tab-button:only-child {
|
||||||
|
border-right: 1px solid #303530;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.directory-tab-button.selected:only-child {
|
||||||
|
border-color: #7da856;
|
||||||
|
}
|
||||||
|
.directory-close {
|
||||||
|
min-width: 24px;
|
||||||
|
border: 1px solid #303530;
|
||||||
|
border-radius: 0 7px 7px 0;
|
||||||
|
padding: 3px 6px;
|
||||||
|
background: #1b1e1b;
|
||||||
|
color: #aeb4ac;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.directory-tab:focus-within .directory-tab-button,
|
||||||
|
.directory-tab:focus-within .directory-close {
|
||||||
|
border-color: #c4ed8b;
|
||||||
|
}
|
||||||
|
.directory-close:hover {
|
||||||
|
background: #3a2222;
|
||||||
|
color: #ffc0c0;
|
||||||
|
}
|
||||||
|
.directory-close:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.directory-add-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 5px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.directory-add {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.directory-add input {
|
||||||
|
width: 220px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.directory-add button {
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.notifications {
|
.notifications {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -500,9 +584,9 @@ textarea {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
.composer textarea:focus {
|
.composer textarea:focus {
|
||||||
outline: none;
|
|
||||||
border-color: #6f8b56;
|
border-color: #6f8b56;
|
||||||
box-shadow: 0 0 0 1px rgba(143, 189, 95, 0.28);
|
outline: 2px solid #c4ed8b;
|
||||||
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
.composer-actions {
|
.composer-actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -657,15 +741,6 @@ h2 {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.folder-form {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.remembered {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.settings-actions {
|
.settings-actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -684,6 +759,15 @@ h2 {
|
|||||||
.session-panel-heading h2 {
|
.session-panel-heading h2 {
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.session-actions button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
.session-panel-heading .muted {
|
.session-panel-heading .muted {
|
||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -727,9 +811,18 @@ h2 {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.extension {
|
.extension-backdrop {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: auto 14px 14px;
|
z-index: 10;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.extension {
|
||||||
|
width: min(100%, 680px);
|
||||||
max-height: min(70vh, 460px);
|
max-height: min(70vh, 460px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #9cc76d;
|
border: 1px solid #9cc76d;
|
||||||
@@ -746,8 +839,13 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.transcript.working {
|
*,
|
||||||
animation: none;
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+391
-127
@@ -1,4 +1,11 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
@@ -8,6 +15,7 @@ type Agent = { id: string; worktreePath: string; state: string };
|
|||||||
type Directory = {
|
type Directory = {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
isHome: boolean;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
};
|
};
|
||||||
type Session = {
|
type Session = {
|
||||||
@@ -122,7 +130,6 @@ const thinkingLevels = [
|
|||||||
"xhigh",
|
"xhigh",
|
||||||
"max",
|
"max",
|
||||||
];
|
];
|
||||||
const rememberedKey = "pi-status-ui.remembered-worktrees";
|
|
||||||
const selectedPathKey = "pi-status-ui.selected-worktree";
|
const selectedPathKey = "pi-status-ui.selected-worktree";
|
||||||
const controlCommands: Command[] = [
|
const controlCommands: Command[] = [
|
||||||
{
|
{
|
||||||
@@ -198,12 +205,12 @@ function currentTodos(messages: Message[]) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function rememberedPaths() {
|
function focusableExtensionControls(container: HTMLElement) {
|
||||||
try {
|
return [
|
||||||
return JSON.parse(localStorage.getItem(rememberedKey) ?? "[]") as string[];
|
...container.querySelectorAll<HTMLElement>(
|
||||||
} catch {
|
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||||
return [];
|
),
|
||||||
}
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -221,6 +228,7 @@ function App() {
|
|||||||
const [commands, setCommands] = useState<Command[]>([]);
|
const [commands, setCommands] = useState<Command[]>([]);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [folderPath, setFolderPath] = useState("");
|
const [folderPath, setFolderPath] = useState("");
|
||||||
|
const [addingDirectory, setAddingDirectory] = useState(false);
|
||||||
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
|
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
|
||||||
const [view, setView] = useState<"conversation" | "settings">("conversation");
|
const [view, setView] = useState<"conversation" | "settings">("conversation");
|
||||||
const [commandFollowUp, setCommandFollowUp] = useState<FollowUpCommand>();
|
const [commandFollowUp, setCommandFollowUp] = useState<FollowUpCommand>();
|
||||||
@@ -237,8 +245,13 @@ function App() {
|
|||||||
Record<string, ExtensionWidget>
|
Record<string, ExtensionWidget>
|
||||||
>({});
|
>({});
|
||||||
const transcriptRef = useRef<HTMLElement>(null);
|
const transcriptRef = useRef<HTMLElement>(null);
|
||||||
|
const extensionRef = useRef<HTMLElement>(null);
|
||||||
|
const directoryTabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
const pendingSubmissionId = useRef(0);
|
const pendingSubmissionId = useRef(0);
|
||||||
const loadGeneration = useRef(0);
|
const loadGeneration = useRef(0);
|
||||||
|
const refreshGeneration = useRef(0);
|
||||||
|
const bridgeRetryTimer = useRef<number | undefined>(undefined);
|
||||||
|
const bridgeRetryAttempt = useRef(0);
|
||||||
const lastEventSequence = useRef<Record<string, number>>({});
|
const lastEventSequence = useRef<Record<string, number>>({});
|
||||||
const [workProgress, setWorkProgress] = useState<WorkProgress>({
|
const [workProgress, setWorkProgress] = useState<WorkProgress>({
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
@@ -247,6 +260,13 @@ function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selected = agents.find((agent) => agent.id === selectedId);
|
const selected = agents.find((agent) => agent.id === selectedId);
|
||||||
|
const selectedDirectoryIndex = directories.findIndex(
|
||||||
|
(directory) => directory.agentId === selectedId,
|
||||||
|
);
|
||||||
|
const selectedDirectoryTabId =
|
||||||
|
selectedDirectoryIndex >= 0
|
||||||
|
? `directory-tab-${selectedDirectoryIndex}`
|
||||||
|
: undefined;
|
||||||
const filteredCommands = useMemo(() => {
|
const filteredCommands = useMemo(() => {
|
||||||
if (!message.startsWith("/")) return [];
|
if (!message.startsWith("/")) return [];
|
||||||
const uniqueCommands = [...controlCommands, ...commands].filter(
|
const uniqueCommands = [...controlCommands, ...commands].filter(
|
||||||
@@ -282,6 +302,16 @@ function App() {
|
|||||||
const loadedCommands = unpack(snapshot.commands);
|
const loadedCommands = unpack(snapshot.commands);
|
||||||
const loadedModels = unpack(snapshot.models);
|
const loadedModels = unpack(snapshot.models);
|
||||||
setState(loadedState as AgentState);
|
setState(loadedState as AgentState);
|
||||||
|
setDirectories((current) =>
|
||||||
|
current.map((directory) =>
|
||||||
|
directory.agentId === agentId
|
||||||
|
? {
|
||||||
|
...directory,
|
||||||
|
state: loadedState.isStreaming ? "streaming" : "idle",
|
||||||
|
}
|
||||||
|
: directory,
|
||||||
|
),
|
||||||
|
);
|
||||||
setStats(loadedStats as SessionStats);
|
setStats(loadedStats as SessionStats);
|
||||||
setMessages((loadedTranscript.messages as Message[] | undefined) ?? []);
|
setMessages((loadedTranscript.messages as Message[] | undefined) ?? []);
|
||||||
setCommands((loadedCommands.commands as Command[] | undefined) ?? []);
|
setCommands((loadedCommands.commands as Command[] | undefined) ?? []);
|
||||||
@@ -304,17 +334,36 @@ function App() {
|
|||||||
await invoke("subscribe_agent", { agentId, cursor: 0 });
|
await invoke("subscribe_agent", { agentId, cursor: 0 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(String(error));
|
setStatus(String(error));
|
||||||
|
scheduleBridgeReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleBridgeReconnect(preferredPath?: string) {
|
||||||
|
if (bridgeRetryTimer.current !== undefined) return;
|
||||||
|
const delay = Math.min(250 * 2 ** bridgeRetryAttempt.current, 2_000);
|
||||||
|
bridgeRetryAttempt.current += 1;
|
||||||
|
setStatus(`Bridge unavailable; retrying in ${delay}ms…`);
|
||||||
|
bridgeRetryTimer.current = window.setTimeout(() => {
|
||||||
|
bridgeRetryTimer.current = undefined;
|
||||||
|
void refreshAgents(preferredPath);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshAgents(
|
async function refreshAgents(
|
||||||
preferredPath = localStorage.getItem(selectedPathKey) ?? undefined,
|
preferredPath = localStorage.getItem(selectedPathKey) ?? undefined,
|
||||||
) {
|
) {
|
||||||
|
const generation = ++refreshGeneration.current;
|
||||||
try {
|
try {
|
||||||
const [agentResponse, directoryResponse] = await Promise.all([
|
const [agentResponse, directoryResponse] = await Promise.all([
|
||||||
invoke<{ agents: Agent[] }>("list_agents"),
|
invoke<{ agents: Agent[] }>("list_agents"),
|
||||||
invoke<{ directories: Directory[] }>("list_directories"),
|
invoke<{ directories: Directory[] }>("list_directories"),
|
||||||
]);
|
]);
|
||||||
|
if (generation !== refreshGeneration.current) return;
|
||||||
|
if (bridgeRetryTimer.current !== undefined) {
|
||||||
|
window.clearTimeout(bridgeRetryTimer.current);
|
||||||
|
bridgeRetryTimer.current = undefined;
|
||||||
|
}
|
||||||
|
bridgeRetryAttempt.current = 0;
|
||||||
setAgents(agentResponse.agents);
|
setAgents(agentResponse.agents);
|
||||||
setDirectories(directoryResponse.directories);
|
setDirectories(directoryResponse.directories);
|
||||||
const next =
|
const next =
|
||||||
@@ -329,7 +378,8 @@ function App() {
|
|||||||
await loadAgent(next.id, true);
|
await loadAgent(next.id, true);
|
||||||
} else setStatus("No Pi agents available");
|
} else setStatus("No Pi agents available");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(String(error));
|
if (generation !== refreshGeneration.current) return;
|
||||||
|
scheduleBridgeReconnect(preferredPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +396,10 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshAgents();
|
void refreshAgents();
|
||||||
|
return () => {
|
||||||
|
if (bridgeRetryTimer.current !== undefined)
|
||||||
|
window.clearTimeout(bridgeRetryTimer.current);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -541,6 +595,18 @@ function App() {
|
|||||||
if (bridgeEvent.type === "extension_ui_request") {
|
if (bridgeEvent.type === "extension_ui_request") {
|
||||||
handleExtension(bridgeEvent.data?.event as Extension | undefined);
|
handleExtension(bridgeEvent.data?.event as Extension | undefined);
|
||||||
} else {
|
} else {
|
||||||
|
if (
|
||||||
|
bridgeEvent.type === "agent_state" &&
|
||||||
|
typeof bridgeEvent.data?.state === "string"
|
||||||
|
) {
|
||||||
|
setDirectories((current) =>
|
||||||
|
current.map((directory) =>
|
||||||
|
directory.agentId === bridgeEvent.agentId
|
||||||
|
? { ...directory, state: bridgeEvent.data?.state as string }
|
||||||
|
: directory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
updateWorkProgress(bridgeEvent);
|
updateWorkProgress(bridgeEvent);
|
||||||
if (selectedId) void loadAgent(selectedId);
|
if (selectedId) void loadAgent(selectedId);
|
||||||
}
|
}
|
||||||
@@ -551,7 +617,7 @@ function App() {
|
|||||||
setStatus(
|
setStatus(
|
||||||
event.payload.message ?? "Bridge connection lost; reconnecting…",
|
event.payload.message ?? "Bridge connection lost; reconnecting…",
|
||||||
);
|
);
|
||||||
window.setTimeout(() => void refreshAgents(), 500);
|
scheduleBridgeReconnect();
|
||||||
}).then((stop) => {
|
}).then((stop) => {
|
||||||
unlistenError = stop;
|
unlistenError = stop;
|
||||||
});
|
});
|
||||||
@@ -580,7 +646,58 @@ function App() {
|
|||||||
else await activateWorktree(directory.worktreePath);
|
else await activateWorktree(directory.worktreePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startNewSession() {
|
function handleDirectoryTabKeyDown(
|
||||||
|
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||||
|
index: number,
|
||||||
|
) {
|
||||||
|
let nextIndex: number | undefined;
|
||||||
|
if (event.key === "ArrowRight")
|
||||||
|
nextIndex = (index + 1) % directories.length;
|
||||||
|
else if (event.key === "ArrowLeft")
|
||||||
|
nextIndex = (index - 1 + directories.length) % directories.length;
|
||||||
|
else if (event.key === "Home") nextIndex = 0;
|
||||||
|
else if (event.key === "End") nextIndex = directories.length - 1;
|
||||||
|
if (nextIndex === undefined) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const nextDirectory = directories[nextIndex];
|
||||||
|
directoryTabRefs.current[nextDirectory.worktreePath]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forgetDirectory(directory: Directory) {
|
||||||
|
if (directory.isHome) return;
|
||||||
|
if (directory.state === "streaming") {
|
||||||
|
setStatus("Wait for Pi to finish before forgetting this directory");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Forget ${directory.worktreePath}? Its saved sessions will remain available when you add it again.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const closingIndex = directories.findIndex(
|
||||||
|
(candidate) => candidate.worktreePath === directory.worktreePath,
|
||||||
|
);
|
||||||
|
const nextFocusPath =
|
||||||
|
directories[closingIndex + 1]?.worktreePath ??
|
||||||
|
directories[closingIndex - 1]?.worktreePath;
|
||||||
|
await invoke("forget_directory", {
|
||||||
|
worktreePath: directory.worktreePath,
|
||||||
|
});
|
||||||
|
const wasSelected = directory.agentId === selectedId;
|
||||||
|
if (wasSelected) localStorage.removeItem(selectedPathKey);
|
||||||
|
await refreshAgents(wasSelected ? "" : selected?.worktreePath);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (nextFocusPath) directoryTabRefs.current[nextFocusPath]?.focus();
|
||||||
|
});
|
||||||
|
setStatus(`Forgot ${worktreeLabel(directory.worktreePath)}`);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startNewSession(successMessage = "Started a new Pi session") {
|
||||||
if (!selectedId) return;
|
if (!selectedId) return;
|
||||||
try {
|
try {
|
||||||
const response = await invoke<{ data?: { cancelled?: boolean } }>(
|
const response = await invoke<{ data?: { cancelled?: boolean } }>(
|
||||||
@@ -594,12 +711,34 @@ function App() {
|
|||||||
await loadAgent(selectedId, true);
|
await loadAgent(selectedId, true);
|
||||||
await loadSessions(selectedId);
|
await loadSessions(selectedId);
|
||||||
setCommandFollowUp(undefined);
|
setCommandFollowUp(undefined);
|
||||||
setStatus("Started a new Pi session");
|
setStatus(successMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(String(error));
|
setStatus(String(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function closeCurrentSession() {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Close the current session and start a new one? The current history remains available through /resume.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await startNewSession(
|
||||||
|
"Closed the current session. Use /resume to reopen it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartPi() {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Restart Pi for the selected directory? Any in-progress response will be interrupted.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await invokeAgent("restart");
|
||||||
|
}
|
||||||
|
|
||||||
async function switchSession(session: Session) {
|
async function switchSession(session: Session) {
|
||||||
if (!selectedId || session.isCurrent) return;
|
if (!selectedId || session.isCurrent) return;
|
||||||
try {
|
try {
|
||||||
@@ -649,10 +788,9 @@ function App() {
|
|||||||
const result = await invoke<{ agent: Agent }>("select_worktree", {
|
const result = await invoke<{ agent: Agent }>("select_worktree", {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
});
|
});
|
||||||
const paths = [...new Set([...rememberedPaths(), worktreePath])];
|
|
||||||
localStorage.setItem(rememberedKey, JSON.stringify(paths));
|
|
||||||
localStorage.setItem(selectedPathKey, worktreePath);
|
localStorage.setItem(selectedPathKey, worktreePath);
|
||||||
setFolderPath("");
|
setFolderPath("");
|
||||||
|
setAddingDirectory(false);
|
||||||
setSelectedId(result.agent.id);
|
setSelectedId(result.agent.id);
|
||||||
await refreshAgents(worktreePath);
|
await refreshAgents(worktreePath);
|
||||||
setView("conversation");
|
setView("conversation");
|
||||||
@@ -724,17 +862,25 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key !== "Escape") return;
|
||||||
event.preventDefault();
|
if (pendingExtension) return;
|
||||||
event.stopPropagation();
|
event.preventDefault();
|
||||||
void getCurrentWindow()
|
event.stopPropagation();
|
||||||
.hide()
|
if (commandFollowUp) {
|
||||||
.catch((error) => setStatus(String(error)));
|
setCommandFollowUp(undefined);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (addingDirectory) {
|
||||||
|
setAddingDirectory(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void getCurrentWindow()
|
||||||
|
.hide()
|
||||||
|
.catch((error) => setStatus(String(error)));
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeydown, true);
|
window.addEventListener("keydown", handleKeydown, true);
|
||||||
return () => window.removeEventListener("keydown", handleKeydown, true);
|
return () => window.removeEventListener("keydown", handleKeydown, true);
|
||||||
}, []);
|
}, [addingDirectory, commandFollowUp, pendingExtension]);
|
||||||
|
|
||||||
async function respondToExtension(response: Record<string, unknown>) {
|
async function respondToExtension(response: Record<string, unknown>) {
|
||||||
if (!selectedId || !pendingExtension?.id) return;
|
if (!selectedId || !pendingExtension?.id) return;
|
||||||
@@ -751,6 +897,37 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingExtension) return;
|
||||||
|
const dialog = extensionRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const controls = focusableExtensionControls(dialog);
|
||||||
|
const initial =
|
||||||
|
dialog.querySelector<HTMLElement>("[data-initial-focus]") ?? controls[0];
|
||||||
|
initial?.focus();
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
void respondToExtension({ cancelled: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const focusable = focusableExtensionControls(dialog);
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (!first || !last) return;
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeydown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeydown);
|
||||||
|
}, [pendingExtension]);
|
||||||
|
|
||||||
const extensionMethod = pendingExtension?.method ?? "";
|
const extensionMethod = pendingExtension?.method ?? "";
|
||||||
const workLabel =
|
const workLabel =
|
||||||
workProgress.phase === "recovering"
|
workProgress.phase === "recovering"
|
||||||
@@ -789,10 +966,13 @@ function App() {
|
|||||||
{workLabel}
|
{workLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-row" role="status">
|
<div className="status-row">
|
||||||
<span title={status}>{status}</span>
|
<span role="status" aria-atomic="true" title={status}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
{contextWindow && (
|
{contextWindow && (
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
className="status-metric"
|
className="status-metric"
|
||||||
title={`Current context: ${formatTokens(contextTokens)} / ${formatTokens(contextWindow)} tokens`}
|
title={`Current context: ${formatTokens(contextTokens)} / ${formatTokens(contextWindow)} tokens`}
|
||||||
>
|
>
|
||||||
@@ -802,6 +982,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{stats.tokens?.total !== undefined && (
|
{stats.tokens?.total !== undefined && (
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
className="status-metric"
|
className="status-metric"
|
||||||
title={`Session tokens — input: ${formatTokens(stats.tokens.input)}, output: ${formatTokens(stats.tokens.output)}, cache read: ${formatTokens(stats.tokens.cacheRead)}, cache write: ${formatTokens(stats.tokens.cacheWrite)}`}
|
title={`Session tokens — input: ${formatTokens(stats.tokens.input)}, output: ${formatTokens(stats.tokens.output)}, cache read: ${formatTokens(stats.tokens.cacheRead)}, cache write: ${formatTokens(stats.tokens.cacheWrite)}`}
|
||||||
>
|
>
|
||||||
@@ -819,6 +1000,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
aria-controls="directory-workspace"
|
||||||
|
aria-expanded={view === "settings"}
|
||||||
className="quiet compact-button"
|
className="quiet compact-button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setView(view === "conversation" ? "settings" : "conversation")
|
setView(view === "conversation" ? "settings" : "conversation")
|
||||||
@@ -828,23 +1011,86 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="agents" aria-label="Managed directories">
|
<section className="directory-tabs" aria-label="Managed directories">
|
||||||
<span className="agent-caption">Directories</span>
|
<div
|
||||||
{directories.map((directory) => (
|
aria-label="Managed directories"
|
||||||
<button
|
className="directory-tab-list"
|
||||||
className={
|
role="tablist"
|
||||||
directory.agentId === selectedId ? "agent selected" : "agent"
|
>
|
||||||
}
|
<span className="agent-caption">Directories</span>
|
||||||
key={directory.worktreePath}
|
{directories.map((directory, index) => {
|
||||||
title={directory.worktreePath}
|
const isSelected = directory.agentId === selectedId;
|
||||||
onClick={() => void chooseDirectory(directory)}
|
return (
|
||||||
|
<div
|
||||||
|
className="directory-tab"
|
||||||
|
key={directory.worktreePath}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-controls="directory-workspace"
|
||||||
|
aria-label={`${worktreeLabel(directory.worktreePath) ?? directory.worktreePath}, ${directory.state}${directory.isHome ? ", home directory" : ""}`}
|
||||||
|
aria-selected={directory.agentId === selectedId}
|
||||||
|
className={
|
||||||
|
isSelected
|
||||||
|
? "directory-tab-button selected"
|
||||||
|
: "directory-tab-button"
|
||||||
|
}
|
||||||
|
id={`directory-tab-${index}`}
|
||||||
|
onClick={() => void chooseDirectory(directory)}
|
||||||
|
onKeyDown={(event) => handleDirectoryTabKeyDown(event, index)}
|
||||||
|
ref={(element) => {
|
||||||
|
directoryTabRefs.current[directory.worktreePath] = element;
|
||||||
|
}}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={isSelected || (!selectedId && index === 0) ? 0 : -1}
|
||||||
|
title={directory.worktreePath}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{worktreeLabel(directory.worktreePath) ??
|
||||||
|
directory.worktreePath}
|
||||||
|
</span>
|
||||||
|
<small>{directory.state}</small>
|
||||||
|
</button>
|
||||||
|
{!directory.isHome && (
|
||||||
|
<button
|
||||||
|
aria-label={`Forget ${directory.worktreePath}`}
|
||||||
|
className="directory-close"
|
||||||
|
disabled={directory.state === "streaming"}
|
||||||
|
title={`Forget ${directory.worktreePath}`}
|
||||||
|
onClick={() => void forgetDirectory(directory)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-expanded={addingDirectory}
|
||||||
|
className="quiet directory-add-toggle"
|
||||||
|
onClick={() => setAddingDirectory((current) => !current)}
|
||||||
|
>
|
||||||
|
{addingDirectory ? "Cancel" : "Add directory"}
|
||||||
|
</button>
|
||||||
|
{addingDirectory && (
|
||||||
|
<form
|
||||||
|
className="directory-add"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void addFolder();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<input
|
||||||
{worktreeLabel(directory.worktreePath) ?? directory.worktreePath}
|
autoFocus
|
||||||
</span>
|
value={folderPath}
|
||||||
<small>{directory.state}</small>
|
onChange={(event) => setFolderPath(event.currentTarget.value)}
|
||||||
</button>
|
placeholder="/absolute/path/to/project"
|
||||||
))}
|
aria-label="Directory path"
|
||||||
|
/>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{extensionNotifications.length > 0 && (
|
{extensionNotifications.length > 0 && (
|
||||||
@@ -853,6 +1099,7 @@ function App() {
|
|||||||
<div
|
<div
|
||||||
className={`notification ${notice.notifyType ?? "info"}`}
|
className={`notification ${notice.notifyType ?? "info"}`}
|
||||||
key={`${notice.id ?? "notice"}-${index}`}
|
key={`${notice.id ?? "notice"}-${index}`}
|
||||||
|
role={notice.notifyType === "error" ? "alert" : "status"}
|
||||||
>
|
>
|
||||||
{notice.message ?? "Extension notification"}
|
{notice.message ?? "Extension notification"}
|
||||||
</div>
|
</div>
|
||||||
@@ -861,32 +1108,13 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view === "settings" ? (
|
{view === "settings" ? (
|
||||||
<section className="settings">
|
<section
|
||||||
<h2>Agents and session</h2>
|
aria-labelledby={selectedDirectoryTabId}
|
||||||
<div className="folder-form">
|
className="settings"
|
||||||
<input
|
id="directory-workspace"
|
||||||
value={folderPath}
|
role="tabpanel"
|
||||||
onChange={(event) => setFolderPath(event.currentTarget.value)}
|
>
|
||||||
placeholder="/absolute/path/to/project"
|
<h2>Session controls</h2>
|
||||||
/>
|
|
||||||
<button onClick={() => void addFolder()}>Add folder</button>
|
|
||||||
</div>
|
|
||||||
<div className="remembered">
|
|
||||||
<p className="muted">Remembered folders</p>
|
|
||||||
{rememberedPaths().length ? (
|
|
||||||
rememberedPaths().map((path) => (
|
|
||||||
<button
|
|
||||||
className="quiet"
|
|
||||||
key={path}
|
|
||||||
onClick={() => void activateWorktree(path)}
|
|
||||||
>
|
|
||||||
{path}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="muted">Home only</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{state.sessionName ?? state.sessionId ?? "Session"} ·{" "}
|
{state.sessionName ?? state.sessionId ?? "Session"} ·{" "}
|
||||||
{state.messageCount ?? 0} messages ·{" "}
|
{state.messageCount ?? 0} messages ·{" "}
|
||||||
@@ -904,17 +1132,29 @@ function App() {
|
|||||||
"Open a directory to view its sessions"}
|
"Open a directory to view its sessions"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="session-actions">
|
||||||
disabled={!selectedId || state.isStreaming}
|
<button
|
||||||
onClick={() => void startNewSession()}
|
aria-label="Close current session and start a new session"
|
||||||
>
|
className="quiet"
|
||||||
New session
|
disabled={!selectedId || state.isStreaming}
|
||||||
</button>
|
onClick={() => void closeCurrentSession()}
|
||||||
|
>
|
||||||
|
Close current
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={!selectedId || state.isStreaming}
|
||||||
|
onClick={() => void startNewSession()}
|
||||||
|
>
|
||||||
|
New session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{sessions.length ? (
|
{sessions.length ? (
|
||||||
<div className="session-list">
|
<div className="session-list">
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<button
|
<button
|
||||||
|
aria-current={session.isCurrent ? "page" : undefined}
|
||||||
|
aria-label={`${session.name ?? session.firstMessage ?? "Untitled session"}${session.isCurrent ? ", current session" : `, ${session.messageCount} messages`}`}
|
||||||
className={
|
className={
|
||||||
session.isCurrent ? "session current" : "session quiet"
|
session.isCurrent ? "session current" : "session quiet"
|
||||||
}
|
}
|
||||||
@@ -944,8 +1184,9 @@ function App() {
|
|||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-label="Restart Pi for the selected directory"
|
||||||
className="quiet danger"
|
className="quiet danger"
|
||||||
onClick={() => void invokeAgent("restart")}
|
onClick={() => void restartPi()}
|
||||||
>
|
>
|
||||||
Restart Pi
|
Restart Pi
|
||||||
</button>
|
</button>
|
||||||
@@ -953,7 +1194,12 @@ function App() {
|
|||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="workflow">
|
<div
|
||||||
|
aria-labelledby={selectedDirectoryTabId}
|
||||||
|
className="workflow"
|
||||||
|
id="directory-workspace"
|
||||||
|
role="tabpanel"
|
||||||
|
>
|
||||||
<div className="workflow-main">
|
<div className="workflow-main">
|
||||||
<section
|
<section
|
||||||
className={`transcript ${pendingExtension ? "with-extension" : ""} ${workProgress.phase}`}
|
className={`transcript ${pendingExtension ? "with-extension" : ""} ${workProgress.phase}`}
|
||||||
@@ -1227,61 +1473,79 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{pendingExtension && (
|
{pendingExtension && (
|
||||||
<section className="extension" aria-label="Pi extension request">
|
<div className="extension-backdrop">
|
||||||
<h2>{pendingExtension.title ?? "Pi extension request"}</h2>
|
<section
|
||||||
<p>{pendingExtension.message ?? "Choose a response."}</p>
|
className="extension"
|
||||||
{extensionMethod === "select" &&
|
ref={extensionRef}
|
||||||
pendingExtension.options?.map((option) => (
|
role="dialog"
|
||||||
<button
|
aria-modal="true"
|
||||||
key={option}
|
aria-labelledby="extension-title"
|
||||||
onClick={() => void respondToExtension({ value: option })}
|
aria-describedby="extension-description"
|
||||||
>
|
|
||||||
{option}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{extensionMethod === "confirm" && (
|
|
||||||
<div className="composer-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => void respondToExtension({ confirmed: true })}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="quiet"
|
|
||||||
onClick={() => void respondToExtension({ confirmed: false })}
|
|
||||||
>
|
|
||||||
Decline
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(extensionMethod === "input" || extensionMethod === "editor") && (
|
|
||||||
<>
|
|
||||||
<textarea
|
|
||||||
value={extensionValue}
|
|
||||||
onChange={(event) =>
|
|
||||||
setExtensionValue(event.currentTarget.value)
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
pendingExtension.placeholder ?? "Extension response"
|
|
||||||
}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
void respondToExtension({ value: extensionValue })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="quiet"
|
|
||||||
onClick={() => void respondToExtension({ cancelled: true })}
|
|
||||||
>
|
>
|
||||||
Cancel request
|
<h2 id="extension-title">
|
||||||
</button>
|
{pendingExtension.title ?? "Pi extension request"}
|
||||||
</section>
|
</h2>
|
||||||
|
<p id="extension-description">
|
||||||
|
{pendingExtension.message ?? "Choose a response."}
|
||||||
|
</p>
|
||||||
|
{extensionMethod === "select" &&
|
||||||
|
pendingExtension.options?.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
onClick={() => void respondToExtension({ value: option })}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{extensionMethod === "confirm" && (
|
||||||
|
<div className="composer-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => void respondToExtension({ confirmed: true })}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="quiet"
|
||||||
|
onClick={() => void respondToExtension({ confirmed: false })}
|
||||||
|
>
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(extensionMethod === "input" || extensionMethod === "editor") && (
|
||||||
|
<>
|
||||||
|
<label className="sr-only" htmlFor="extension-value">
|
||||||
|
Extension response
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="extension-value"
|
||||||
|
value={extensionValue}
|
||||||
|
onChange={(event) =>
|
||||||
|
setExtensionValue(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
pendingExtension.placeholder ?? "Extension response"
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
void respondToExtension({ value: extensionValue })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
data-initial-focus
|
||||||
|
className="quiet"
|
||||||
|
onClick={() => void respondToExtension({ cancelled: true })}
|
||||||
|
>
|
||||||
|
Cancel request
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user