From 0949c6f0d78d031e4ca388fdb46fcd9ecc16975d Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 31 Aug 2026 17:35:07 +0200 Subject: [PATCH] refactor!: remove redundant compatibility layers Use native browser and Tauri behavior instead of custom selector, dialog, and window-state implementations. Remove obsolete bridge, relay, asset, and dependency paths to keep the supported surface small. BREAKING CHANGE: The Noctalia relay no longer accepts --agent, and the Tauri host no longer exposes the legacy load_agent command. --- README.md | 2 +- src/bridge/agent-registry.js | 3 +- src/bridge/daemon.js | 10 +- src/bridge/runtime.js | 2 - src/bridge/unix-server.js | 2 - src/bridge/workspace-store.js | 14 +- src/client/noctalia-relay-cli.js | 24 +- src/client/noctalia-relay.js | 73 +----- test/daemon-and-relay.test.js | 33 --- test/noctalia-relay.test.js | 13 + test/quick-pane-ui.test.js | 26 +- test/tauri-ui.test.js | 5 +- ui/index.html | 3 +- ui/package-lock.json | 10 - ui/package.json | 1 - ui/public/tauri.svg | 6 - ui/public/vite.svg | 1 - ui/src-tauri/src/bridge.rs | 157 +---------- ui/src-tauri/src/lib.rs | 274 ++++++-------------- ui/src/App.css | 17 +- ui/src/App.test.tsx | 7 +- ui/src/assets/react.svg | 1 - ui/src/components/CommandFormDialog.tsx | 215 +++++++-------- ui/src/components/Dropdown.css.test.ts | 18 +- ui/src/components/ExtensionDialog.tsx | 218 +++++++--------- ui/src/components/SessionPicker.tsx | 137 ++++------ ui/src/components/ThinkingSelector.css | 19 +- ui/src/components/ThinkingSelector.test.tsx | 177 ++----------- ui/src/components/ThinkingSelector.tsx | 269 +++---------------- ui/src/components/components.test.tsx | 36 +-- ui/src/quick-pane/QuickPane.test.tsx | 34 +-- ui/src/quick-pane/QuickPane.tsx | 1 - ui/src/quick-pane/quick-pane.css | 10 +- ui/src/test-setup.ts | 10 + ui/src/thinking-parity.test.tsx | 17 +- ui/src/workspace/useWorkspace.test.tsx | 3 - ui/src/workspace/useWorkspace.ts | 3 - 37 files changed, 510 insertions(+), 1341 deletions(-) delete mode 100644 test/daemon-and-relay.test.js create mode 100644 test/noctalia-relay.test.js delete mode 100644 ui/public/tauri.svg delete mode 100644 ui/public/vite.svg delete mode 100644 ui/src/assets/react.svg diff --git a/README.md b/README.md index f8bd473..7edb3be 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The request payload is the local protocol JSON. See [`DESIGN.md`](DESIGN.md) for node src/client/noctalia-relay-cli.js --socket "$PI_STATUS_BRIDGE_SOCKET" ``` -The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests. `--agent ` retains the former agent-scoped relay only for external compatibility callers. +The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests. ## Keep the bridge running in the background (recommended) diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js index b167f24..507d65e 100644 --- a/src/bridge/agent-registry.js +++ b/src/bridge/agent-registry.js @@ -1799,7 +1799,7 @@ export function createAgentRegistry({ async stop() { stopping = true; while (pendingOpenOperations.size > 0) - await Promise.allSettled([...pendingOpenOperations]); + await Promise.allSettled(pendingOpenOperations); const runtimes = [...runtimesByRuntimeId.values()]; await Promise.all(runtimes.map((runtime) => runtime.operationQueue)); await Promise.all( @@ -1815,5 +1815,4 @@ export function createAgentRegistry({ }; } -export const sessionDirectoryMode = SESSION_DIRECTORY_MODE; export const sessionDirectoryPath = sessionDirectoryFor; diff --git a/src/bridge/daemon.js b/src/bridge/daemon.js index 41e2e02..e5b63b5 100644 --- a/src/bridge/daemon.js +++ b/src/bridge/daemon.js @@ -1,20 +1,14 @@ import { startBridgeService } from "./service.js"; -export async function startBridgeDaemon(options) { - return startBridgeService(options); -} - export async function runBridgeDaemon(options) { - const daemon = await startBridgeDaemon(options); + const daemon = await startBridgeService(options); let stopping = false; const stop = async () => { if (stopping) return; stopping = true; await daemon.close(); }; - process.stdout.write( - `${JSON.stringify({ socketPath: daemon.socketPath })}\n`, - ); + process.stdout.write(`${JSON.stringify({ socketPath: daemon.socketPath })}\n`); await new Promise((resolve) => { const shutdown = async () => { await stop(); diff --git a/src/bridge/runtime.js b/src/bridge/runtime.js index 4ac3b84..60e2ca6 100644 --- a/src/bridge/runtime.js +++ b/src/bridge/runtime.js @@ -66,5 +66,3 @@ export async function startBridgeServer({ throw error; } } - -export const runtimeDirectoryMode = RUNTIME_DIRECTORY_MODE; diff --git a/src/bridge/unix-server.js b/src/bridge/unix-server.js index 131ce70..6132afa 100644 --- a/src/bridge/unix-server.js +++ b/src/bridge/unix-server.js @@ -163,5 +163,3 @@ export async function startUnixSocketServer({ }, }; } - -export const socketMode = OWNER_ONLY_MODE; diff --git a/src/bridge/workspace-store.js b/src/bridge/workspace-store.js index feefdbd..dbae018 100644 --- a/src/bridge/workspace-store.js +++ b/src/bridge/workspace-store.js @@ -41,8 +41,7 @@ function normalizeManifest(value) { (value.directories !== undefined && (!Array.isArray(value.directories) || !value.directories.every( - (directory) => - typeof directory === "string" && path.isAbsolute(directory), + (directory) => typeof directory === "string" && path.isAbsolute(directory), ))) ) throw new Error("workspace manifest has an unsupported or invalid shape"); @@ -54,9 +53,7 @@ function normalizeManifest(value) { for (const runtime of value.runtimes) directories.add(runtime.worktreePath); for (const runtime of value.runtimes) { if (runtimeIds.has(runtime.runtimeId)) - throw new Error( - `workspace manifest repeats runtime ${runtime.runtimeId}`, - ); + throw new Error(`workspace manifest repeats runtime ${runtime.runtimeId}`); runtimeIds.add(runtime.runtimeId); if (runtime.sessionPath) { if (sessionPaths.has(runtime.sessionPath)) @@ -85,9 +82,7 @@ export function createWorkspaceStore(sessionRoot) { await chmod(sessionRoot, DIRECTORY_MODE); try { return { - manifest: normalizeManifest( - JSON.parse(await readFile(filePath, "utf8")), - ), + manifest: normalizeManifest(JSON.parse(await readFile(filePath, "utf8"))), migrating: false, }; } catch (error) { @@ -148,6 +143,3 @@ export function createWorkspaceStore(sessionRoot) { return { filePath, load, save }; } - -export const workspaceManifestVersion = WORKSPACE_VERSION; -export const workspaceManifestFile = WORKSPACE_FILE; diff --git a/src/client/noctalia-relay-cli.js b/src/client/noctalia-relay-cli.js index 91ca147..4d61c62 100755 --- a/src/client/noctalia-relay-cli.js +++ b/src/client/noctalia-relay-cli.js @@ -10,23 +10,17 @@ function flag(args, name) { async function run() { const args = process.argv.slice(2); - const socketPath = flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET; - const agentId = flag(args, "--agent"); + const socketPath = + flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET; if (!socketPath) - throw new Error("Usage: pi-status-bridge-noctalia-relay --socket [--agent ]"); + throw new Error("Usage: pi-status-bridge-noctalia-relay --socket "); const client = await connectLocalClient({ socketPath }); - let agent; - if (agentId) { - const agents = await client.request("list_agents"); - agent = agents.agents.find((candidate) => candidate.id === agentId); - if (!agent) throw new Error(`unknown agent: ${agentId}`); - } const relay = createNoctaliaStateRelay({ client, - ...(agent ? { agent } : {}), - onState: (state) => void publishNoctaliaState(state).catch((error) => - process.stderr.write(`${error.message}\n`), - ), + onState: (state) => + void publishNoctaliaState(state).catch((error) => + process.stderr.write(`${error.message}\n`), + ), }); await relay.start(); await new Promise((resolve) => { @@ -39,6 +33,8 @@ async function run() { } run().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`); + process.stderr.write( + `${error instanceof Error ? error.message : "Noctalia relay failed"}\n`, + ); process.exitCode = 1; }); diff --git a/src/client/noctalia-relay.js b/src/client/noctalia-relay.js index 6190739..9bb9e4b 100644 --- a/src/client/noctalia-relay.js +++ b/src/client/noctalia-relay.js @@ -1,5 +1,3 @@ -import path from "node:path"; - function detailForSummary(summary) { return [ `${summary.openCount} open`, @@ -30,75 +28,30 @@ function summaryState(summary) { }; } -function legacyDetail(state) { - switch (state) { - case "streaming": return "Streaming"; - case "recovering": return "Recovering Pi session"; - case "failed": return "Recovery failed"; - case "error": return "Bridge error"; - default: return "Idle"; - } -} - -/** - * Relays workspace-wide presentation state to Noctalia. Passing `agent` keeps - * the former agent-scoped behavior for external compatibility callers only. - */ -export function createNoctaliaStateRelay({ client, agent, onState, pollMs = 2_000 }) { +export function createNoctaliaStateRelay({ client, onState, pollMs = 2_000 }) { if (!client || typeof client.request !== "function") throw new TypeError("client must support request"); - if (agent && (!agent.id || !agent.worktreePath)) - throw new TypeError("agent id and worktreePath are required"); - if (!agent && (!Number.isSafeInteger(pollMs) || pollMs < 250)) + if (!Number.isSafeInteger(pollMs) || pollMs < 250) throw new TypeError("pollMs must be an integer of at least 250ms"); - if (typeof onState !== "function") throw new TypeError("onState must be a function"); + if (typeof onState !== "function") + throw new TypeError("onState must be a function"); - let unsubscribe = () => {}; let timer; let stopped = false; - const publishLegacy = (state, attentionCount) => onState({ - state, - projectLabel: path.basename(agent.worktreePath) || agent.worktreePath, - attentionCount, - detail: legacyDetail(state), - openCount: 1, - workingCount: state === "streaming" ? 1 : 0, - recoveringCount: state === "recovering" ? 1 : 0, - errorCount: ["error", "failed"].includes(state) ? 1 : 0, - }); + const refresh = async () => { + try { + const summary = await client.request("get_workspace_summary"); + if (!stopped) onState(summaryState(summary)); + } finally { + if (!stopped) timer = setTimeout(() => void refresh(), pollMs); + } + }; return { - async start() { - if (agent) { - if (typeof client.subscribe !== "function") - throw new TypeError("legacy agent relay requires client.subscribe"); - const response = await client.request("get_state", { agentId: agent.id }); - let state = response?.data?.isStreaming ? "streaming" : "idle"; - let attentionCount = 0; - publishLegacy(state, attentionCount); - unsubscribe = await client.subscribe(agent.id, 0, (event) => { - if (event.type === "agent_state" && typeof event.data?.state === "string") state = event.data.state; - if (event.type === "queue") attentionCount = (event.data?.event?.steering?.length ?? 0) + (event.data?.event?.followUp?.length ?? 0); - if (event.type === "extension_ui_request") attentionCount = Math.max(attentionCount, 1); - publishLegacy(state, attentionCount); - }); - return; - } - const refresh = async () => { - try { - const summary = await client.request("get_workspace_summary"); - if (!stopped) onState(summaryState(summary)); - } finally { - if (!stopped) timer = setTimeout(() => void refresh(), pollMs); - } - }; - await refresh(); - }, + start: refresh, stop() { stopped = true; clearTimeout(timer); - unsubscribe(); - unsubscribe = () => {}; }, }; } diff --git a/test/daemon-and-relay.test.js b/test/daemon-and-relay.test.js deleted file mode 100644 index be35291..0000000 --- a/test/daemon-and-relay.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdir, mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; -import { startBridgeDaemon } from "../src/bridge/daemon.js"; -import { createNoctaliaStateRelay } from "../src/client/noctalia-relay.js"; - -test("starts a bridge daemon around the local bridge service", async () => { - const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-daemon-")); - const home = join(root, "home"); - await mkdir(home); - const daemon = await startBridgeDaemon({ homeWorktree: home, runtimeDir: join(root, "runtime"), sessionRoot: join(root, "sessions"), startAdapter: () => ({ send: async () => ({ type: "response", success: true }), respondToExtension: () => {}, stop: async () => {} }) }); - try { assert.match(daemon.socketPath, /bridge\.sock$/); assert.equal(daemon.listAgents()[0].worktreePath, home); } - finally { await daemon.close(); } -}); - -test("relays aggregate workspace summary as compact Noctalia state", async () => { - const updates = []; - const relay = createNoctaliaStateRelay({ - client: { request: async (operation) => { assert.equal(operation, "get_workspace_summary"); return { openCount: 3, workingCount: 1, attentionCount: 2, recoveringCount: 0, errorCount: 1 }; } }, - onState: (state) => updates.push(state), pollMs: 250, - }); - await relay.start(); relay.stop(); - assert.deepEqual(updates, [{ state: "error", projectLabel: "3 sessions", attentionCount: 2, detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1 }]); -}); - -test("retains agent-scoped relay behavior only when explicitly requested", async () => { - let listener; const updates = []; - const relay = createNoctaliaStateRelay({ client: { request: async () => ({ data: { isStreaming: false } }), subscribe: async (_agentId, _cursor, callback) => { listener = callback; return () => {}; } }, agent: { id: "agent-1", worktreePath: "/worktrees/feature" }, onState: (state) => updates.push(state) }); - await relay.start(); listener({ type: "agent_state", data: { state: "streaming" } }); - assert.equal(updates.at(-1).state, "streaming"); assert.equal(updates.at(-1).projectLabel, "feature"); -}); diff --git a/test/noctalia-relay.test.js b/test/noctalia-relay.test.js new file mode 100644 index 0000000..66a2e4b --- /dev/null +++ b/test/noctalia-relay.test.js @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createNoctaliaStateRelay } from "../src/client/noctalia-relay.js"; + +test("relays aggregate workspace summary as compact Noctalia state", async () => { + const updates = []; + const relay = createNoctaliaStateRelay({ + client: { request: async (operation) => { assert.equal(operation, "get_workspace_summary"); return { openCount: 3, workingCount: 1, attentionCount: 2, recoveringCount: 0, errorCount: 1 }; } }, + onState: (state) => updates.push(state), pollMs: 250, + }); + await relay.start(); relay.stop(); + assert.deepEqual(updates, [{ state: "error", projectLabel: "3 sessions", attentionCount: 2, detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1 }]); +}); diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js index 9d68c75..c430baa 100644 --- a/test/quick-pane-ui.test.js +++ b/test/quick-pane-ui.test.js @@ -114,8 +114,14 @@ test("Quick Pane keeps slash-containing model IDs atomic and renews only on pane assert.match(pane, /provider: submittedModel\.provider/); assert.match(pane, /modelId: submittedModel\.modelId/); assert.doesNotMatch(pane, /selectedModel\.split/); - assert.match(pane, /listen\("quick-pane-shown"[\s\S]*?void renewChatOnShow\(\)/); - assert.match(pane, /onFocusChanged[\s\S]*?if \(event\.payload\) focusInput\(\);/); + assert.match( + pane, + /listen\("quick-pane-shown"[\s\S]*?void renewChatOnShow\(\)/, + ); + assert.match( + pane, + /onFocusChanged[\s\S]*?if \(event\.payload\) focusInput\(\);/, + ); assert.match(pane, /const CHAT_RENEWAL_MS = 180_000/); assert.match(pane, /Date\.now\(\) - lastActivity > CHAT_RENEWAL_MS/); assert.match(pane, /event\.ctrlKey && event\.key\.toLowerCase\(\) === "n"/); @@ -131,7 +137,7 @@ test("Quick Pane keeps slash-containing model IDs atomic and renews only on pane assert.match(catalog, /JSON\.parse\(value\)/); }); -test("Quick Pane dismiss and mutually exclusive window visibility stay native-serialized", async () => { +test("Quick Pane dismiss and mutually exclusive visibility use native window state", async () => { const [pane, nativeSource] = await Promise.all([ readFile( new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), @@ -151,32 +157,26 @@ test("Quick Pane dismiss and mutually exclusive window visibility stay native-se nativeSource, /dismiss_quick_pane,[\s\S]*create_session_runtime/, ); - assert.match( - nativeSource, - /struct WindowVisibility\(Mutex\)/, - ); + assert.doesNotMatch(nativeSource, /ManagedWindowVisibility|WindowVisibility/); + assert.match(nativeSource, /window\.is_visible\(\)/); const mainShow = nativeSource.slice( nativeSource.indexOf("fn apply_main_window_action"), nativeSource.indexOf("fn get_or_create_quick_pane"), ); const mainHideQuick = mainShow.indexOf("quick_pane.hide()"); - const mainClearQuick = mainShow.indexOf('visibility.hidden("quick-pane")'); const showMain = mainShow.indexOf("window.show()"); assert.ok(mainShow.indexOf('get_webview_window("quick-pane")') >= 0); assert.ok(mainHideQuick >= 0 && mainHideQuick < showMain); - assert.ok(mainClearQuick >= 0 && mainClearQuick < showMain); const quickShow = nativeSource.slice( nativeSource.indexOf("fn toggle_quick_pane"), nativeSource.indexOf("fn requested_new_worktree_argument"), ); const quickHideMain = quickShow.indexOf("main.hide()"); - const quickClearMain = quickShow.indexOf('visibility.hidden("main")'); const showQuick = quickShow.indexOf("window.show()"); assert.ok(quickShow.indexOf('get_webview_window("main")') >= 0); assert.ok(quickHideMain >= 0 && quickHideMain < showQuick); - assert.ok(quickClearMain >= 0 && quickClearMain < showQuick); }); test("native promotion owns visibility and Main consumes pending runtime after startup refresh", async () => { @@ -196,10 +196,6 @@ test("native promotion owns visibility and Main consumes pending runtime after s promotion.indexOf("quick_pane\n .hide()") < promotion.indexOf("window\n .show()"), ); - assert.ok( - promotion.indexOf('visibility.hidden("quick-pane")') < - promotion.indexOf("window\n .show()"), - ); assert.match(promotion, /\*pending = Some\(runtime_id\)/); assert.match(nativeSource, /fn get_pending_promoted_runtime/); assert.match(nativeSource, /fn consume_pending_promoted_runtime/); diff --git a/test/tauri-ui.test.js b/test/tauri-ui.test.js index 872ebb6..8f64902 100644 --- a/test/tauri-ui.test.js +++ b/test/tauri-ui.test.js @@ -73,8 +73,9 @@ test("preserves native session naming, compaction, composer, and extension respo assert.match(app, /respond_to_extension/); assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/); assert.match(conversation, /className="todos-pane"/); - assert.match(extension, /role="dialog"/); - assert.match(extension, /aria-modal="true"/); + assert.match(extension, / { diff --git a/ui/index.html b/ui/index.html index ff93803..219d430 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2,9 +2,8 @@ - - Tauri + React + Typescript + Pi Status UI diff --git a/ui/package-lock.json b/ui/package-lock.json index 5e3c1a7..a847cdf 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.2", - "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -1605,15 +1604,6 @@ "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@tauri-apps/plugin-opener": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", - "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/ui/package.json b/ui/package.json index 210bb9c..a601c4e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,7 +21,6 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.2", - "@tauri-apps/plugin-opener": "^2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/ui/public/tauri.svg b/ui/public/tauri.svg deleted file mode 100644 index 31b62c9..0000000 --- a/ui/public/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/ui/public/vite.svg b/ui/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/ui/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ui/src-tauri/src/bridge.rs b/ui/src-tauri/src/bridge.rs index 31a85bc..9af157a 100644 --- a/ui/src-tauri/src/bridge.rs +++ b/ui/src-tauri/src/bridge.rs @@ -1,7 +1,6 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{json, Value}; use std::env; -use std::path::Path; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -346,72 +345,11 @@ pub async fn new_session(socket_path: &str, agent_id: &str) -> Result Value { - let Some(path) = state.pointer("/data/sessionFile").and_then(Value::as_str) else { - return json!({ "data": { "messages": [] } }); - }; - let path = Path::new(path); - if !path.is_absolute() - || path.extension().and_then(|extension| extension.to_str()) != Some("jsonl") - { - return json!({ "data": { "messages": [] } }); - } - let messages = std::fs::read_to_string(path) - .ok() - .map(|content| { - content - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("message")) - .filter_map(|entry| entry.get("message").cloned()) - .collect::>() - }) - .unwrap_or_default(); - json!({ "data": { "messages": messages } }) -} - -fn compact_commands(value: Value) -> Value { - let commands = value - .pointer("/data/commands") - .and_then(Value::as_array) - .map(|commands| { - commands - .iter() - .map(|command| { - let mut compact = serde_json::Map::new(); - for field in ["name", "description"] { - if let Some(value) = command.get(field) { - compact.insert(field.to_owned(), value.clone()); - } - } - Value::Object(compact) - }) - .collect::>() - }) - .unwrap_or_default(); - json!({ "data": { "commands": commands } }) -} - -pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result { - // Older bridge daemons can still serve the conversation while they await a restart. - let stats = request(socket_path, "get_session_stats", Some(agent_id), None) - .await - .unwrap_or_else(|_| json!({ "data": {} })); - let (state, commands, models) = tokio::try_join!( - request(socket_path, "get_state", Some(agent_id), None), - request(socket_path, "get_commands", Some(agent_id), None), - request(socket_path, "get_available_models", Some(agent_id), None), - )?; - Ok(json!({ - "state": state, - "stats": stats, - "transcript": fallback_transcript(&state), - "commands": compact_commands(commands), - "models": models - })) -} - -pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) -> Result { +pub async fn submit_prompt( + socket_path: &str, + agent_id: &str, + message: &str, +) -> Result { request( socket_path, "submit_prompt", @@ -896,91 +834,6 @@ mod tests { std::fs::remove_file(path).expect("socket cleanup"); } - #[test] - fn reads_messages_from_the_saved_session_file_when_transcript_rpc_is_unavailable() { - let path = format!( - "{}/pi-status-ui-history-{}.jsonl", - env::temp_dir().display(), - std::process::id() - ); - std::fs::write( - &path, - concat!( - "{\"type\":\"session\",\"id\":\"session-1\"}\n", - "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}\n", - "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}\n" - ), - ) - .expect("session fixture"); - let transcript = fallback_transcript(&json!({ - "data": { "sessionFile": path } - })); - assert_eq!( - transcript["data"]["messages"], - json!([ - { "role": "user", "content": "Hello" }, - { "role": "assistant", "content": "Hi" } - ]) - ); - std::fs::remove_file(path).expect("session cleanup"); - } - - #[tokio::test] - async fn loads_state_history_commands_and_models_as_one_snapshot() { - let path = format!( - "{}/pi-status-ui-load-{}.sock", - env::temp_dir().display(), - std::process::id() - ); - let _ = std::fs::remove_file(&path); - let listener = UnixListener::bind(&path).expect("listener"); - let server = tokio::spawn(async move { - for _ in 0..4 { - let (stream, _) = listener.accept().await.expect("connection"); - tokio::spawn(async move { - let mut line = String::new(); - let mut reader = BufReader::new(stream); - reader.read_line(&mut line).await.expect("request"); - let request: Value = serde_json::from_str(&line).expect("JSON request"); - assert_eq!(request["agentId"], "agent-1"); - let result = match request["op"].as_str() { - Some("get_state") => json!({ "data": { "isStreaming": false } }), - Some("get_session_stats") => json!({ - "data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } } - }), - Some("get_commands") => json!({ "data": { "commands": [{ - "name": "resume", - "description": "Resume a saved session", - "sourceInfo": { "path": "/very/large/extension/metadata" } - }] } }), - Some("get_available_models") => json!({ "data": { "models": [] } }), - other => panic!("unexpected operation: {other:?}"), - }; - let mut response = serde_json::to_vec(&json!({ - "id": "tauri-ui", "ok": true, "result": result - })) - .expect("response JSON"); - response.push(b'\n'); - reader - .get_mut() - .write_all(&response) - .await - .expect("response"); - }); - } - }); - let snapshot = load_agent(&path, "agent-1").await.expect("snapshot"); - assert_eq!(snapshot["state"]["data"]["isStreaming"], false); - assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000); - assert_eq!(snapshot["transcript"]["data"]["messages"], json!([])); - assert_eq!( - snapshot["commands"]["data"]["commands"], - json!([{ "name": "resume", "description": "Resume a saved session" }]) - ); - server.await.expect("server succeeds"); - std::fs::remove_file(path).expect("socket cleanup"); - } - fn runtime(id: &str) -> Value { json!({ "runtimeId": id, diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs index 3ee974f..e8e3629 100644 --- a/ui/src-tauri/src/lib.rs +++ b/ui/src-tauri/src/lib.rs @@ -14,33 +14,6 @@ use tauri::{ }; struct LegacySubscription(Mutex>>); -#[derive(Debug, PartialEq, Eq)] -struct ManagedWindowVisibility { - main: bool, - quick_pane: bool, -} - -impl ManagedWindowVisibility { - fn main_shown(&mut self) { - self.main = true; - self.quick_pane = false; - } - - fn quick_pane_shown(&mut self) { - self.main = false; - self.quick_pane = true; - } - - fn hidden(&mut self, label: &str) { - if label == "main" { - self.main = false; - } else if label == "quick-pane" { - self.quick_pane = false; - } - } -} -// One mutex serializes cross-window transitions, avoiding nested visibility-lock ordering. -struct WindowVisibility(Mutex); struct PendingPromotedRuntime(Mutex>); struct WorkspaceSubscription { generation: Arc, @@ -131,22 +104,10 @@ fn apply_main_window_action(app: &AppHandle, args: &[String]) { return; } }; - let Some(state) = app.try_state::() else { - eprintln!("Window visibility state is unavailable"); - return; - }; - let mut visibility = state - .0 - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if created { - visibility.main = false; - } - match window_action(args, visibility.main) { + let visible = !created && window.is_visible().unwrap_or(false); + match window_action(args, visible) { WindowAction::Hide => { - if window.hide().is_ok() { - visibility.hidden("main"); - } + let _ = window.hide(); } WindowAction::ShowAndFocus => { if let Some(quick_pane) = app.get_webview_window("quick-pane") { @@ -155,9 +116,7 @@ fn apply_main_window_action(app: &AppHandle, args: &[String]) { return; } } - visibility.hidden("quick-pane"); if window.show().is_ok() { - visibility.main_shown(); let _ = window.unminimize(); let _ = window.set_focus(); } @@ -185,37 +144,24 @@ fn get_or_create_quick_pane(app: &AppHandle) -> Result<(WebviewWindow, bool), St fn show_main_for_promoted_runtime(app: &AppHandle, runtime_id: String) -> Result<(), String> { // A recreated Main starts hidden, so Quick is never visible with it. - let (window, created) = get_or_create_main_with_visibility(app, false)?; - let state = app - .try_state::() - .ok_or_else(|| "Window visibility state is unavailable".to_owned())?; + let (window, _) = get_or_create_main_with_visibility(app, false)?; let pending = app .try_state::() .ok_or_else(|| "Promoted runtime state is unavailable".to_owned())?; - let mut visibility = state - .0 - .lock() - .map_err(|_| "Could not update window visibility".to_owned())?; let mut pending = pending .0 .lock() .map_err(|_| "Could not queue promoted runtime".to_owned())?; - if created { - visibility.main = false; - } if let Some(quick_pane) = app.get_webview_window("quick-pane") { quick_pane .hide() .map_err(|error| format!("Could not hide Quick Pane before promotion: {error}"))?; } - visibility.hidden("quick-pane"); // Retain this on show failure; a later Main startup can consume it. *pending = Some(runtime_id); - visibility.main = false; window .show() .map_err(|error| format!("Could not show Main after hiding Quick Pane: {error}"))?; - visibility.main_shown(); let _ = window.unminimize(); let _ = window.set_focus(); Ok(()) @@ -229,22 +175,10 @@ fn toggle_quick_pane(app: &AppHandle) { return; } }; - let Some(state) = app.try_state::() else { - eprintln!("Window visibility state is unavailable"); - return; - }; - let mut visibility = state - .0 - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if created { - visibility.quick_pane = false; - } - match quick_pane_action(visibility.quick_pane) { + let visible = !created && window.is_visible().unwrap_or(false); + match quick_pane_action(visible) { WindowAction::Hide => { - if window.hide().is_ok() { - visibility.hidden("quick-pane"); - } + let _ = window.hide(); } WindowAction::ShowAndFocus => { if let Some(main) = app.get_webview_window("main") { @@ -253,9 +187,7 @@ fn toggle_quick_pane(app: &AppHandle) { return; } } - visibility.hidden("main"); if window.show().is_ok() { - visibility.quick_pane_shown(); let _ = window.unminimize(); let _ = window.set_focus(); let _ = app.emit("quick-pane-shown", ()); @@ -288,30 +220,52 @@ fn requested_new_worktree(app: &AppHandle, args: &[String]) -> Option { } fn launch_new_session(app: AppHandle, args: &[String]) { - let Some(worktree_path) = requested_new_worktree(&app, args) else { return }; + let Some(worktree_path) = requested_new_worktree(&app, args) else { + return; + }; tauri::async_runtime::spawn(async move { // Give a newly-created webview time to register its frontend listeners. tokio::time::sleep(Duration::from_millis(350)).await; - let _ = app.emit("workspace-new-session", json!({ - "phase": "starting", - "detail": "Starting a new Pi session…" - })); + let _ = app.emit( + "workspace-new-session", + json!({ + "phase": "starting", + "detail": "Starting a new Pi session…" + }), + ); match socket_path().map(|socket| (socket, worktree_path)) { - Ok((socket, worktree_path)) => match bridge::create_session_runtime(&socket, &worktree_path).await { - Ok(result) => { let _ = app.emit("workspace-new-session", json!({ - "phase": "ready", - "runtimeId": result.runtime.runtime_id, - "detail": "New session ready" - })); } - Err(error) => { let _ = app.emit("workspace-new-session", json!({ - "phase": "error", - "detail": format!("Could not start session: {error}") - })); } - }, - Err(error) => { let _ = app.emit("workspace-new-session", json!({ - "phase": "error", - "detail": error - })); } + Ok((socket, worktree_path)) => { + match bridge::create_session_runtime(&socket, &worktree_path).await { + Ok(result) => { + let _ = app.emit( + "workspace-new-session", + json!({ + "phase": "ready", + "runtimeId": result.runtime.runtime_id, + "detail": "New session ready" + }), + ); + } + Err(error) => { + let _ = app.emit( + "workspace-new-session", + json!({ + "phase": "error", + "detail": format!("Could not start session: {error}") + }), + ); + } + } + } + Err(error) => { + let _ = app.emit( + "workspace-new-session", + json!({ + "phase": "error", + "detail": error + }), + ); + } } }); } @@ -344,7 +298,9 @@ fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> { let quick_pane = app .get_webview_window("quick-pane") .ok_or_else(|| "Quick Pane window is unavailable".to_owned())?; - let scale_factor = quick_pane.scale_factor().map_err(|error| error.to_string())?; + let scale_factor = quick_pane + .scale_factor() + .map_err(|error| error.to_string())?; let current_size = quick_pane.inner_size().map_err(|error| error.to_string())?; quick_pane .set_size(LogicalSize::new( @@ -355,20 +311,11 @@ fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> { } #[tauri::command] -fn dismiss_quick_pane( - app: AppHandle, - visibility: State<'_, WindowVisibility>, -) -> Result<(), String> { - let quick_pane = app - .get_webview_window("quick-pane") - .ok_or_else(|| "Quick Pane window is unavailable".to_owned())?; - let mut visibility = visibility - .0 - .lock() - .map_err(|_| "Could not update window visibility".to_owned())?; - quick_pane.hide().map_err(|error| error.to_string())?; - visibility.hidden("quick-pane"); - Ok(()) +fn dismiss_quick_pane(app: AppHandle) -> Result<(), String> { + app.get_webview_window("quick-pane") + .ok_or_else(|| "Quick Pane window is unavailable".to_owned())? + .hide() + .map_err(|error| error.to_string()) } #[tauri::command] @@ -388,7 +335,10 @@ async fn promote_quick_runtime( ) -> Result { let result = bridge::promote_quick_runtime(&socket_path()?, &runtime_id).await?; show_main_for_promoted_runtime(&app, result.runtime.runtime_id.clone())?; - let _ = app.emit("workspace-promoted-runtime", json!({ "runtimeId": result.runtime.runtime_id })); + let _ = app.emit( + "workspace-promoted-runtime", + json!({ "runtimeId": result.runtime.runtime_id }), + ); Ok(result) } @@ -490,11 +440,6 @@ async fn forget_directory(worktree_path: String) -> Result { bridge::forget_directory(&socket_path()?, &worktree_path).await } -#[tauri::command] -async fn load_agent(agent_id: String) -> Result { - bridge::load_agent(&socket_path()?, &agent_id).await -} - #[tauri::command] async fn list_sessions(agent_id: String) -> Result { bridge::list_sessions(&socket_path()?, &agent_id).await @@ -557,7 +502,8 @@ async fn pi_rpc_command(agent_id: String, command: String, input: Value) -> Resu "pi_rpc_command", Some(&agent_id), Some(serde_json::json!({ "command": command, "input": input })), - ).await + ) + .await } #[tauri::command] @@ -665,10 +611,6 @@ pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .manage(LegacySubscription(Mutex::new(None))) - .manage(WindowVisibility(Mutex::new(ManagedWindowVisibility { - main: true, - quick_pane: false, - }))) .manage(PendingPromotedRuntime(Mutex::new(None))) .manage(WorkspaceSubscription { generation: Arc::new(bridge::SubscriptionGeneration::default()), @@ -686,17 +628,7 @@ pub fn run() { if should_hide_close_request(window.label()) { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); - if let Some(state) = window.app_handle().try_state::() { - let mut visibility = state - .0 - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if window.hide().is_ok() { - visibility.hidden(window.label()); - } - } else { - let _ = window.hide(); - } + let _ = window.hide(); } } }) @@ -732,7 +664,6 @@ pub fn run() { list_directories, select_worktree, forget_directory, - load_agent, list_sessions, switch_session, new_session, @@ -776,10 +707,26 @@ mod tests { #[test] fn new_session_uses_an_explicit_absolute_worktree() { - let args = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "/workspace".into()]; - assert_eq!(requested_new_worktree_argument(&args), Some("/workspace".into())); - let relative = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "workspace".into()]; - assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None); + let args = vec![ + "pi-status-ui".into(), + "--new".into(), + "--worktree".into(), + "/workspace".into(), + ]; + assert_eq!( + requested_new_worktree_argument(&args), + Some("/workspace".into()) + ); + let relative = vec![ + "pi-status-ui".into(), + "--new".into(), + "--worktree".into(), + "workspace".into(), + ]; + assert_eq!( + requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), + None + ); } #[test] @@ -797,42 +744,6 @@ mod tests { assert!(!should_hide_close_request("other")); } - #[test] - fn showing_main_clears_quick_pane_visibility() { - let mut visibility = ManagedWindowVisibility { - main: false, - quick_pane: true, - }; - - visibility.main_shown(); - - assert_eq!( - visibility, - ManagedWindowVisibility { - main: true, - quick_pane: false, - } - ); - } - - #[test] - fn showing_quick_pane_clears_main_visibility() { - let mut visibility = ManagedWindowVisibility { - main: true, - quick_pane: false, - }; - - visibility.quick_pane_shown(); - - assert_eq!( - visibility, - ManagedWindowVisibility { - main: false, - quick_pane: true, - } - ); - } - #[test] fn promoted_runtime_pending_id_clears_only_after_matching_acknowledgement() { let pending = PendingPromotedRuntime(Mutex::new(Some("runtime-a".to_owned()))); @@ -843,23 +754,6 @@ mod tests { assert_eq!(value.as_deref(), None); } - #[test] - fn dismissed_quick_pane_reopens_with_one_toggle() { - let mut visibility = ManagedWindowVisibility { - main: false, - quick_pane: true, - }; - - visibility.hidden("quick-pane"); - assert_eq!( - quick_pane_action(visibility.quick_pane), - WindowAction::ShowAndFocus - ); - visibility.quick_pane_shown(); - assert!(visibility.quick_pane); - assert!(!visibility.main); - } - #[test] fn main_recreation_uses_the_complete_configured_window() { let context: tauri::Context = tauri::generate_context!(); diff --git a/ui/src/App.css b/ui/src/App.css index 53319ef..d5ca057 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -1007,17 +1007,14 @@ select:focus-visible { flex: 0 0 auto; transform: scale(1.35); } -.modal-backdrop, -.extension-backdrop { - position: absolute; - z-index: 10; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; +dialog.session-picker::backdrop, +dialog.extension::backdrop { background: rgba(0, 0, 0, 0.62); } +dialog.session-picker:not([open]), +dialog.extension:not([open]) { + display: none; +} .session-picker, .extension { width: min(680px, 90vw); @@ -1025,10 +1022,12 @@ select:focus-visible { overflow: auto; display: grid; gap: 8px; + margin: auto; padding: 15px; border: 1px solid #b76f26; border-radius: 10px; background: #2d2e31; + color: #f1f3f4; } .session-picker { overflow: hidden; diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index e3edcdd..fde707d 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -190,10 +190,9 @@ describe("App command progress and errors", () => { await user.click( screen.getByRole("option", { name: /Claude.*anthropic\/claude\/sonnet/i }), ); - await user.click( - screen.getByRole("button", { name: "Default thinking Medium" }), - ); - await user.click(screen.getByRole("option", { name: "High" })); + fireEvent.change(screen.getByRole("combobox", { name: "Default thinking" }), { + target: { value: "high" }, + }); await user.click( screen.getByRole("button", { name: "Save Quick Pane settings" }), ); diff --git a/ui/src/assets/react.svg b/ui/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/ui/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ui/src/components/CommandFormDialog.tsx b/ui/src/components/CommandFormDialog.tsx index d9ea4d7..3cac914 100644 --- a/ui/src/components/CommandFormDialog.tsx +++ b/ui/src/components/CommandFormDialog.tsx @@ -1,16 +1,18 @@ import { useEffect, useRef, useState } from "react"; import type { RpcCommand, RpcField } from "../commands/rpc"; -const controls = (container: HTMLElement | null) => - Array.from( - container?.querySelectorAll("button, input, select, textarea") ?? - [], - ).filter((node) => !node.hasAttribute("disabled")); +type RpcInputValue = + | string + | boolean + | null + | RpcInputValue[] + | { [key: string]: RpcInputValue } + | undefined; -function valueFor(field: RpcField, value: string | boolean): unknown { +function valueFor(field: RpcField, value: string | boolean): RpcInputValue { if (field.kind === "boolean") return value === true; if (field.kind === "json") - return value ? JSON.parse(value as string) : undefined; + return value ? (JSON.parse(value as string) as RpcInputValue) : undefined; return value || undefined; } @@ -27,19 +29,17 @@ export function CommandFormDialog({ pending?: boolean; commandError?: string; }) { - const ref = useRef(null); - const opener = useRef(null); + const ref = useRef(null); const [values, setValues] = useState>({}); const [validationError, setValidationError] = useState(); useEffect(() => { if (!command) return; - opener.current = document.activeElement as HTMLElement | null; setValues({}); setValidationError(undefined); - const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus()); + const dialog = ref.current; + dialog?.showModal(); return () => { - cancelAnimationFrame(frame); - opener.current?.focus(); + if (dialog?.open) dialog.close(); }; }, [command]); if (!command) return null; @@ -63,109 +63,90 @@ export function CommandFormDialog({ } }; return ( -
-
{ - if (event.key === "Escape") { - event.preventDefault(); - onCancel(); - } - if (event.key === "Tab") { - const items = controls(ref.current), - first = items[0], - last = items[items.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(); - } - } - }} - > -

Pi RPC command

-

/{command.command}

-

{command.description}

- {fields.map((field) => ( -