refactor!: integrate quick pane simplification

Merge the validated feature branch after the Ctrl+N session shortcut landed on main.

BREAKING CHANGE: The Noctalia relay no longer accepts --agent, and the Tauri host no longer exposes the legacy load_agent command.
This commit is contained in:
2026-08-31 19:31:23 +02:00
37 changed files with 510 additions and 1341 deletions
+1 -1
View File
@@ -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" 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 <agent-id>` 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) ## Keep the bridge running in the background (recommended)
+1 -2
View File
@@ -1799,7 +1799,7 @@ export function createAgentRegistry({
async stop() { async stop() {
stopping = true; stopping = true;
while (pendingOpenOperations.size > 0) while (pendingOpenOperations.size > 0)
await Promise.allSettled([...pendingOpenOperations]); await Promise.allSettled(pendingOpenOperations);
const runtimes = [...runtimesByRuntimeId.values()]; const runtimes = [...runtimesByRuntimeId.values()];
await Promise.all(runtimes.map((runtime) => runtime.operationQueue)); await Promise.all(runtimes.map((runtime) => runtime.operationQueue));
await Promise.all( await Promise.all(
@@ -1815,5 +1815,4 @@ export function createAgentRegistry({
}; };
} }
export const sessionDirectoryMode = SESSION_DIRECTORY_MODE;
export const sessionDirectoryPath = sessionDirectoryFor; export const sessionDirectoryPath = sessionDirectoryFor;
+2 -8
View File
@@ -1,20 +1,14 @@
import { startBridgeService } from "./service.js"; import { startBridgeService } from "./service.js";
export async function startBridgeDaemon(options) {
return startBridgeService(options);
}
export async function runBridgeDaemon(options) { export async function runBridgeDaemon(options) {
const daemon = await startBridgeDaemon(options); const daemon = await startBridgeService(options);
let stopping = false; let stopping = false;
const stop = async () => { const stop = async () => {
if (stopping) return; if (stopping) return;
stopping = true; stopping = true;
await daemon.close(); await daemon.close();
}; };
process.stdout.write( process.stdout.write(`${JSON.stringify({ socketPath: daemon.socketPath })}\n`);
`${JSON.stringify({ socketPath: daemon.socketPath })}\n`,
);
await new Promise((resolve) => { await new Promise((resolve) => {
const shutdown = async () => { const shutdown = async () => {
await stop(); await stop();
-2
View File
@@ -66,5 +66,3 @@ export async function startBridgeServer({
throw error; throw error;
} }
} }
export const runtimeDirectoryMode = RUNTIME_DIRECTORY_MODE;
-2
View File
@@ -163,5 +163,3 @@ export async function startUnixSocketServer({
}, },
}; };
} }
export const socketMode = OWNER_ONLY_MODE;
+3 -11
View File
@@ -41,8 +41,7 @@ function normalizeManifest(value) {
(value.directories !== undefined && (value.directories !== undefined &&
(!Array.isArray(value.directories) || (!Array.isArray(value.directories) ||
!value.directories.every( !value.directories.every(
(directory) => (directory) => typeof directory === "string" && path.isAbsolute(directory),
typeof directory === "string" && path.isAbsolute(directory),
))) )))
) )
throw new Error("workspace manifest has an unsupported or invalid shape"); 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) directories.add(runtime.worktreePath);
for (const runtime of value.runtimes) { for (const runtime of value.runtimes) {
if (runtimeIds.has(runtime.runtimeId)) if (runtimeIds.has(runtime.runtimeId))
throw new Error( throw new Error(`workspace manifest repeats runtime ${runtime.runtimeId}`);
`workspace manifest repeats runtime ${runtime.runtimeId}`,
);
runtimeIds.add(runtime.runtimeId); runtimeIds.add(runtime.runtimeId);
if (runtime.sessionPath) { if (runtime.sessionPath) {
if (sessionPaths.has(runtime.sessionPath)) if (sessionPaths.has(runtime.sessionPath))
@@ -85,9 +82,7 @@ export function createWorkspaceStore(sessionRoot) {
await chmod(sessionRoot, DIRECTORY_MODE); await chmod(sessionRoot, DIRECTORY_MODE);
try { try {
return { return {
manifest: normalizeManifest( manifest: normalizeManifest(JSON.parse(await readFile(filePath, "utf8"))),
JSON.parse(await readFile(filePath, "utf8")),
),
migrating: false, migrating: false,
}; };
} catch (error) { } catch (error) {
@@ -148,6 +143,3 @@ export function createWorkspaceStore(sessionRoot) {
return { filePath, load, save }; return { filePath, load, save };
} }
export const workspaceManifestVersion = WORKSPACE_VERSION;
export const workspaceManifestFile = WORKSPACE_FILE;
+10 -14
View File
@@ -10,23 +10,17 @@ function flag(args, name) {
async function run() { async function run() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const socketPath = flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET; const socketPath =
const agentId = flag(args, "--agent"); flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
if (!socketPath) if (!socketPath)
throw new Error("Usage: pi-status-bridge-noctalia-relay --socket <path> [--agent <id>]"); throw new Error("Usage: pi-status-bridge-noctalia-relay --socket <path>");
const client = await connectLocalClient({ socketPath }); 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({ const relay = createNoctaliaStateRelay({
client, client,
...(agent ? { agent } : {}), onState: (state) =>
onState: (state) => void publishNoctaliaState(state).catch((error) => void publishNoctaliaState(state).catch((error) =>
process.stderr.write(`${error.message}\n`), process.stderr.write(`${error.message}\n`),
), ),
}); });
await relay.start(); await relay.start();
await new Promise((resolve) => { await new Promise((resolve) => {
@@ -39,6 +33,8 @@ async function run() {
} }
run().catch((error) => { 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; process.exitCode = 1;
}); });
+13 -60
View File
@@ -1,5 +1,3 @@
import path from "node:path";
function detailForSummary(summary) { function detailForSummary(summary) {
return [ return [
`${summary.openCount} open`, `${summary.openCount} open`,
@@ -30,75 +28,30 @@ function summaryState(summary) {
}; };
} }
function legacyDetail(state) { export function createNoctaliaStateRelay({ client, onState, pollMs = 2_000 }) {
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 }) {
if (!client || typeof client.request !== "function") if (!client || typeof client.request !== "function")
throw new TypeError("client must support request"); throw new TypeError("client must support request");
if (agent && (!agent.id || !agent.worktreePath)) if (!Number.isSafeInteger(pollMs) || pollMs < 250)
throw new TypeError("agent id and worktreePath are required");
if (!agent && (!Number.isSafeInteger(pollMs) || pollMs < 250))
throw new TypeError("pollMs must be an integer of at least 250ms"); 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 timer;
let stopped = false; let stopped = false;
const publishLegacy = (state, attentionCount) => onState({ const refresh = async () => {
state, try {
projectLabel: path.basename(agent.worktreePath) || agent.worktreePath, const summary = await client.request("get_workspace_summary");
attentionCount, if (!stopped) onState(summaryState(summary));
detail: legacyDetail(state), } finally {
openCount: 1, if (!stopped) timer = setTimeout(() => void refresh(), pollMs);
workingCount: state === "streaming" ? 1 : 0, }
recoveringCount: state === "recovering" ? 1 : 0, };
errorCount: ["error", "failed"].includes(state) ? 1 : 0,
});
return { return {
async start() { start: refresh,
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();
},
stop() { stop() {
stopped = true; stopped = true;
clearTimeout(timer); clearTimeout(timer);
unsubscribe();
unsubscribe = () => {};
}, },
}; };
} }
-33
View File
@@ -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");
});
+13
View File
@@ -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 }]);
});
+11 -15
View File
@@ -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, /provider: submittedModel\.provider/);
assert.match(pane, /modelId: submittedModel\.modelId/); assert.match(pane, /modelId: submittedModel\.modelId/);
assert.doesNotMatch(pane, /selectedModel\.split/); assert.doesNotMatch(pane, /selectedModel\.split/);
assert.match(pane, /listen\("quick-pane-shown"[\s\S]*?void renewChatOnShow\(\)/); assert.match(
assert.match(pane, /onFocusChanged[\s\S]*?if \(event\.payload\) focusInput\(\);/); 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, /const CHAT_RENEWAL_MS = 180_000/);
assert.match(pane, /Date\.now\(\) - lastActivity > CHAT_RENEWAL_MS/); assert.match(pane, /Date\.now\(\) - lastActivity > CHAT_RENEWAL_MS/);
assert.match(pane, /event\.ctrlKey && event\.key\.toLowerCase\(\) === "n"/); 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\)/); 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([ const [pane, nativeSource] = await Promise.all([
readFile( readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), 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, nativeSource,
/dismiss_quick_pane,[\s\S]*create_session_runtime/, /dismiss_quick_pane,[\s\S]*create_session_runtime/,
); );
assert.match( assert.doesNotMatch(nativeSource, /ManagedWindowVisibility|WindowVisibility/);
nativeSource, assert.match(nativeSource, /window\.is_visible\(\)/);
/struct WindowVisibility\(Mutex<ManagedWindowVisibility>\)/,
);
const mainShow = nativeSource.slice( const mainShow = nativeSource.slice(
nativeSource.indexOf("fn apply_main_window_action"), nativeSource.indexOf("fn apply_main_window_action"),
nativeSource.indexOf("fn get_or_create_quick_pane"), nativeSource.indexOf("fn get_or_create_quick_pane"),
); );
const mainHideQuick = mainShow.indexOf("quick_pane.hide()"); const mainHideQuick = mainShow.indexOf("quick_pane.hide()");
const mainClearQuick = mainShow.indexOf('visibility.hidden("quick-pane")');
const showMain = mainShow.indexOf("window.show()"); const showMain = mainShow.indexOf("window.show()");
assert.ok(mainShow.indexOf('get_webview_window("quick-pane")') >= 0); assert.ok(mainShow.indexOf('get_webview_window("quick-pane")') >= 0);
assert.ok(mainHideQuick >= 0 && mainHideQuick < showMain); assert.ok(mainHideQuick >= 0 && mainHideQuick < showMain);
assert.ok(mainClearQuick >= 0 && mainClearQuick < showMain);
const quickShow = nativeSource.slice( const quickShow = nativeSource.slice(
nativeSource.indexOf("fn toggle_quick_pane"), nativeSource.indexOf("fn toggle_quick_pane"),
nativeSource.indexOf("fn requested_new_worktree_argument"), nativeSource.indexOf("fn requested_new_worktree_argument"),
); );
const quickHideMain = quickShow.indexOf("main.hide()"); const quickHideMain = quickShow.indexOf("main.hide()");
const quickClearMain = quickShow.indexOf('visibility.hidden("main")');
const showQuick = quickShow.indexOf("window.show()"); const showQuick = quickShow.indexOf("window.show()");
assert.ok(quickShow.indexOf('get_webview_window("main")') >= 0); assert.ok(quickShow.indexOf('get_webview_window("main")') >= 0);
assert.ok(quickHideMain >= 0 && quickHideMain < showQuick); 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 () => { 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("quick_pane\n .hide()") <
promotion.indexOf("window\n .show()"), 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(promotion, /\*pending = Some\(runtime_id\)/);
assert.match(nativeSource, /fn get_pending_promoted_runtime/); assert.match(nativeSource, /fn get_pending_promoted_runtime/);
assert.match(nativeSource, /fn consume_pending_promoted_runtime/); assert.match(nativeSource, /fn consume_pending_promoted_runtime/);
+3 -2
View File
@@ -73,8 +73,9 @@ test("preserves native session naming, compaction, composer, and extension respo
assert.match(app, /respond_to_extension/); assert.match(app, /respond_to_extension/);
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/); assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
assert.match(conversation, /className="todos-pane"/); assert.match(conversation, /className="todos-pane"/);
assert.match(extension, /role="dialog"/); assert.match(extension, /<dialog/);
assert.match(extension, /aria-modal="true"/); assert.match(extension, /showModal\(\)/);
assert.match(extension, /onCancel=/);
}); });
test("configures native window behavior and folder dialog capabilities", async () => { test("configures native window behavior and folder dialog capabilities", async () => {
+1 -2
View File
@@ -2,9 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title> <title>Pi Status UI</title>
</head> </head>
<body> <body>
-10
View File
@@ -11,7 +11,6 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -1605,15 +1604,6 @@
"@tauri-apps/api": "^2.11.0" "@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": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
-1
View File
@@ -21,7 +21,6 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
-6
View File
@@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+5 -152
View File
@@ -1,7 +1,6 @@
use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::env; use std::env;
use std::path::Path;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@@ -346,72 +345,11 @@ pub async fn new_session(socket_path: &str, agent_id: &str) -> Result<Value, Str
request(socket_path, "new_session", Some(agent_id), None).await request(socket_path, "new_session", Some(agent_id), None).await
} }
fn fallback_transcript(state: &Value) -> Value { pub async fn submit_prompt(
let Some(path) = state.pointer("/data/sessionFile").and_then(Value::as_str) else { socket_path: &str,
return json!({ "data": { "messages": [] } }); agent_id: &str,
}; message: &str,
let path = Path::new(path); ) -> Result<Value, String> {
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::<Value>(line).ok())
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("message"))
.filter_map(|entry| entry.get("message").cloned())
.collect::<Vec<_>>()
})
.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::<Vec<_>>()
})
.unwrap_or_default();
json!({ "data": { "commands": commands } })
}
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, String> {
// 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<Value, String> {
request( request(
socket_path, socket_path,
"submit_prompt", "submit_prompt",
@@ -896,91 +834,6 @@ mod tests {
std::fs::remove_file(path).expect("socket cleanup"); 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 { fn runtime(id: &str) -> Value {
json!({ json!({
"runtimeId": id, "runtimeId": id,
+84 -190
View File
@@ -14,33 +14,6 @@ use tauri::{
}; };
struct LegacySubscription(Mutex<Option<JoinHandle<()>>>); struct LegacySubscription(Mutex<Option<JoinHandle<()>>>);
#[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<ManagedWindowVisibility>);
struct PendingPromotedRuntime(Mutex<Option<String>>); struct PendingPromotedRuntime(Mutex<Option<String>>);
struct WorkspaceSubscription { struct WorkspaceSubscription {
generation: Arc<bridge::SubscriptionGeneration>, generation: Arc<bridge::SubscriptionGeneration>,
@@ -131,22 +104,10 @@ fn apply_main_window_action(app: &AppHandle, args: &[String]) {
return; return;
} }
}; };
let Some(state) = app.try_state::<WindowVisibility>() else { let visible = !created && window.is_visible().unwrap_or(false);
eprintln!("Window visibility state is unavailable"); match window_action(args, visible) {
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) {
WindowAction::Hide => { WindowAction::Hide => {
if window.hide().is_ok() { let _ = window.hide();
visibility.hidden("main");
}
} }
WindowAction::ShowAndFocus => { WindowAction::ShowAndFocus => {
if let Some(quick_pane) = app.get_webview_window("quick-pane") { 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; return;
} }
} }
visibility.hidden("quick-pane");
if window.show().is_ok() { if window.show().is_ok() {
visibility.main_shown();
let _ = window.unminimize(); let _ = window.unminimize();
let _ = window.set_focus(); 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> { 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. // A recreated Main starts hidden, so Quick is never visible with it.
let (window, created) = get_or_create_main_with_visibility(app, false)?; let (window, _) = get_or_create_main_with_visibility(app, false)?;
let state = app
.try_state::<WindowVisibility>()
.ok_or_else(|| "Window visibility state is unavailable".to_owned())?;
let pending = app let pending = app
.try_state::<PendingPromotedRuntime>() .try_state::<PendingPromotedRuntime>()
.ok_or_else(|| "Promoted runtime state is unavailable".to_owned())?; .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 let mut pending = pending
.0 .0
.lock() .lock()
.map_err(|_| "Could not queue promoted runtime".to_owned())?; .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") { if let Some(quick_pane) = app.get_webview_window("quick-pane") {
quick_pane quick_pane
.hide() .hide()
.map_err(|error| format!("Could not hide Quick Pane before promotion: {error}"))?; .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. // Retain this on show failure; a later Main startup can consume it.
*pending = Some(runtime_id); *pending = Some(runtime_id);
visibility.main = false;
window window
.show() .show()
.map_err(|error| format!("Could not show Main after hiding Quick Pane: {error}"))?; .map_err(|error| format!("Could not show Main after hiding Quick Pane: {error}"))?;
visibility.main_shown();
let _ = window.unminimize(); let _ = window.unminimize();
let _ = window.set_focus(); let _ = window.set_focus();
Ok(()) Ok(())
@@ -229,22 +175,10 @@ fn toggle_quick_pane(app: &AppHandle) {
return; return;
} }
}; };
let Some(state) = app.try_state::<WindowVisibility>() else { let visible = !created && window.is_visible().unwrap_or(false);
eprintln!("Window visibility state is unavailable"); match quick_pane_action(visible) {
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) {
WindowAction::Hide => { WindowAction::Hide => {
if window.hide().is_ok() { let _ = window.hide();
visibility.hidden("quick-pane");
}
} }
WindowAction::ShowAndFocus => { WindowAction::ShowAndFocus => {
if let Some(main) = app.get_webview_window("main") { if let Some(main) = app.get_webview_window("main") {
@@ -253,9 +187,7 @@ fn toggle_quick_pane(app: &AppHandle) {
return; return;
} }
} }
visibility.hidden("main");
if window.show().is_ok() { if window.show().is_ok() {
visibility.quick_pane_shown();
let _ = window.unminimize(); let _ = window.unminimize();
let _ = window.set_focus(); let _ = window.set_focus();
let _ = app.emit("quick-pane-shown", ()); let _ = app.emit("quick-pane-shown", ());
@@ -288,30 +220,52 @@ fn requested_new_worktree(app: &AppHandle, args: &[String]) -> Option<String> {
} }
fn launch_new_session(app: AppHandle, args: &[String]) { 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 { tauri::async_runtime::spawn(async move {
// Give a newly-created webview time to register its frontend listeners. // Give a newly-created webview time to register its frontend listeners.
tokio::time::sleep(Duration::from_millis(350)).await; tokio::time::sleep(Duration::from_millis(350)).await;
let _ = app.emit("workspace-new-session", json!({ let _ = app.emit(
"phase": "starting", "workspace-new-session",
"detail": "Starting a new Pi session…" json!({
})); "phase": "starting",
"detail": "Starting a new Pi session…"
}),
);
match socket_path().map(|socket| (socket, worktree_path)) { match socket_path().map(|socket| (socket, worktree_path)) {
Ok((socket, worktree_path)) => match bridge::create_session_runtime(&socket, &worktree_path).await { Ok((socket, worktree_path)) => {
Ok(result) => { let _ = app.emit("workspace-new-session", json!({ match bridge::create_session_runtime(&socket, &worktree_path).await {
"phase": "ready", Ok(result) => {
"runtimeId": result.runtime.runtime_id, let _ = app.emit(
"detail": "New session ready" "workspace-new-session",
})); } json!({
Err(error) => { let _ = app.emit("workspace-new-session", json!({ "phase": "ready",
"phase": "error", "runtimeId": result.runtime.runtime_id,
"detail": format!("Could not start session: {error}") "detail": "New session ready"
})); } }),
}, );
Err(error) => { let _ = app.emit("workspace-new-session", json!({ }
"phase": "error", Err(error) => {
"detail": 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 let quick_pane = app
.get_webview_window("quick-pane") .get_webview_window("quick-pane")
.ok_or_else(|| "Quick Pane window is unavailable".to_owned())?; .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())?; let current_size = quick_pane.inner_size().map_err(|error| error.to_string())?;
quick_pane quick_pane
.set_size(LogicalSize::new( .set_size(LogicalSize::new(
@@ -355,20 +311,11 @@ fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
fn dismiss_quick_pane( fn dismiss_quick_pane(app: AppHandle) -> Result<(), String> {
app: AppHandle, app.get_webview_window("quick-pane")
visibility: State<'_, WindowVisibility>, .ok_or_else(|| "Quick Pane window is unavailable".to_owned())?
) -> Result<(), String> { .hide()
let quick_pane = app .map_err(|error| error.to_string())
.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(())
} }
#[tauri::command] #[tauri::command]
@@ -388,7 +335,10 @@ async fn promote_quick_runtime(
) -> Result<bridge::RuntimeResult, String> { ) -> Result<bridge::RuntimeResult, String> {
let result = bridge::promote_quick_runtime(&socket_path()?, &runtime_id).await?; let result = bridge::promote_quick_runtime(&socket_path()?, &runtime_id).await?;
show_main_for_promoted_runtime(&app, result.runtime.runtime_id.clone())?; 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) Ok(result)
} }
@@ -490,11 +440,6 @@ async fn forget_directory(worktree_path: String) -> Result<Value, String> {
bridge::forget_directory(&socket_path()?, &worktree_path).await bridge::forget_directory(&socket_path()?, &worktree_path).await
} }
#[tauri::command]
async fn load_agent(agent_id: String) -> Result<Value, String> {
bridge::load_agent(&socket_path()?, &agent_id).await
}
#[tauri::command] #[tauri::command]
async fn list_sessions(agent_id: String) -> Result<Value, String> { async fn list_sessions(agent_id: String) -> Result<Value, String> {
bridge::list_sessions(&socket_path()?, &agent_id).await 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", "pi_rpc_command",
Some(&agent_id), Some(&agent_id),
Some(serde_json::json!({ "command": command, "input": input })), Some(serde_json::json!({ "command": command, "input": input })),
).await )
.await
} }
#[tauri::command] #[tauri::command]
@@ -665,10 +611,6 @@ pub fn run() {
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.manage(LegacySubscription(Mutex::new(None))) .manage(LegacySubscription(Mutex::new(None)))
.manage(WindowVisibility(Mutex::new(ManagedWindowVisibility {
main: true,
quick_pane: false,
})))
.manage(PendingPromotedRuntime(Mutex::new(None))) .manage(PendingPromotedRuntime(Mutex::new(None)))
.manage(WorkspaceSubscription { .manage(WorkspaceSubscription {
generation: Arc::new(bridge::SubscriptionGeneration::default()), generation: Arc::new(bridge::SubscriptionGeneration::default()),
@@ -686,17 +628,7 @@ pub fn run() {
if should_hide_close_request(window.label()) { if should_hide_close_request(window.label()) {
if let WindowEvent::CloseRequested { api, .. } = event { if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close(); api.prevent_close();
if let Some(state) = window.app_handle().try_state::<WindowVisibility>() { let _ = window.hide();
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();
}
} }
} }
}) })
@@ -732,7 +664,6 @@ pub fn run() {
list_directories, list_directories,
select_worktree, select_worktree,
forget_directory, forget_directory,
load_agent,
list_sessions, list_sessions,
switch_session, switch_session,
new_session, new_session,
@@ -776,10 +707,26 @@ mod tests {
#[test] #[test]
fn new_session_uses_an_explicit_absolute_worktree() { fn new_session_uses_an_explicit_absolute_worktree() {
let args = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "/workspace".into()]; let args = vec![
assert_eq!(requested_new_worktree_argument(&args), Some("/workspace".into())); "pi-status-ui".into(),
let relative = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "workspace".into()]; "--new".into(),
assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None); "--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] #[test]
@@ -797,42 +744,6 @@ mod tests {
assert!(!should_hide_close_request("other")); 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] #[test]
fn promoted_runtime_pending_id_clears_only_after_matching_acknowledgement() { fn promoted_runtime_pending_id_clears_only_after_matching_acknowledgement() {
let pending = PendingPromotedRuntime(Mutex::new(Some("runtime-a".to_owned()))); let pending = PendingPromotedRuntime(Mutex::new(Some("runtime-a".to_owned())));
@@ -843,23 +754,6 @@ mod tests {
assert_eq!(value.as_deref(), None); 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] #[test]
fn main_recreation_uses_the_complete_configured_window() { fn main_recreation_uses_the_complete_configured_window() {
let context: tauri::Context<tauri::Wry> = tauri::generate_context!(); let context: tauri::Context<tauri::Wry> = tauri::generate_context!();
+8 -9
View File
@@ -1007,17 +1007,14 @@ select:focus-visible {
flex: 0 0 auto; flex: 0 0 auto;
transform: scale(1.35); transform: scale(1.35);
} }
.modal-backdrop, dialog.session-picker::backdrop,
.extension-backdrop { dialog.extension::backdrop {
position: absolute;
z-index: 10;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.62); background: rgba(0, 0, 0, 0.62);
} }
dialog.session-picker:not([open]),
dialog.extension:not([open]) {
display: none;
}
.session-picker, .session-picker,
.extension { .extension {
width: min(680px, 90vw); width: min(680px, 90vw);
@@ -1025,10 +1022,12 @@ select:focus-visible {
overflow: auto; overflow: auto;
display: grid; display: grid;
gap: 8px; gap: 8px;
margin: auto;
padding: 15px; padding: 15px;
border: 1px solid #b76f26; border: 1px solid #b76f26;
border-radius: 10px; border-radius: 10px;
background: #2d2e31; background: #2d2e31;
color: #f1f3f4;
} }
.session-picker { .session-picker {
overflow: hidden; overflow: hidden;
+3 -4
View File
@@ -213,10 +213,9 @@ describe("App command progress and errors", () => {
await user.click( await user.click(
screen.getByRole("option", { name: /Claude.*anthropic\/claude\/sonnet/i }), screen.getByRole("option", { name: /Claude.*anthropic\/claude\/sonnet/i }),
); );
await user.click( fireEvent.change(screen.getByRole("combobox", { name: "Default thinking" }), {
screen.getByRole("button", { name: "Default thinking Medium" }), target: { value: "high" },
); });
await user.click(screen.getByRole("option", { name: "High" }));
await user.click( await user.click(
screen.getByRole("button", { name: "Save Quick Pane settings" }), screen.getByRole("button", { name: "Save Quick Pane settings" }),
); );
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

+98 -117
View File
@@ -1,16 +1,18 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { RpcCommand, RpcField } from "../commands/rpc"; import type { RpcCommand, RpcField } from "../commands/rpc";
const controls = (container: HTMLElement | null) => type RpcInputValue =
Array.from( | string
container?.querySelectorAll<HTMLElement>("button, input, select, textarea") ?? | boolean
[], | null
).filter((node) => !node.hasAttribute("disabled")); | 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 === "boolean") return value === true;
if (field.kind === "json") 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; return value || undefined;
} }
@@ -27,19 +29,17 @@ export function CommandFormDialog({
pending?: boolean; pending?: boolean;
commandError?: string; commandError?: string;
}) { }) {
const ref = useRef<HTMLElement>(null); const ref = useRef<HTMLDialogElement>(null);
const opener = useRef<HTMLElement | null>(null);
const [values, setValues] = useState<Record<string, string | boolean>>({}); const [values, setValues] = useState<Record<string, string | boolean>>({});
const [validationError, setValidationError] = useState<string>(); const [validationError, setValidationError] = useState<string>();
useEffect(() => { useEffect(() => {
if (!command) return; if (!command) return;
opener.current = document.activeElement as HTMLElement | null;
setValues({}); setValues({});
setValidationError(undefined); setValidationError(undefined);
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus()); const dialog = ref.current;
dialog?.showModal();
return () => { return () => {
cancelAnimationFrame(frame); if (dialog?.open) dialog.close();
opener.current?.focus();
}; };
}, [command]); }, [command]);
if (!command) return null; if (!command) return null;
@@ -63,109 +63,90 @@ export function CommandFormDialog({
} }
}; };
return ( return (
<div className="extension-backdrop"> <dialog
<section ref={ref}
ref={ref} className="extension"
className="extension" aria-labelledby="command-form-title"
role="dialog" aria-busy={pending}
aria-modal="true" onCancel={(event) => {
aria-labelledby="command-form-title" event.preventDefault();
aria-busy={pending} onCancel();
onKeyDown={(event) => { }}
if (event.key === "Escape") { >
event.preventDefault(); <p className="extension-eyebrow">Pi RPC command</p>
onCancel(); <h2 id="command-form-title">/{command.command}</h2>
} <p>{command.description}</p>
if (event.key === "Tab") { {fields.map((field) => (
const items = controls(ref.current), <label key={field.name}>
first = items[0], {field.label}
last = items[items.length - 1]; {field.kind === "boolean" ? (
if (!first || !last) return; <input
if (event.shiftKey && document.activeElement === first) { aria-label={field.label}
event.preventDefault(); type="checkbox"
last.focus(); checked={values[field.name] === true}
} else if (!event.shiftKey && document.activeElement === last) { disabled={pending}
event.preventDefault(); onChange={(event) =>
first.focus(); setValues({
} ...values,
} [field.name]: event.currentTarget.checked,
}} })
> }
<p className="extension-eyebrow">Pi RPC command</p> />
<h2 id="command-form-title">/{command.command}</h2> ) : field.kind === "enum" ? (
<p>{command.description}</p> <select
{fields.map((field) => ( aria-label={field.label}
<label key={field.name}> value={(values[field.name] as string) ?? ""}
{field.label} disabled={pending}
{field.kind === "boolean" ? ( onChange={(event) =>
<input setValues({
aria-label={field.label} ...values,
type="checkbox" [field.name]: event.currentTarget.value,
checked={values[field.name] === true} })
disabled={pending} }
onChange={(event) => >
setValues({ <option value="">Select…</option>
...values, {field.options?.map((option) => (
[field.name]: event.currentTarget.checked, <option key={option}>{option}</option>
}) ))}
} </select>
/> ) : field.kind === "json" ? (
) : field.kind === "enum" ? ( <textarea
<select aria-label={field.label}
aria-label={field.label} value={(values[field.name] as string) ?? ""}
value={(values[field.name] as string) ?? ""} disabled={pending}
disabled={pending} onChange={(event) =>
onChange={(event) => setValues({
setValues({ ...values,
...values, [field.name]: event.currentTarget.value,
[field.name]: event.currentTarget.value, })
}) }
} />
> ) : (
<option value="">Select…</option> <input
{field.options?.map((option) => ( aria-label={field.label}
<option key={option}>{option}</option> value={(values[field.name] as string) ?? ""}
))} disabled={pending}
</select> onChange={(event) =>
) : field.kind === "json" ? ( setValues({
<textarea ...values,
aria-label={field.label} [field.name]: event.currentTarget.value,
value={(values[field.name] as string) ?? ""} })
disabled={pending} }
onChange={(event) => />
setValues({ )}
...values, </label>
[field.name]: event.currentTarget.value, ))}
}) {(validationError || commandError) && (
} <p role="alert">{validationError || commandError}</p>
/> )}
) : ( <div className="extension-options">
<input <button disabled={pending} onClick={submit}>
aria-label={field.label} {pending ? "Running…" : "Run command"}
value={(values[field.name] as string) ?? ""} </button>
disabled={pending} <button className="quiet" disabled={pending} onClick={onCancel}>
onChange={(event) => Cancel
setValues({ </button>
...values, </div>
[field.name]: event.currentTarget.value, </dialog>
})
}
/>
)}
</label>
))}
{(validationError || commandError) && (
<p role="alert">{validationError || commandError}</p>
)}
<div className="extension-options">
<button disabled={pending} onClick={submit}>
{pending ? "Running…" : "Run command"}
</button>
<button className="quiet" disabled={pending} onClick={onCancel}>
Cancel
</button>
</div>
</section>
</div>
); );
} }
+12 -6
View File
@@ -2,20 +2,26 @@ import { describe, expect, it } from "vitest";
const fs = await import(["node", "fs"].join(":")); const fs = await import(["node", "fs"].join(":"));
const modelCss = fs.readFileSync("src/components/ModelSelector.css", "utf8"); const modelCss = fs.readFileSync("src/components/ModelSelector.css", "utf8");
const thinkingCss = fs.readFileSync("src/components/ThinkingSelector.css", "utf8");
const quickPaneCss = fs.readFileSync("src/quick-pane/quick-pane.css", "utf8"); const quickPaneCss = fs.readFileSync("src/quick-pane/quick-pane.css", "utf8");
describe("dropdown CSS", () => { describe("dropdown CSS", () => {
it("keeps regular model menu capped with scrolling results", () => { it("keeps regular model menu capped with scrolling results", () => {
expect(modelCss).toMatch(/\.model-menu\s*\{[\s\S]*max-height:\s*min\(420px, calc\(100vh - 80px\)\)/); expect(modelCss).toMatch(
/\.model-menu\s*\{[\s\S]*max-height:\s*min\(420px, calc\(100vh - 80px\)\)/,
);
expect(modelCss).toMatch(/\.model-results\s*\{[\s\S]*overflow-y:\s*auto/); expect(modelCss).toMatch(/\.model-results\s*\{[\s\S]*overflow-y:\s*auto/);
expect(modelCss).toMatch(/\.model-option\[aria-selected="true"\]/); expect(modelCss).toMatch(/\.model-option\[aria-selected="true"\]/);
}); });
it("caps compact menus to Quick Pane container below titlebar and toolbar", () => { it("caps compact menus to Quick Pane container below titlebar and toolbar", () => {
expect(quickPaneCss).toMatch(/\.quick-pane\s*\{[\s\S]*container-type:\s*size/); expect(quickPaneCss).toMatch(
expect(quickPaneCss).toMatch(/\.quick-pane \.model-menu\s*\{[\s\S]*max-height:\s*min\(350px, calc\(100cqh - 74px\)\)/); /\.quick-pane\s*\{[\s\S]*container-type:\s*size/,
expect(quickPaneCss).toMatch(/\.quick-pane \.model-menu\s*\{[\s\S]*width:\s*min\(400px, calc\(100cqw - 24px\)\)/); );
expect(thinkingCss).toMatch(/\.thinking-selector-down \.thinking-menu\s*\{[\s\S]*top:\s*calc\(100% \+ 6px\)/); expect(quickPaneCss).toMatch(
/\.quick-pane \.model-menu\s*\{[\s\S]*max-height:\s*min\(350px, calc\(100cqh - 74px\)\)/,
);
expect(quickPaneCss).toMatch(
/\.quick-pane \.model-menu\s*\{[\s\S]*width:\s*min\(400px, calc\(100cqw - 24px\)\)/,
);
}); });
}); });
+90 -128
View File
@@ -1,137 +1,99 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { Extension } from "../workspace/types"; import type { Extension } from "../workspace/types";
function controls(container: HTMLElement | null) {
return [
...(container?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
) ?? []),
];
}
export function ExtensionDialog({ export function ExtensionDialog({
extension, extension,
onRespond, onRespond,
}: { }: {
extension?: Extension; extension?: Extension;
onRespond: (response: Record<string, unknown>) => void; onRespond: (response: Record<string, unknown>) => void;
}) { }) {
const ref = useRef<HTMLElement>(null); const ref = useRef<HTMLDialogElement>(null);
const opener = useRef<HTMLElement | null>(null); const [value, setValue] = useState("");
const respond = useRef(onRespond);
const [value, setValue] = useState("");
respond.current = onRespond;
useEffect(() => { useEffect(() => {
if (!extension) return; if (!extension) return;
opener.current = document.activeElement as HTMLElement | null; setValue(
setValue( extension.prefill ?? extension.initialValue ?? extension.text ?? "",
extension.prefill ?? extension.initialValue ?? extension.text ?? "", );
); const dialog = ref.current;
const frame = requestAnimationFrame(() => dialog?.showModal();
controls(ref.current)[0]?.focus(), return () => {
); if (dialog?.open) dialog.close();
const keydown = (event: KeyboardEvent) => { };
if (event.key === "Escape") { }, [extension]);
event.preventDefault();
respond.current({ cancelled: true });
return;
}
if (event.key !== "Tab") return;
const focusable = controls(ref.current);
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", keydown);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("keydown", keydown);
opener.current?.focus();
};
}, [extension]);
if (!extension) return null; if (!extension) return null;
return ( return (
<div className="extension-backdrop"> <dialog
<section ref={ref}
ref={ref} className="extension"
className="extension" aria-labelledby="extension-title"
role="dialog" onCancel={(event) => {
aria-modal="true" event.preventDefault();
aria-labelledby="extension-title" onRespond({ cancelled: true });
> }}
<p className="extension-eyebrow">Pi agent request</p> >
<h2 id="extension-title">Review request</h2> <p className="extension-eyebrow">Pi agent request</p>
<div className="extension-request"> <h2 id="extension-title">Review request</h2>
<p className="extension-request-label">Requested action</p> <div className="extension-request">
<p className="extension-request-action"> <p className="extension-request-label">Requested action</p>
{extension.title ?? "Pi needs input"} <p className="extension-request-action">
</p> {extension.title ?? "Pi needs input"}
{extension.message && ( </p>
<> {extension.message && (
<p className="extension-request-label">Request details</p> <>
<p className="extension-request-details">{extension.message}</p> <p className="extension-request-label">Request details</p>
</> <p className="extension-request-details">{extension.message}</p>
)} </>
</div> )}
{extension.method === "select" && ( </div>
<div className="extension-options"> {extension.method === "select" && (
{extension.options?.map((option) => ( <div className="extension-options">
<button key={option} onClick={() => onRespond({ value: option })}> {extension.options?.map((option) => (
{option} <button key={option} onClick={() => onRespond({ value: option })}>
</button> {option}
))} </button>
</div> ))}
)} </div>
{extension.method === "confirm" && ( )}
<div className="extension-options"> {extension.method === "confirm" && (
<button onClick={() => onRespond({ confirmed: true })}> <div className="extension-options">
Allow request <button onClick={() => onRespond({ confirmed: true })}>
</button> Allow request
<button </button>
className="quiet" <button
onClick={() => onRespond({ confirmed: false })} className="quiet"
> onClick={() => onRespond({ confirmed: false })}
Decline request >
</button> Decline request
</div> </button>
)} </div>
{extension.method === "input" && ( )}
<> {extension.method === "input" && (
<input <>
aria-label="Extension value" <input
value={value} aria-label="Extension value"
placeholder={extension.placeholder} value={value}
onChange={(event) => setValue(event.currentTarget.value)} placeholder={extension.placeholder}
/> onChange={(event) => setValue(event.currentTarget.value)}
<button onClick={() => onRespond({ value })}>Submit</button> />
</> <button onClick={() => onRespond({ value })}>Submit</button>
)} </>
{extension.method === "editor" && ( )}
<> {extension.method === "editor" && (
<textarea <>
aria-label="Extension value" <textarea
value={value} aria-label="Extension value"
onChange={(event) => setValue(event.currentTarget.value)} value={value}
/> onChange={(event) => setValue(event.currentTarget.value)}
<button onClick={() => onRespond({ value })}>Submit</button> />
</> <button onClick={() => onRespond({ value })}>Submit</button>
)} </>
<button )}
className="quiet" <button className="quiet" onClick={() => onRespond({ cancelled: true })}>
onClick={() => onRespond({ cancelled: true })} Cancel
> </button>
Cancel </dialog>
</button> );
</section>
</div>
);
} }
+46 -91
View File
@@ -1,9 +1,6 @@
import { type RefObject, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { DirectorySession } from "../workspace/types"; import type { DirectorySession } from "../workspace/types";
const focusableSelector =
'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])';
function sessionTitle(session: DirectorySession) { function sessionTitle(session: DirectorySession) {
return session.name ?? session.firstMessage ?? "Untitled session"; return session.name ?? session.firstMessage ?? "Untitled session";
} }
@@ -26,9 +23,7 @@ function SessionRow({
onOpen: (session: DirectorySession) => void; onOpen: (session: DirectorySession) => void;
}) { }) {
const showPreview = Boolean( const showPreview = Boolean(
session.name && session.name && session.firstMessage && session.firstMessage !== session.name,
session.firstMessage &&
session.firstMessage !== session.name,
); );
return ( return (
<li> <li>
@@ -71,47 +66,6 @@ function SessionResults({
); );
} }
function useDialogFocus(
dialogRef: RefObject<HTMLElement | null>,
searchRef: RefObject<HTMLInputElement | null>,
onClose: () => void,
) {
const openerRef = useRef<HTMLElement | null>(
document.activeElement as HTMLElement | null,
);
useEffect(() => {
const dialog = dialogRef.current;
const controls = () => [
...(dialog?.querySelectorAll<HTMLElement>(focusableSelector) ?? []),
];
searchRef.current?.focus();
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = controls();
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", keydown);
return () => {
window.removeEventListener("keydown", keydown);
openerRef.current?.focus();
};
}, [dialogRef, onClose, searchRef]);
}
export function SessionPicker({ export function SessionPicker({
sessions, sessions,
onOpen, onOpen,
@@ -122,55 +76,56 @@ export function SessionPicker({
onClose: () => void; onClose: () => void;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const dialogRef = useRef<HTMLElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const normalizedQuery = query.trim().toLocaleLowerCase(); const normalizedQuery = query.trim().toLocaleLowerCase();
const filteredSessions = useMemo( const filteredSessions = useMemo(
() => sessions.filter((session) => matchesQuery(session, normalizedQuery)), () => sessions.filter((session) => matchesQuery(session, normalizedQuery)),
[normalizedQuery, sessions], [normalizedQuery, sessions],
); );
useDialogFocus(dialogRef, searchRef, onClose); useEffect(() => {
const dialog = dialogRef.current;
dialog?.showModal();
return () => {
if (dialog?.open) dialog.close();
};
}, []);
const resultLabel = `${filteredSessions.length} saved session${filteredSessions.length === 1 ? "" : "s"}`; const resultLabel = `${filteredSessions.length} saved session${filteredSessions.length === 1 ? "" : "s"}`;
return ( return (
<div className="modal-backdrop"> <dialog
<section ref={dialogRef}
ref={dialogRef} className="session-picker"
className="session-picker" aria-labelledby="session-picker-title"
role="dialog" onCancel={(event) => {
aria-modal="true" event.preventDefault();
aria-labelledby="session-picker-title" onClose();
> }}
<div className="picker-heading"> >
<h2 id="session-picker-title">Saved sessions</h2> <div className="picker-heading">
<button onClick={onClose}>Close</button> <h2 id="session-picker-title">Saved sessions</h2>
</div> <button onClick={onClose}>Close</button>
<div className="session-search"> </div>
<label className="sr-only" htmlFor="session-search"> <div className="session-search">
Search saved sessions <label className="sr-only" htmlFor="session-search">
</label> Search saved sessions
<input </label>
ref={searchRef} <input
id="session-search" autoFocus
type="search" id="session-search"
value={query} type="search"
onChange={(event) => setQuery(event.target.value)} value={query}
placeholder="Search by name, message, or ID" onChange={(event) => setQuery(event.target.value)}
aria-controls="session-results" placeholder="Search by name, message, or ID"
/> aria-controls="session-results"
</div> />
<p className="session-result-count" role="status" aria-live="polite"> </div>
{sessions.length ? resultLabel : "No saved sessions"} <p className="session-result-count" role="status" aria-live="polite">
</p> {sessions.length ? resultLabel : "No saved sessions"}
{sessions.length === 0 ? ( </p>
<p className="muted">No saved sessions for this directory.</p> {sessions.length === 0 ? (
) : ( <p className="muted">No saved sessions for this directory.</p>
<SessionResults ) : (
sessions={filteredSessions} <SessionResults sessions={filteredSessions} query={query} onOpen={onOpen} />
query={query} )}
onOpen={onOpen} </dialog>
/>
)}
</section>
</div>
); );
} }
+4 -15
View File
@@ -11,19 +11,8 @@
text-transform: uppercase; text-transform: uppercase;
} }
.thinking-menu { .thinking-trigger {
grid-template-rows: minmax(0, 1fr); appearance: auto;
} padding-right: 6px;
background-image: none;
.thinking-selector-down .thinking-menu {
top: calc(100% + 6px);
bottom: auto;
}
.thinking-options {
gap: 2px;
}
.thinking-option {
font-size: inherit;
} }
+21 -156
View File
@@ -1,11 +1,4 @@
import { import { fireEvent, render, screen } from "@testing-library/react";
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { ThinkingSelector } from "./ThinkingSelector"; import { ThinkingSelector } from "./ThinkingSelector";
@@ -19,6 +12,7 @@ const model = {
xhigh: "xhigh", xhigh: "xhigh",
}, },
}; };
const fullModel = { const fullModel = {
reasoning: true, reasoning: true,
thinkingLevelMap: { thinkingLevelMap: {
@@ -31,176 +25,47 @@ const fullModel = {
}; };
describe("ThinkingSelector", () => { describe("ThinkingSelector", () => {
it("uses reconciled model options and marks selected level", async () => { it("uses reconciled model options", () => {
const user = userEvent.setup();
render( render(
<ThinkingSelector model={model} value="medium" onChange={vi.fn()} />, <ThinkingSelector model={model} value="medium" onChange={vi.fn()} />,
); );
const trigger = screen.getByRole("button", { name: "Thinking Off" }); const select = screen.getByRole("combobox", { name: "Thinking" });
await user.click(trigger); expect(select).toHaveValue("off");
const menu = screen.getByRole("listbox", { name: "Thinking options" });
expect( expect(
within(menu) screen.getAllByRole("option").map((option) => option.textContent),
.getAllByRole("option")
.map((option) => option.textContent),
).toEqual(["Off", "High", "XHigh"]); ).toEqual(["Off", "High", "XHigh"]);
expect(within(menu).getByRole("option", { name: "Off" })).toHaveAttribute(
"aria-selected",
"true",
);
}); });
it("selects an option, closes menu, and restores trigger focus", async () => { it("reports native selection changes", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ThinkingSelector model={model} value="off" onChange={onChange} />);
const trigger = screen.getByRole("button", { name: "Thinking Off" });
await user.click(trigger);
await user.click(screen.getByRole("option", { name: "High" }));
expect(onChange).toHaveBeenCalledWith("high");
expect(
screen.queryByRole("listbox", { name: "Thinking options" }),
).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it("roves options with arrows, Home, End, and Enter", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<ThinkingSelector model={fullModel} value="medium" onChange={onChange} />,
);
const trigger = screen.getByRole("button", { name: "Thinking Medium" });
await user.click(trigger);
const menu = screen.getByRole("listbox", { name: "Thinking options" });
const medium = within(menu).getByRole("option", { name: "Medium" });
await waitFor(() => expect(medium).toHaveFocus());
expect(medium).toHaveAttribute("tabindex", "0");
await user.keyboard("{ArrowDown}");
await waitFor(() =>
expect(within(menu).getByRole("option", { name: "High" })).toHaveFocus(),
);
await user.keyboard("{ArrowUp}{Home}");
await waitFor(() =>
expect(within(menu).getByRole("option", { name: "Off" })).toHaveFocus(),
);
await user.keyboard("{End}");
await waitFor(() =>
expect(within(menu).getByRole("option", { name: "XHigh" })).toHaveFocus(),
);
await user.keyboard("{Enter}");
expect(onChange).toHaveBeenCalledWith("xhigh");
expect(
screen.queryByRole("listbox", { name: "Thinking options" }),
).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it("selects active option with Space and cycles typeahead matches", async () => {
const user = userEvent.setup();
const onChange = vi.fn(); const onChange = vi.fn();
render( render(
<ThinkingSelector model={fullModel} value="off" onChange={onChange} />, <ThinkingSelector model={fullModel} value="off" onChange={onChange} />,
); );
const trigger = screen.getByRole("button", { name: "Thinking Off" }); fireEvent.change(screen.getByRole("combobox", { name: "Thinking" }), {
await user.click(trigger); target: { value: "high" },
const menu = screen.getByRole("listbox", { name: "Thinking options" }); });
await waitFor(() =>
expect(within(menu).getByRole("option", { name: "Off" })).toHaveFocus(),
);
await user.keyboard("m");
await waitFor(() =>
expect(
within(menu).getByRole("option", { name: "Minimal" }),
).toHaveFocus(),
);
await user.keyboard("m");
await waitFor(() =>
expect(
within(menu).getByRole("option", { name: "Medium" }),
).toHaveFocus(),
);
await user.keyboard(" ");
expect(onChange).toHaveBeenCalledWith("medium"); expect(onChange).toHaveBeenCalledWith("high");
expect(
screen.queryByRole("listbox", { name: "Thinking options" }),
).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
}); });
it("closes on Escape with focus restore and outside pointer without closing pane", async () => { it("supports labels, disabled state, and control classes", () => {
const user = userEvent.setup(); render(
render(<ThinkingSelector model={model} value="high" onChange={vi.fn()} />);
const trigger = screen.getByRole("button", { name: "Thinking High" });
await user.click(trigger);
await waitFor(() =>
expect(screen.getByRole("option", { name: "High" })).toHaveFocus(),
);
await user.keyboard("{Escape}");
expect(
screen.queryByRole("listbox", { name: "Thinking options" }),
).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
await user.click(trigger);
fireEvent.pointerDown(document.body);
expect(
screen.queryByRole("listbox", { name: "Thinking options" }),
).toBeNull();
});
it("closes an open menu when disabled without restoring focus", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const { rerender } = render(
<ThinkingSelector model={model} value="high" onChange={onChange} />,
);
const trigger = screen.getByRole("button", { name: "Thinking High" });
await user.click(trigger);
const option = screen.getByRole("option", { name: "High" });
await waitFor(() => expect(option).toHaveFocus());
rerender(
<ThinkingSelector <ThinkingSelector
label="Default thinking"
model={model} model={model}
value="high"
disabled
onChange={onChange}
/>,
);
expect(
screen.queryByRole("listbox", { name: "Thinking options" }),
).toBeNull();
expect(trigger).toBeDisabled();
expect(trigger).not.toHaveFocus();
fireEvent.click(option);
option.focus();
fireEvent.keyDown(option, { key: "Enter" });
fireEvent.click(option, { detail: 0 });
expect(onChange).not.toHaveBeenCalled();
});
it("supports disabled and downward trigger placement", () => {
const { container } = render(
<ThinkingSelector
value="off" value="off"
disabled disabled
placement="down" className="compact"
controlClassName="toolbar-control"
onChange={vi.fn()} onChange={vi.fn()}
/>, />,
); );
expect(screen.getByRole("button", { name: "Thinking Off" })).toBeDisabled();
expect( const select = screen.getByRole("combobox", { name: "Default thinking" });
container.querySelector(".thinking-selector-down"), expect(select).toBeDisabled();
).toBeInTheDocument(); expect(select).toHaveClass("toolbar-control");
expect(select.closest("label")).toHaveClass("compact");
}); });
}); });
+41 -228
View File
@@ -1,240 +1,53 @@
import { useEffect, useId, useRef, useState } from "react";
import { reconcileThinkingLevel, thinkingLevelOptions } from "../model-catalog"; import { reconcileThinkingLevel, thinkingLevelOptions } from "../model-catalog";
import type { Model } from "../workspace/types"; import type { Model } from "../workspace/types";
import "./ModelSelector.css"; import "./ModelSelector.css";
import "./ThinkingSelector.css"; import "./ThinkingSelector.css";
type ThinkingSelectorProps = { type ThinkingSelectorProps = {
model?: Model; model?: Model;
label?: string; label?: string;
value: string; value: string;
disabled?: boolean; disabled?: boolean;
placement?: "up" | "down"; className?: string;
className?: string; controlClassName?: string;
controlClassName?: string; onChange: (level: string) => void;
onChange: (level: string) => void;
}; };
export function ThinkingSelector({ export function ThinkingSelector({
model, model,
label = "Thinking", label = "Thinking",
value, value,
disabled = false, disabled = false,
placement = "up",
className,
controlClassName,
onChange,
}: ThinkingSelectorProps) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const openRef = useRef(false);
const disabledRef = useRef(disabled);
const focusFrameRef = useRef<number | undefined>(undefined);
const typeaheadRef = useRef({ query: "", time: 0 });
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
disabledRef.current = disabled;
const labelId = useId();
const valueId = useId();
const menuId = useId();
const options = thinkingLevelOptions(model);
const selected = reconcileThinkingLevel(model, value);
const selectedIndex = options.findIndex(
(option) => option.value === selected,
);
const selectedOption = options[selectedIndex];
const classes = [
"thinking-selector",
`thinking-selector-${placement}`,
className, className,
] controlClassName,
.filter(Boolean) onChange,
.join(" "); }: ThinkingSelectorProps) {
const closeMenu = (restoreFocus = false) => { const options = thinkingLevelOptions(model);
if (!openRef.current) return; return (
openRef.current = false; <label
setOpen(false); className={["thinking-selector", className]
typeaheadRef.current = { query: "", time: 0 }; .filter(Boolean)
if (restoreFocus) { .join(" ")}
window.cancelAnimationFrame(focusFrameRef.current ?? 0);
focusFrameRef.current = window.requestAnimationFrame(() => {
focusFrameRef.current = undefined;
if (!disabledRef.current) triggerRef.current?.focus();
});
}
};
const openMenu = () => {
if (disabledRef.current || openRef.current) return;
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
openRef.current = true;
setOpen(true);
};
const selectOption = (index: number) => {
const option = options[index];
if (!option || disabledRef.current) return;
onChange(option.value);
closeMenu(true);
};
const moveActiveOption = (direction: 1 | -1) => {
setActiveIndex(
(index) => (index + direction + options.length) % options.length,
);
};
const typeahead = (key: string) => {
const now = Date.now();
const previous = typeaheadRef.current;
let query = now - previous.time < 500 ? `${previous.query}${key}` : key;
let matches = options
.map((option, index) => ({ option, index }))
.filter(({ option }) =>
option.label.toLocaleLowerCase().startsWith(query),
);
if (!matches.length && query.length > 1) {
query = key;
matches = options
.map((option, index) => ({ option, index }))
.filter(({ option }) =>
option.label.toLocaleLowerCase().startsWith(query),
);
}
typeaheadRef.current = { query, time: now };
if (!matches.length) return;
const next = matches.find(({ index }) => index > activeIndex) ?? matches[0];
setActiveIndex(next.index);
};
useEffect(() => {
if (!disabled) return;
window.cancelAnimationFrame(focusFrameRef.current ?? 0);
focusFrameRef.current = undefined;
closeMenu();
}, [disabled]);
useEffect(() => {
return () => {
openRef.current = false;
window.cancelAnimationFrame(focusFrameRef.current ?? 0);
};
}, []);
useEffect(() => {
if (!open) return;
const focusFrame = window.requestAnimationFrame(() =>
optionRefs.current[activeIndex]?.focus(),
);
return () => window.cancelAnimationFrame(focusFrame);
}, [activeIndex, open]);
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeMenu(true);
};
const closeOutside = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) closeMenu();
};
window.addEventListener("keydown", closeOnEscape);
window.addEventListener("pointerdown", closeOutside);
return () => {
window.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("pointerdown", closeOutside);
};
}, [open]);
return (
<div className={classes}>
<span id={labelId}>{label}</span>
<div className="model-selector thinking-popup" ref={rootRef}>
<button
ref={triggerRef}
type="button"
className={["model-trigger", "thinking-trigger", controlClassName]
.filter(Boolean)
.join(" ")}
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={open ? menuId : undefined}
aria-labelledby={`${labelId} ${valueId}`}
disabled={disabled}
onClick={() => {
if (disabledRef.current) return;
if (openRef.current) closeMenu();
else openMenu();
}}
> >
<span id={valueId}>{selectedOption?.label ?? "Off"}</span> <span>{label}</span>
</button> <select
{open && ( className={[
<div "model-trigger",
id={menuId} "thinking-trigger",
className="model-menu thinking-menu" controlClassName,
role="listbox" ]
aria-label={`${label} options`} .filter(Boolean)
> .join(" ")}
<div className="model-results thinking-options"> value={reconcileThinkingLevel(model, value)}
{options.map((option, index) => ( disabled={disabled}
<button onChange={(event) => onChange(event.currentTarget.value)}
ref={(element) => { >
optionRefs.current[index] = element; {options.map((option) => (
}} <option value={option.value} key={option.value}>
type="button" {option.label}
className="model-option thinking-option" </option>
role="option" ))}
aria-selected={option.value === selected} </select>
tabIndex={index === activeIndex ? 0 : -1} </label>
value={option.value} );
key={option.value}
onClick={() => selectOption(index)}
onKeyDown={(event) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveActiveOption(1);
break;
case "ArrowUp":
event.preventDefault();
moveActiveOption(-1);
break;
case "Home":
event.preventDefault();
setActiveIndex(0);
break;
case "End":
event.preventDefault();
setActiveIndex(options.length - 1);
break;
case "Enter":
case " ":
event.preventDefault();
selectOption(activeIndex);
break;
case "Escape":
event.preventDefault();
closeMenu(true);
break;
default:
if (
event.key.length === 1 &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
) {
event.preventDefault();
typeahead(event.key.toLocaleLowerCase());
}
}
}}
>
{option.label}
</button>
))}
</div>
</div>
)}
</div>
</div>
);
} }
+11 -25
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { DirectorySidebar } from "./DirectorySidebar"; import { DirectorySidebar } from "./DirectorySidebar";
@@ -199,10 +199,9 @@ describe("multi-session navigation", () => {
onCommand={vi.fn()} onCommand={vi.fn()}
/>, />,
); );
const user = userEvent.setup(); const thinking = screen.getByRole("combobox", { name: "Thinking" });
await user.click(screen.getByRole("button", { name: "Thinking Medium" }));
expect( expect(
screen within(thinking)
.getAllByRole("option") .getAllByRole("option")
.map((option) => option.getAttribute("value")), .map((option) => option.getAttribute("value")),
).toEqual(["off", "minimal", "low", "medium", "xhigh"]); ).toEqual(["off", "minimal", "low", "medium", "xhigh"]);
@@ -380,29 +379,16 @@ describe("multi-session navigation", () => {
await user.type(search, "missing"); await user.type(search, "missing");
expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument(); expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument();
}); });
it("focuses search, traps focus, and restores the opener", async () => { it("opens a native modal and handles cancellation", () => {
const user = userEvent.setup();
const opener = document.createElement("button");
document.body.append(opener);
opener.focus();
const onClose = vi.fn(); const onClose = vi.fn();
const { unmount } = render( render(<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />);
<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />, const dialog = screen.getByRole("dialog", { name: "Saved sessions" });
); expect(dialog).toHaveAttribute("open");
const search = screen.getByRole("searchbox", { expect(
name: "Search saved sessions", screen.getByRole("searchbox", { name: "Search saved sessions" }),
}); ).toHaveFocus();
const close = screen.getByRole("button", { name: "Close" }); fireEvent(dialog, new Event("cancel", { cancelable: true }));
expect(search).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(close).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(search).toHaveFocus();
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
unmount();
expect(opener).toHaveFocus();
document.body.removeChild(opener);
}); });
it("labels permission requests with the action and exact details", async () => { it("labels permission requests with the action and exact details", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
+12 -22
View File
@@ -254,12 +254,12 @@ describe("QuickPane", () => {
bridgeMock([defaultModels]); bridgeMock([defaultModels]);
const { container } = render(<QuickPane />); const { container } = render(<QuickPane />);
const model = await preparedTrigger(); const model = await preparedTrigger();
const thinking = screen.getByRole("button", { name: "Thinking Medium" }); const thinking = screen.getByRole("combobox", { name: "Thinking" });
const close = screen.getByRole("button", { name: "Close Quick Pane" }); const close = screen.getByRole("button", { name: "Close Quick Pane" });
for (const control of [model, thinking, close]) for (const control of [model, thinking, close])
expect(control).toHaveClass("quick-pane-toolbar-control"); expect(control).toHaveClass("quick-pane-toolbar-control");
expect(thinking).toHaveTextContent("Medium"); expect(thinking).toHaveValue("medium");
expect( expect(
within(container.querySelector(".quick-pane-toolbar")!).queryByRole( within(container.querySelector(".quick-pane-toolbar")!).queryByRole(
"button", "button",
@@ -275,7 +275,7 @@ describe("QuickPane", () => {
const toolbar = container.querySelector(".quick-pane-toolbar")!; const toolbar = container.querySelector(".quick-pane-toolbar")!;
const controls = [ const controls = [
await preparedTrigger(), await preparedTrigger(),
screen.getByRole("button", { name: "Thinking Medium" }), screen.getByRole("combobox", { name: "Thinking" }),
screen.getByRole("button", { name: "Close Quick Pane" }), screen.getByRole("button", { name: "Close Quick Pane" }),
]; ];
@@ -296,9 +296,7 @@ describe("QuickPane", () => {
fireEvent.pointerDown( fireEvent.pointerDown(
screen.getByRole("button", { name: "Close Quick Pane" }), screen.getByRole("button", { name: "Close Quick Pane" }),
); );
fireEvent.pointerDown( fireEvent.pointerDown(screen.getByRole("combobox", { name: "Thinking" }));
screen.getByRole("button", { name: "Thinking Medium" }),
);
expect(mocks.startDragging).toHaveBeenCalledTimes(1); expect(mocks.startDragging).toHaveBeenCalledTimes(1);
}); });
@@ -449,9 +447,9 @@ describe("QuickPane", () => {
}); });
render(<QuickPane />); render(<QuickPane />);
await preparedTrigger(); await preparedTrigger();
expect( expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveValue(
screen.getByRole("button", { name: "Thinking High" }), "high",
).toBeInTheDocument(); );
await ask("Inspect"); await ask("Inspect");
await waitFor(() => await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", { expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", {
@@ -481,14 +479,13 @@ describe("QuickPane", () => {
); );
render(<QuickPane />); render(<QuickPane />);
await preparedTrigger(); await preparedTrigger();
const thinking = screen.getByRole("button", { name: "Thinking Off" }); const thinking = screen.getByRole("combobox", { name: "Thinking" });
await userEvent.click(thinking);
expect( expect(
within(screen.getByRole("listbox", { name: "Thinking options" })) within(thinking)
.getAllByRole("option") .getAllByRole("option")
.map((option) => option.textContent), .map((option) => option.textContent),
).toEqual(["Off", "High"]); ).toEqual(["Off", "High"]);
await userEvent.click(screen.getByRole("option", { name: "High" })); fireEvent.change(thinking, { target: { value: "high" } });
await ask("Inspect"); await ask("Inspect");
await waitFor(() => await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", { expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", {
@@ -498,7 +495,7 @@ describe("QuickPane", () => {
); );
}); });
it("closes thinking menu while Ctrl+N replaces chat", async () => { it("disables thinking selection while Ctrl+N replaces chat", async () => {
const closing = deferred<void>(); const closing = deferred<void>();
const tracker = bridgeMock([defaultModels, defaultModels]); const tracker = bridgeMock([defaultModels, defaultModels]);
const baseImplementation = mocks.invoke.getMockImplementation(); const baseImplementation = mocks.invoke.getMockImplementation();
@@ -510,17 +507,10 @@ describe("QuickPane", () => {
); );
render(<QuickPane />); render(<QuickPane />);
await preparedTrigger(); await preparedTrigger();
const thinking = screen.getByRole("button", { name: "Thinking Medium" }); const thinking = screen.getByRole("combobox", { name: "Thinking" });
await userEvent.click(thinking);
expect(
screen.getByRole("listbox", { name: "Thinking options" }),
).toBeInTheDocument();
fireEvent.keyDown(window, { key: "n", ctrlKey: true, cancelable: true }); fireEvent.keyDown(window, { key: "n", ctrlKey: true, cancelable: true });
await waitFor(() => expect(thinking).toBeDisabled()); await waitFor(() => expect(thinking).toBeDisabled());
expect(
screen.queryByRole("listbox", { name: "Thinking options" }),
).toBeNull();
await act(async () => closing.resolve()); await act(async () => closing.resolve());
await waitFor(() => expect(tracker.runtimeCount()).toBe(2)); await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
-1
View File
@@ -1179,7 +1179,6 @@ export default function QuickPane() {
<ThinkingSelector <ThinkingSelector
className="quick-pane-thinking" className="quick-pane-thinking"
controlClassName="quick-pane-toolbar-control" controlClassName="quick-pane-toolbar-control"
placement="down"
model={selectedCatalogModel} model={selectedCatalogModel}
value={thinkingLevel} value={thinkingLevel}
disabled={catalogOverlayActive || runActive || chatReplacing} disabled={catalogOverlayActive || runActive || chatReplacing}
+2 -8
View File
@@ -533,16 +533,10 @@ body {
font-size: 12px; font-size: 12px;
} }
.quick-pane-exchange .extension-backdrop,
.quick-pane-exchange .extension { .quick-pane-exchange .extension {
position: static; width: min(400px, calc(100cqw - 24px));
display: block; max-height: calc(100cqh - 48px);
padding: 7px 9px; padding: 7px 9px;
background: transparent;
}
.quick-pane-exchange .extension-backdrop {
flex: 0 0 auto;
border: 1px solid #65533e; border: 1px solid #65533e;
border-radius: 7px; border-radius: 7px;
background: #302b25; background: #302b25;
+10
View File
@@ -1,4 +1,14 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest"; import { afterEach } from "vitest";
if (!HTMLDialogElement.prototype.showModal) {
HTMLDialogElement.prototype.showModal = function showModal() {
this.open = true;
};
HTMLDialogElement.prototype.close = function close() {
this.open = false;
};
}
afterEach(cleanup); afterEach(cleanup);
+5 -12
View File
@@ -1,5 +1,4 @@
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationWorkspace } from "./components/ConversationWorkspace"; import { ConversationWorkspace } from "./components/ConversationWorkspace";
import QuickPane from "./quick-pane/QuickPane"; import QuickPane from "./quick-pane/QuickPane";
@@ -128,18 +127,12 @@ describe("thinking selector parity", () => {
"matches Pi %s semantics in Conversation Workspace and Quick Pane", "matches Pi %s semantics in Conversation Workspace and Quick Pane",
async (_name, metadata, expected) => { async (_name, metadata, expected) => {
renderSurfaces({ provider: "provider", id: "model", ...metadata }); renderSurfaces({ provider: "provider", id: "model", ...metadata });
const user = userEvent.setup(); const selectors = await screen.findAllByRole("combobox", {
const triggers = await screen.findAllByRole("button", { name: "Thinking",
name: /Thinking /,
}); });
expect(triggers).toHaveLength(2); expect(selectors).toHaveLength(2);
for (const trigger of triggers) { for (const selector of selectors)
await user.click(trigger); expect(options(selector)).toEqual(expected);
expect(options(screen.getByRole("listbox", { name: "Thinking options" }))).toEqual(
expected,
);
await user.click(trigger);
}
}, },
); );
}); });
-3
View File
@@ -10,9 +10,6 @@ const mocks = vi.hoisted(() => ({
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({}),
}));
vi.mock("./persistence", () => ({ vi.mock("./persistence", () => ({
legacyPreferences: () => ({}), legacyPreferences: () => ({}),
loadPresentation: async () => ({ loadPresentation: async () => ({
-3
View File
@@ -1,6 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useReducer, useRef } from "react"; import { useCallback, useEffect, useReducer, useRef } from "react";
import { initialWorkspaceState, workspaceReducer } from "./reducer"; import { initialWorkspaceState, workspaceReducer } from "./reducer";
import { import {
@@ -289,7 +288,6 @@ export function useWorkspace() {
}); });
} }
})(); })();
const appWindow = getCurrentWindow();
const visibility = () => const visibility = () =>
dispatch({ dispatch({
type: "setWindowActive", type: "setWindowActive",
@@ -310,7 +308,6 @@ export function useWorkspace() {
window.removeEventListener("blur", visibility); window.removeEventListener("blur", visibility);
document.removeEventListener("visibilitychange", visibility); document.removeEventListener("visibilitychange", visibility);
Object.values(timers.current).forEach(clearTimeout); Object.values(timers.current).forEach(clearTimeout);
void appWindow;
}; };
}, [consumePromotedRuntime, refresh, scheduleSnapshot]); }, [consumePromotedRuntime, refresh, scheduleSnapshot]);