6 Commits

Author SHA1 Message Date
alex bab754714e test(quick-pane): cover settings load 2026-08-25 19:27:00 +02:00
alex 67063464e7 feat(quick-pane): refine spotlight workflow 2026-08-25 19:23:59 +02:00
alex dd23898769 feat(quick-pane): merge constrained agent window 2026-08-25 18:34:29 +02:00
alex 612b146fd6 feat(status-ui): add compact session status strip 2026-08-25 18:32:33 +02:00
alex 4f57114f19 feat(quick-pane): add constrained agent window 2026-08-25 18:30:39 +02:00
alex 166b065ad4 feat(status-ui): improve concurrent session workflows
Add in-place session renewal, searchable model selection, clearer progress feedback, tool-result previews, and per-session presentation state.

Keep commands, errors, loading, and lifecycle indicators attached to their owning runtime so parallel tabs cannot interfere.
2026-08-25 14:56:42 +02:00
32 changed files with 3596 additions and 519 deletions
+66
View File
@@ -0,0 +1,66 @@
# Quick Pane v1 Design
## Goal
Quick Pane is a small native window opened by a desktop/window-manager hotkey. It accepts one short request and returns either a useful answer or an explicit escalation within 60 seconds.
## Scope
- Cold-launchable, independent Tauri window.
- Compact request input, quick-model picker, and workspace picker.
- Configured default workspace, quick model, follow-up model, supplemental prompt, time limit, and read-only tool limit.
- Ephemeral Quick Pane UI state and best-effort runtime cleanup.
- Advisory read-only/no-subagent instructions, inline permission/clarification prompts, and explicit normal-session handoff.
## Non-goals
- Persisted quick history.
- Named profiles.
- App-managed WM binding writes.
- Trusted prevention of mutable tools or direct edits.
- Trusted prevention of subagents.
- A memory-only Pi runtime; Pi may write temporary session data while running.
## Invocation
Desktop/WM configuration owns the hotkey so it works while Pi GUI is absent. Settings displays and copies the required command; it does not register or modify system bindings.
The app creates or focuses a dedicated small Quick Pane window. `Esc` closes it and discards quick-only state.
## Request lifecycle
1. Pane chooses configured default workspace and quick model, with per-request overrides.
2. It starts an isolated Pi RPC runtime in the chosen workspace.
3. Immutable prompt text instructs one request, read-only inspection, zero subagents, configured wall-clock budget, and configured tool-call budget. Current Pi RPC cannot trusted-enforce those instructions; event-derived caps and cleanup are best effort.
4. Supplemental user instructions append beneath those prompt guardrails.
5. Pane streams answer, tool status, and inline extension requests.
6. On success it displays final answer. On a cap it preserves partial answer, explains the cap, and offers handoff.
## Escalation
Mutations, subagents, multi-step work, and likely-over-budget work preflight-escalate. The quick agent explains why. The user clicks **Continue in full session** to open a new normal session with:
- original request;
- escalation note or partial findings;
- selected workspace; and
- configured follow-up model.
No full session opens automatically.
## Permissions and privacy
Quick Pane surfaces Pi extension `select`, `confirm`, `input`, and `editor` requests inline. User explicitly answers every prompt. Existing policy remains authoritative: ordinary workspace reads may be allowed, external-directory reads may ask, and sensitive paths may deny.
Quick Pane UI request, output, and transcript stay in memory only. Closing pane removes them and triggers best-effort quick-runtime cleanup. Pi may write temporary session data while the child runs. A deliberate handoff persists only in newly created normal-session history.
## Failure handling
Unavailable models, startup failures, and crashes render inline actionable errors: retry, pick another quick model, or open normal session. No silent fallback model is used.
## Settings validation
Settings reject invalid model, workspace, custom prompt, or numeric limit. Failed save keeps previously stored configuration and marks invalid fields.
## Acceptance
From cold or hot desktop state, the hotkey produces an answer or explicit escalation within 60 seconds. Automated component, bridge, persistence, and native tests cover behavior; a manual cold-launch/hotkey smoke test verifies desktop integration.
+43
View File
@@ -0,0 +1,43 @@
# Quick Pane v1 Implementation Plan
## 1. Define persisted configuration
- Add Quick Pane settings types, defaults, validation, serialization, and migration-safe native storage.
- Fields: default workspace, quick model, follow-up model, supplemental prompt, maximum seconds, maximum read-only tool calls.
- Preserve current configuration when validation or save fails.
## 2. Add native window and launch contract
- Extend Tauri configuration and Rust window management with a dedicated Quick Pane window label.
- Add command-line action used by external WM hotkey to create/show/focus pane, including cold launch.
- Keep `--toggle` and ordinary `--new` behavior unchanged.
## 3. Build Quick Pane React surface
- Add isolated state reducer/component for request, selected model/workspace, progress, final answer, partial answer, escalation, extension UI, and errors.
- Keep pane transcript in component memory only and request best-effort runtime/session cleanup on close.
- Add Settings controls and copyable WM launch command.
## 4. Add constrained bridge runtime mode
- Extend runtime creation with Quick Pane metadata and ephemeral session lifecycle.
- Build immutable advisory quick-agent instructions around supplemental custom prompt.
- Use observed tool events and wall-clock cancellation for best-effort caps; document that current Pi RPC cannot trusted-enforce read-only/no-subagent policy or memory-only runtime sessions.
- Instruct preflight escalation for mutation, subagent, multi-step, or likely-over-budget requests.
## 5. Route interaction and handoff
- Reuse existing extension request/response protocol for inline Quick Pane permission and clarification dialogs.
- On explicit continuation, create normal runtime using workspace and follow-up model.
- Prefill it with original request plus escalation note/partial findings.
## 6. Test and document
- Add unit tests for settings validation/persistence and guardrail classification.
- Add component tests for pane states, picker behavior, inline prompts, error/retry, and cleanup.
- Add Rust/bridge tests for window command routing, budget cancellation, ephemeral cleanup, and handoff payload.
- Add manual WM hotkey/cold-launch instructions and smoke checklist.
## Sequencing
Land configuration/runtime contract first, then native window and UI, then handoff and test coverage. Preserve existing normal-session paths throughout.
+42
View File
@@ -0,0 +1,42 @@
# Quick Pane v1 Test Plan and QA Checklist
## Automated coverage
### Settings
- Valid defaults load when no Quick Pane configuration exists.
- Valid configuration round-trips through native storage.
- Invalid workspace, model, prompt, time, and tool values show field errors and retain prior saved state.
- Quick model picker affects quick request only; follow-up model remains configured default.
### Runtime guardrails
- Quick runtime receives immutable advisory read-only/no-subagent prompt text plus supplemental prompt.
- Mutation, subagent, multi-step, and likely-over-budget requests are instructed to preflight-escalate.
- Runtime applies best-effort configured 60-second/6-tool caps from observed events and exposes reason.
- Partial result survives a cap; pane transcript is removed and runtime cleanup requested after pane close.
- Tests/documentation state current limitations: Pi RPC cannot trusted-enforce prompt policy or guarantee memory-only temporary sessions.
### UI and bridge
- Quick Pane opens/focuses through dedicated native command without changing normal `--new` or `--toggle` behavior.
- Workspace and quick-model picker values reach runtime creation.
- Permission and clarification extension requests render inline and send correct response payloads.
- Retry, model change, and normal-session actions appear for startup/model/crash errors.
- Handoff creates normal session with original request, escalation note, workspace, and follow-up model.
## Manual smoke checklist
1. Configure default workspace, models, prompt, and 60-second/6-tool limits.
2. Add desktop/WM hotkey using Settings launch command.
3. From no running GUI process, invoke hotkey and verify Quick Pane opens/focuses.
4. Submit short answer-only request; verify result within 60 seconds.
5. Submit read-only inspection request; answer any external-read permission prompt inline.
6. Submit mutation or multi-step request; verify reason and **Continue in full session**.
7. Continue; verify normal session receives handoff and follow-up model.
8. Close Quick Pane; reopen and verify quick transcript is absent.
9. Select unavailable model or induce startup failure; verify inline recovery options.
## Exit criteria
All targeted automated tests pass. Manual cold-launch/hotkey smoke completes. No regressions in normal sessions, `--toggle`, or `--new` behavior.
+119 -85
View File
@@ -51,8 +51,7 @@ async function readSessionReference(sessionDir) {
); );
return { return {
sessionPath: sessionPath:
typeof value?.sessionPath === "string" && typeof value?.sessionPath === "string" && path.isAbsolute(value.sessionPath)
path.isAbsolute(value.sessionPath)
? value.sessionPath ? value.sessionPath
: undefined, : undefined,
worktreePath: worktreePath:
@@ -110,11 +109,7 @@ async function readSessionSummary(sessionPath, currentPath) {
firstMessage = sessionPreview(entry.message.content); firstMessage = sessionPreview(entry.message.content);
} }
} }
if ( if (!header || typeof header.id !== "string" || typeof header.cwd !== "string")
!header ||
typeof header.id !== "string" ||
typeof header.cwd !== "string"
)
return undefined; return undefined;
return { return {
path: sessionPath, path: sessionPath,
@@ -309,7 +304,9 @@ export function createAgentRegistry({
version: 2, version: 2,
migrated: true, migrated: true,
directories: [...managedWorktrees].sort(), directories: [...managedWorktrees].sort(),
runtimes: [...runtimesByRuntimeId.values()].map((runtime) => ({ runtimes: [...runtimesByRuntimeId.values()]
.filter((runtime) => !runtime.ephemeral)
.map((runtime) => ({
runtimeId: runtime.runtimeId, runtimeId: runtime.runtimeId,
worktreePath: runtime.worktreePath, worktreePath: runtime.worktreePath,
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}), ...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
@@ -326,7 +323,7 @@ export function createAgentRegistry({
} }
function addWorktreeRuntime(runtime) { function addWorktreeRuntime(runtime) {
managedWorktrees.add(runtime.worktreePath); if (!runtime.ephemeral) managedWorktrees.add(runtime.worktreePath);
let runtimes = runtimesByWorktreePath.get(runtime.worktreePath); let runtimes = runtimesByWorktreePath.get(runtime.worktreePath);
if (!runtimes) { if (!runtimes) {
runtimes = new Set(); runtimes = new Set();
@@ -380,8 +377,7 @@ export function createAgentRegistry({
} }
function enqueue(runtime, operation) { function enqueue(runtime, operation) {
if (stopping) if (stopping) return Promise.reject(new Error("agent registry is stopping"));
return Promise.reject(new Error("agent registry is stopping"));
if ( if (
runtime.state === "closing" || runtime.state === "closing" ||
runtime.state === "stopped" || runtime.state === "stopped" ||
@@ -420,10 +416,7 @@ export function createAgentRegistry({
} catch (error) { } catch (error) {
if (error?.code !== "ENOENT" || !allowMissing) throw error; if (error?.code !== "ENOENT" || !allowMissing) throw error;
const resolvedParent = await realpath(path.dirname(sessionPath)); const resolvedParent = await realpath(path.dirname(sessionPath));
resolvedSessionPath = path.join( resolvedSessionPath = path.join(resolvedParent, path.basename(sessionPath));
resolvedParent,
path.basename(sessionPath),
);
} }
const relative = path.relative(resolvedSessionDir, resolvedSessionPath); const relative = path.relative(resolvedSessionDir, resolvedSessionPath);
if ( if (
@@ -461,9 +454,7 @@ export function createAgentRegistry({
resolved = candidate; resolved = candidate;
} catch (error) { } catch (error) {
if (error?.code !== "ENOENT") if (error?.code !== "ENOENT")
throw new Error( throw new Error("Pi reported a session outside its managed directory");
"Pi reported a session outside its managed directory",
);
} }
} }
if (expectedSessionPath && resolved !== expectedSessionPath) if (expectedSessionPath && resolved !== expectedSessionPath)
@@ -483,8 +474,7 @@ export function createAgentRegistry({
throw new Error("session runtime is closing"); throw new Error("session runtime is closing");
if (typeof state.sessionName === "string") if (typeof state.sessionName === "string")
runtime.sessionName = state.sessionName; runtime.sessionName = state.sessionName;
if (typeof state.sessionId === "string") if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
runtime.sessionId = state.sessionId;
if (resolved) { if (resolved) {
if ( if (
runtime.sessionPath && runtime.sessionPath &&
@@ -532,12 +522,8 @@ export function createAgentRegistry({
} }
} }
if (type === "queue") { if (type === "queue") {
const followUp = Array.isArray(event.followUp) const followUp = Array.isArray(event.followUp) ? event.followUp.length : 0;
? event.followUp.length const steering = Array.isArray(event.steering) ? event.steering.length : 0;
: 0;
const steering = Array.isArray(event.steering)
? event.steering.length
: 0;
runtime.queueCount = runtime.queueCount =
typeof event.pendingMessageCount === "number" typeof event.pendingMessageCount === "number"
? event.pendingMessageCount ? event.pendingMessageCount
@@ -553,8 +539,7 @@ export function createAgentRegistry({
const method = event.method; const method = event.method;
if (["select", "confirm", "input", "editor"].includes(method)) { if (["select", "confirm", "input", "editor"].includes(method)) {
runtime.attention = true; runtime.attention = true;
if (typeof event.id === "string") if (typeof event.id === "string") runtime.extensions.set(event.id, event);
runtime.extensions.set(event.id, event);
} }
} }
const message = event.message; const message = event.message;
@@ -573,6 +558,7 @@ export function createAgentRegistry({
sessionPath, sessionPath,
sessionId, sessionId,
legacyDefault = false, legacyDefault = false,
ephemeral = false,
openedAt = new Date().toISOString(), openedAt = new Date().toISOString(),
}) { }) {
return { return {
@@ -583,6 +569,7 @@ export function createAgentRegistry({
sessionPath, sessionPath,
sessionId, sessionId,
legacyDefault, legacyDefault,
ephemeral,
openedAt, openedAt,
state: "starting", state: "starting",
stateVersion: 0, stateVersion: 0,
@@ -624,8 +611,7 @@ export function createAgentRegistry({
const state = await adapter.send({ type: "get_state" }); const state = await adapter.send({ type: "get_state" });
await updateRuntimeIdentity(runtime, state, { adapter }); await updateRuntimeIdentity(runtime, state, { adapter });
}).catch((error) => { }).catch((error) => {
if (runtime.stopped || runtime.state === "closing" || stopping) if (runtime.stopped || runtime.state === "closing" || stopping) return;
return;
runtime.error = { runtime.error = {
code: "identity_refresh", code: "identity_refresh",
message: error.message, message: error.message,
@@ -719,13 +705,16 @@ export function createAgentRegistry({
sessionPath, sessionPath,
sessionId, sessionId,
legacyDefault = false, legacyDefault = false,
ephemeral = false,
openedAt, openedAt,
}, },
{ restore = false } = {}, { restore = false } = {},
) { ) {
if (stopping) throw new Error("agent registry is stopping"); if (stopping) throw new Error("agent registry is stopping");
const canonicalPath = await realpath(worktreePath); const canonicalPath = await realpath(worktreePath);
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath); const sessionDir = ephemeral
? path.join(sessionRoot, "quick", runtimeId)
: sessionDirectoryFor(sessionRoot, canonicalPath);
await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE }); await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE });
await chmod(sessionDir, SESSION_DIRECTORY_MODE); await chmod(sessionDir, SESSION_DIRECTORY_MODE);
let ownedPath; let ownedPath;
@@ -757,6 +746,7 @@ export function createAgentRegistry({
sessionPath: ownedPath, sessionPath: ownedPath,
sessionId, sessionId,
legacyDefault, legacyDefault,
ephemeral,
openedAt, openedAt,
}); });
if (stopping) throw new Error("agent registry is stopping"); if (stopping) throw new Error("agent registry is stopping");
@@ -778,7 +768,7 @@ export function createAgentRegistry({
}); });
try { try {
await launch(runtime); await launch(runtime);
await persistWorkspace(); if (!runtime.ephemeral) await persistWorkspace();
publishWorkspace("runtime_opened", runtime, { publishWorkspace("runtime_opened", runtime, {
runtime: publicRuntime(runtime), runtime: publicRuntime(runtime),
}); });
@@ -829,8 +819,7 @@ export function createAgentRegistry({
} }
function openRuntime(record, options) { function openRuntime(record, options) {
if (stopping) if (stopping) return Promise.reject(new Error("agent registry is stopping"));
return Promise.reject(new Error("agent registry is stopping"));
const operation = openRuntimeInternal(record, options); const operation = openRuntimeInternal(record, options);
pendingOpenOperations.add(operation); pendingOpenOperations.add(operation);
void operation void operation
@@ -867,14 +856,8 @@ export function createAgentRegistry({
let canonicalSession; let canonicalSession;
if (record.sessionPath) { if (record.sessionPath) {
const canonicalWorktree = await realpath(record.worktreePath); const canonicalWorktree = await realpath(record.worktreePath);
const sessionDir = sessionDirectoryFor( const sessionDir = sessionDirectoryFor(sessionRoot, canonicalWorktree);
sessionRoot, canonicalSession = await ownedSessionPath(sessionDir, record.sessionPath);
canonicalWorktree,
);
canonicalSession = await ownedSessionPath(
sessionDir,
record.sessionPath,
);
} }
prepared.push({ record, canonicalSession }); prepared.push({ record, canonicalSession });
} catch (error) { } catch (error) {
@@ -974,7 +957,7 @@ export function createAgentRegistry({
// Remove desired-open/UI intent now, but retain the session lease until // Remove desired-open/UI intent now, but retain the session lease until
// the old child can no longer write to its JSONL file. // the old child can no longer write to its JSONL file.
unindexRuntime(runtime, { releaseSessionLease: false }); unindexRuntime(runtime, { releaseSessionLease: false });
await persistWorkspace(); if (!runtime.ephemeral) await persistWorkspace();
runtime.stopped = true; runtime.stopped = true;
runtime.supervisor?.stop(); runtime.supervisor?.stop();
let stopConfirmed = true; let stopConfirmed = true;
@@ -1002,6 +985,8 @@ export function createAgentRegistry({
} }
} }
if (stopConfirmed) releaseClosedSessionLease(runtime); if (stopConfirmed) releaseClosedSessionLease(runtime);
if (runtime.ephemeral && stopConfirmed)
await rm(runtime.sessionDir, { recursive: true, force: true }).catch(() => {});
runtime.state = "stopped"; runtime.state = "stopped";
publishWorkspace("runtime_closed", runtime, { publishWorkspace("runtime_closed", runtime, {
runtimeId: runtime.runtimeId, runtimeId: runtime.runtimeId,
@@ -1057,6 +1042,13 @@ export function createAgentRegistry({
runtime.sessionId = undefined; runtime.sessionId = undefined;
runtime.sessionName = undefined; runtime.sessionName = undefined;
runtime.firstMessage = undefined; runtime.firstMessage = undefined;
runtime.extensions.clear();
runtime.queueCount = 0;
runtime.activeTool = undefined;
runtime.error = undefined;
runtime.errorAttention = false;
runtime.attention = false;
runtime.lastActivity = new Date().toISOString();
if (runtime.legacyDefault) if (runtime.legacyDefault)
await persistSessionReference( await persistSessionReference(
runtime.sessionDir, runtime.sessionDir,
@@ -1066,6 +1058,12 @@ export function createAgentRegistry({
await persistWorkspace(); await persistWorkspace();
const state = await runtime.adapter.send({ type: "get_state" }); const state = await runtime.adapter.send({ type: "get_state" });
await updateRuntimeIdentity(runtime, state, { adapter: runtime.adapter }); await updateRuntimeIdentity(runtime, state, { adapter: runtime.adapter });
runtime.state = state?.data?.isStreaming ? "streaming" : "idle";
runtime.stateVersion += 1;
publishAgent(runtime, "agent_state", { state: runtime.state });
publishWorkspace("runtime_renewed", runtime, {
runtime: publicRuntime(runtime),
});
return response; return response;
} }
@@ -1105,17 +1103,14 @@ export function createAgentRegistry({
workingCount: runtimes.filter((entry) => entry.state === "streaming") workingCount: runtimes.filter((entry) => entry.state === "streaming")
.length, .length,
attentionCount: runtimes.filter((entry) => entry.attention).length, attentionCount: runtimes.filter((entry) => entry.attention).length,
recoveringCount: runtimes.filter( recoveringCount: runtimes.filter((entry) => entry.state === "recovering")
(entry) => entry.state === "recovering", .length,
).length,
errorCount: runtimes.filter((entry) => errorCount: runtimes.filter((entry) =>
["error", "failed"].includes(entry.state), ["error", "failed"].includes(entry.state),
).length, ).length,
runtimes, runtimes,
})) }))
.sort((left, right) => .sort((left, right) => left.worktreePath.localeCompare(right.worktreePath)),
left.worktreePath.localeCompare(right.worktreePath),
),
...(manifestIssue ? { issue: manifestIssue } : {}), ...(manifestIssue ? { issue: manifestIssue } : {}),
}; };
} }
@@ -1130,10 +1125,7 @@ export function createAgentRegistry({
const loaded = await workspaceStore.load(); const loaded = await workspaceStore.load();
manifestIssue = loaded.issue; manifestIssue = loaded.issue;
if (loaded.migrating) { if (loaded.migrating) {
const sessionDir = sessionDirectoryFor( const sessionDir = sessionDirectoryFor(sessionRoot, canonicalHomeWorktree);
sessionRoot,
canonicalHomeWorktree,
);
await mkdir(sessionDir, { await mkdir(sessionDir, {
recursive: true, recursive: true,
mode: SESSION_DIRECTORY_MODE, mode: SESSION_DIRECTORY_MODE,
@@ -1143,9 +1135,7 @@ export function createAgentRegistry({
runtimeId: randomUUID(), runtimeId: randomUUID(),
worktreePath: canonicalHomeWorktree, worktreePath: canonicalHomeWorktree,
legacyDefault: true, legacyDefault: true,
...(reference.sessionPath ...(reference.sessionPath ? { sessionPath: reference.sessionPath } : {}),
? { sessionPath: reference.sessionPath }
: {}),
}; };
if (reference.sessionPath) await restoreRecords([migrationRecord]); if (reference.sessionPath) await restoreRecords([migrationRecord]);
else await openRuntime(migrationRecord); else await openRuntime(migrationRecord);
@@ -1179,8 +1169,7 @@ export function createAgentRegistry({
...(runtimesByWorktreePath.get(canonicalPath) ?? []), ...(runtimesByWorktreePath.get(canonicalPath) ?? []),
].filter((runtime) => runtime.agentId); ].filter((runtime) => runtime.agentId);
const existing = const existing =
liveRuntimes.find((runtime) => runtime.legacyDefault) ?? liveRuntimes.find((runtime) => runtime.legacyDefault) ?? liveRuntimes[0];
liveRuntimes[0];
if (existing) { if (existing) {
if (!existing.legacyDefault) { if (!existing.legacyDefault) {
await setLegacyDefault(existing); await setLegacyDefault(existing);
@@ -1197,25 +1186,39 @@ export function createAgentRegistry({
const opened = await openRuntime({ const opened = await openRuntime({
worktreePath: canonicalPath, worktreePath: canonicalPath,
legacyDefault: true, legacyDefault: true,
...(reference.sessionPath ...(reference.sessionPath ? { sessionPath: reference.sessionPath } : {}),
? { sessionPath: reference.sessionPath }
: {}),
}); });
return publicAgent(getRuntime(opened.runtimeId)); return publicAgent(getRuntime(opened.runtimeId));
}, },
async createSessionRuntime(worktreePath) { async createSessionRuntime(worktreePath) {
return openRuntime({ worktreePath }); return openRuntime({ worktreePath });
}, },
async createQuickRuntime(worktreePath) {
return openRuntime({ worktreePath, ephemeral: true });
},
async openSessionRuntime(worktreePath, sessionPath) { async openSessionRuntime(worktreePath, sessionPath) {
return openRuntime({ worktreePath, sessionPath }); return openRuntime({ worktreePath, sessionPath });
}, },
closeSessionRuntime: closeRuntime, closeSessionRuntime: closeRuntime,
async closeQuickRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (!runtime.ephemeral) throw new Error("runtime is not a quick runtime");
return closeRuntime(runtimeId);
},
async getModelCatalog() {
const runtime = [...runtimesByAgentId.values()].find(
(candidate) => candidate.adapter,
);
if (!runtime?.adapter) return [];
const response = await enqueue(runtime, () =>
runtime.adapter.send({ type: "get_available_models" }),
);
return response?.data?.models ?? [];
},
listAgents() { listAgents() {
return [...runtimesByAgentId.values()] return [...runtimesByAgentId.values()]
.map(publicAgent) .map(publicAgent)
.sort((left, right) => .sort((left, right) => left.worktreePath.localeCompare(right.worktreePath));
left.worktreePath.localeCompare(right.worktreePath),
);
}, },
async listDirectories() { async listDirectories() {
let entries = []; let entries = [];
@@ -1240,9 +1243,9 @@ export function createAgentRegistry({
]); ]);
return [...paths] return [...paths]
.map((worktreePath) => { .map((worktreePath) => {
const runtime = [ const runtime = [...(runtimesByWorktreePath.get(worktreePath) ?? [])].find(
...(runtimesByWorktreePath.get(worktreePath) ?? []), (entry) => entry.agentId,
].find((entry) => entry.agentId); );
return { return {
worktreePath, worktreePath,
state: runtime?.state ?? "inactive", state: runtime?.state ?? "inactive",
@@ -1250,9 +1253,7 @@ export function createAgentRegistry({
...(runtime ? { agentId: runtime.agentId } : {}), ...(runtime ? { agentId: runtime.agentId } : {}),
}; };
}) })
.sort((left, right) => .sort((left, right) => left.worktreePath.localeCompare(right.worktreePath));
left.worktreePath.localeCompare(right.worktreePath),
);
}, },
async forgetDirectory(worktreePath) { async forgetDirectory(worktreePath) {
let canonicalPath = path.normalize(worktreePath); let canonicalPath = path.normalize(worktreePath);
@@ -1297,9 +1298,8 @@ export function createAgentRegistry({
bridgeInstanceId, bridgeInstanceId,
latestSeq: workspaceSequence, latestSeq: workspaceSequence,
openCount: runtimes.length, openCount: runtimes.length,
workingCount: runtimes.filter( workingCount: runtimes.filter((runtime) => runtime.state === "streaming")
(runtime) => runtime.state === "streaming", .length,
).length,
attentionCount: runtimes.filter((runtime) => runtime.attention).length, attentionCount: runtimes.filter((runtime) => runtime.attention).length,
recoveringCount: runtimes.filter( recoveringCount: runtimes.filter(
(runtime) => runtime.state === "recovering", (runtime) => runtime.state === "recovering",
@@ -1323,11 +1323,11 @@ export function createAgentRegistry({
}; };
return enqueue(runtime, async () => { return enqueue(runtime, async () => {
const adapter = runtime.adapter; const adapter = runtime.adapter;
const safe = async (command, fallback) => { const safe = async (command, fallback, { includeError = false } = {}) => {
try { try {
return await adapter.send({ type: command }); return await adapter.send({ type: command });
} catch { } catch (error) {
return fallback; return includeError ? { ...fallback, error: String(error) } : fallback;
} }
}; };
const state = await safe("get_state", { data: {} }); const state = await safe("get_state", { data: {} });
@@ -1336,9 +1336,11 @@ export function createAgentRegistry({
data: { messages: [] }, data: { messages: [] },
}); });
const commands = await safe("get_commands", { data: { commands: [] } }); const commands = await safe("get_commands", { data: { commands: [] } });
const models = await safe("get_available_models", { const models = await safe(
data: { models: [] }, "get_available_models",
}); { data: { models: [] } },
{ includeError: true },
);
await updateRuntimeIdentity(runtime, state, { adapter }); await updateRuntimeIdentity(runtime, state, { adapter });
return { return {
bridgeInstanceId, bridgeInstanceId,
@@ -1392,10 +1394,23 @@ export function createAgentRegistry({
return publicAgent(runtime); return publicAgent(runtime);
}); });
}, },
async renewSession(agentId) {
const runtime = getAgent(agentId);
return enqueue(runtime, async () => {
if (!runtime.adapter) throw new Error("Pi is not currently running");
if (runtime.state === "streaming" || runtime.queueCount > 0)
await runtime.adapter.send({ type: "abort" });
const response = await beginLegacyNewSession(runtime);
if (response?.data?.cancelled)
throw new Error("Pi cancelled the session renewal");
return publicAgent(runtime);
});
},
async restart(agentId) { async restart(agentId) {
const runtime = getAgent(agentId); const runtime = getAgent(agentId);
return enqueue(runtime, async () => { return enqueue(runtime, async () => {
const replacedAdapter = runtime.adapter; const replacedAdapter = runtime.adapter;
if (!replacedAdapter) throw new Error("Pi is not currently running");
// Detach the old child before stopping it so late events cannot mutate // Detach the old child before stopping it so late events cannot mutate
// presentation state that belongs to the replacement generation. // presentation state that belongs to the replacement generation.
runtime.adapter = undefined; runtime.adapter = undefined;
@@ -1406,7 +1421,29 @@ export function createAgentRegistry({
runtime.attention = runtime.errorAttention; runtime.attention = runtime.errorAttention;
runtime.supervisor.markHealthy(); runtime.supervisor.markHealthy();
runtime.recoveryPromise = runtime.supervisor.handleUnexpectedExit(); runtime.recoveryPromise = runtime.supervisor.handleUnexpectedExit();
await runtime.recoveryPromise; const nextAdapter = await runtime.recoveryPromise;
if (!nextAdapter) {
runtime.state = "failed";
runtime.stateVersion += 1;
runtime.error = {
code: "restart_failed",
message: "Pi did not restart after the recovery attempts were exhausted",
};
runtime.errorAttention = true;
runtime.attention = true;
publishAgent(runtime, "agent_state", {
state: runtime.state,
error: runtime.error,
});
throw new Error(runtime.error.message);
}
runtime.error = undefined;
runtime.errorAttention = false;
runtime.attention = runtime.extensions.size > 0;
publishAgent(runtime, "agent_state", { state: runtime.state });
publishWorkspace("runtime_recovered", runtime, {
runtime: publicRuntime(runtime),
});
return publicAgent(runtime); return publicAgent(runtime);
}); });
}, },
@@ -1458,9 +1495,7 @@ export function createAgentRegistry({
operation === "extension_response" && operation === "extension_response" &&
!runtime.extensions.has(payload.requestId) !runtime.extensions.has(payload.requestId)
) )
throw new Error( throw new Error("extension request is not actionable for this runtime");
"extension request is not actionable for this runtime",
);
const response = await routeCommand( const response = await routeCommand(
runtime.adapter, runtime.adapter,
operation, operation,
@@ -1473,8 +1508,7 @@ export function createAgentRegistry({
} }
if (operation === "extension_response") { if (operation === "extension_response") {
runtime.extensions.delete(payload.requestId); runtime.extensions.delete(payload.requestId);
runtime.attention = runtime.attention = runtime.errorAttention || runtime.extensions.size > 0;
runtime.errorAttention || runtime.extensions.size > 0;
} }
return delivery ? { ...response, delivery } : response; return delivery ? { ...response, delivery } : response;
} catch (error) { } catch (error) {
+12
View File
@@ -40,12 +40,20 @@ export async function startBridgeService({
return registry.getWorkspace(); return registry.getWorkspace();
case "get_workspace_summary": case "get_workspace_summary":
return registry.getWorkspaceSummary(); return registry.getWorkspaceSummary();
case "get_model_catalog":
return { models: await registry.getModelCatalog() };
case "create_session_runtime": case "create_session_runtime":
return { return {
runtime: await registry.createSessionRuntime( runtime: await registry.createSessionRuntime(
request.payload.worktreePath, request.payload.worktreePath,
), ),
}; };
case "create_quick_runtime":
return {
runtime: await registry.createQuickRuntime(
request.payload.worktreePath,
),
};
case "open_session_runtime": case "open_session_runtime":
return { return {
runtime: await registry.openSessionRuntime( runtime: await registry.openSessionRuntime(
@@ -55,6 +63,8 @@ export async function startBridgeService({
}; };
case "close_session_runtime": case "close_session_runtime":
return registry.closeSessionRuntime(request.payload.runtimeId); return registry.closeSessionRuntime(request.payload.runtimeId);
case "close_quick_runtime":
return registry.closeQuickRuntime(request.payload.runtimeId);
case "list_directory_sessions": case "list_directory_sessions":
return { return {
sessions: await registry.listDirectorySessions( sessions: await registry.listDirectorySessions(
@@ -76,6 +86,8 @@ export async function startBridgeService({
return { agent: await registry.retry(request.agentId) }; return { agent: await registry.retry(request.agentId) };
case "restart": case "restart":
return { agent: await registry.restart(request.agentId) }; return { agent: await registry.restart(request.agentId) };
case "renew_session":
return { agent: await registry.renewSession(request.agentId) };
case "list_sessions": case "list_sessions":
return { sessions: await registry.listSessions(request.agentId) }; return { sessions: await registry.listSessions(request.agentId) };
default: default:
+5 -4
View File
@@ -11,9 +11,12 @@ const requestOperations = new Map([
["forget_directory", { agent: false, payload: "worktree" }], ["forget_directory", { agent: false, payload: "worktree" }],
["get_workspace", { agent: false, payload: "none" }], ["get_workspace", { agent: false, payload: "none" }],
["get_workspace_summary", { agent: false, payload: "none" }], ["get_workspace_summary", { agent: false, payload: "none" }],
["get_model_catalog", { agent: false, payload: "none" }],
["create_session_runtime", { agent: false, payload: "worktree" }], ["create_session_runtime", { agent: false, payload: "worktree" }],
["create_quick_runtime", { agent: false, payload: "worktree" }],
["open_session_runtime", { agent: false, payload: "worktreeSession" }], ["open_session_runtime", { agent: false, payload: "worktreeSession" }],
["close_session_runtime", { agent: false, payload: "runtime" }], ["close_session_runtime", { agent: false, payload: "runtime" }],
["close_quick_runtime", { agent: false, payload: "runtime" }],
["list_directory_sessions", { agent: false, payload: "worktree" }], ["list_directory_sessions", { agent: false, payload: "worktree" }],
["get_session_runtime_snapshot", { agent: false, payload: "runtime" }], ["get_session_runtime_snapshot", { agent: false, payload: "runtime" }],
["subscribe_workspace", { agent: false, payload: "cursor" }], ["subscribe_workspace", { agent: false, payload: "cursor" }],
@@ -32,6 +35,7 @@ const requestOperations = new Map([
["extension_response", { agent: true, payload: "extensionResponse" }], ["extension_response", { agent: true, payload: "extensionResponse" }],
["retry", { agent: true, payload: "none" }], ["retry", { agent: true, payload: "none" }],
["restart", { agent: true, payload: "none" }], ["restart", { agent: true, payload: "none" }],
["renew_session", { agent: true, payload: "none" }],
["get_available_models", { agent: true, payload: "none" }], ["get_available_models", { agent: true, payload: "none" }],
["get_commands", { agent: true, payload: "none" }], ["get_commands", { agent: true, payload: "none" }],
["set_session_name", { agent: true, payload: "sessionName" }], ["set_session_name", { agent: true, payload: "sessionName" }],
@@ -102,10 +106,7 @@ function assertString(value, field, { maxLength = 4096, pattern } = {}) {
); );
} }
if (pattern && !pattern.test(value)) if (pattern && !pattern.test(value))
throw new ProtocolError( throw new ProtocolError("invalid_message", `${field} has an invalid format`);
"invalid_message",
`${field} has an invalid format`,
);
return value; return value;
} }
+117 -13
View File
@@ -224,12 +224,10 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
startAdapter, startAdapter,
}); });
const starting = restored.start(); const starting = restored.start();
while (gates.length < 2) while (gates.length < 2) await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(gates.length, 2); assert.equal(gates.length, 2);
gates.splice(0).forEach((resolve) => resolve()); gates.splice(0).forEach((resolve) => resolve());
while (gates.length < 1) while (gates.length < 1) await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(gates.length, 1); assert.equal(gates.length, 1);
gates.splice(0).forEach((resolve) => resolve()); gates.splice(0).forEach((resolve) => resolve());
await starting; await starting;
@@ -290,11 +288,7 @@ test("serializes close behind an in-flight command and aborts the working runtim
test("forces a child kill and retains its lease until exit is confirmed", async () => { test("forces a child kill and retains its lease until exit is confirmed", async () => {
const paths = await fixture(); const paths = await fixture();
const saved = await sessionFile( const saved = await sessionFile(paths.sessionRoot, paths.home, "forced-close");
paths.sessionRoot,
paths.home,
"forced-close",
);
let killed; let killed;
let releaseStop; let releaseStop;
let blockedSavedAdapter = false; let blockedSavedAdapter = false;
@@ -445,6 +439,68 @@ test("legacy new session releases old identity and makes its history reopenable"
await registry.stop(); await registry.stop();
}); });
test("renews one runtime in place, aborts work, and clears its active identity", async () => {
const paths = await fixture();
const saved = await sessionFile(
paths.sessionRoot,
paths.home,
"renew-me",
"renew-me-id",
);
let currentState = {
sessionFile: saved,
sessionId: "renew-me-id",
sessionName: "Renew me",
};
const adapters = adapterFactory({ stateFor: () => currentState });
const registry = createAgentRegistry({
homeWorktree: paths.home,
sessionRoot: paths.sessionRoot,
startAdapter: adapters.startAdapter,
});
const original = await registry.start();
const adapter = adapters.calls[0].adapter;
const originalSend = adapter.send.bind(adapter);
adapter.send = async (command) => {
if (command.type === "new_session") currentState = {};
return originalSend(command);
};
adapters.calls[0].options.onEvent({
type: "extension_ui_request",
data: { event: { id: "stale-renew-request", method: "confirm" } },
});
adapters.calls[0].options.onEvent({
type: "agent_state",
data: { state: "streaming" },
});
const renewed = await registry.renewSession(original.id);
const active = registry.getWorkspace().directories[0].runtimes[0];
assert.equal(renewed.id, original.id);
assert.equal(renewed.runtimeId, original.runtimeId);
assert.equal(active.worktreePath, paths.home);
assert.equal(active.sessionPath, undefined);
assert.equal(active.sessionId, undefined);
assert.equal(active.label, "New session");
assert.equal(active.state, "idle");
assert.equal(active.attention, false);
assert.deepEqual(
adapter.sent.slice(-3).map((command) => command.type),
["abort", "new_session", "get_state"],
);
assert.equal(
registry
.workspaceEventsAfter(0)
.events.some((event) => event.type === "runtime_renewed"),
true,
);
currentState = { sessionFile: saved, sessionId: "renew-me-id" };
const reopened = await registry.openSessionRuntime(paths.home, saved);
assert.notEqual(reopened.runtimeId, original.runtimeId);
await registry.stop();
});
test("persists a first-prompt session when the runtime settles and awaits refresh on shutdown", async () => { test("persists a first-prompt session when the runtime settles and awaits refresh on shutdown", async () => {
const paths = await fixture(); const paths = await fixture();
const createdPath = await sessionFile( const createdPath = await sessionFile(
@@ -611,6 +667,28 @@ test("non-default runtime snapshots do not replace the legacy session pointer",
await registry.stop(); await registry.stop();
}); });
test("reports available-model RPC failures in runtime snapshots", async () => {
const paths = await fixture();
const adapters = adapterFactory();
const registry = createAgentRegistry({
homeWorktree: paths.home,
sessionRoot: paths.sessionRoot,
startAdapter: adapters.startAdapter,
});
const runtime = await registry.start();
const originalSend = adapters.calls[0].adapter.send;
adapters.calls[0].adapter.send = async function (command) {
if (command.type === "get_available_models")
throw new Error("model registry unavailable");
return originalSend.call(this, command);
};
const snapshot = await registry.getSessionRuntimeSnapshot(runtime.runtimeId);
assert.deepEqual(snapshot.models.data.models, []);
assert.match(snapshot.models.error, /model registry unavailable/);
await registry.stop();
});
test("retains a corrupt workspace manifest and reports its issue", async () => { test("retains a corrupt workspace manifest and reports its issue", async () => {
const paths = await fixture(); const paths = await fixture();
await mkdir(paths.sessionRoot, { recursive: true }); await mkdir(paths.sessionRoot, { recursive: true });
@@ -622,10 +700,7 @@ test("retains a corrupt workspace manifest and reports its issue", async () => {
startAdapter: adapterFactory().startAdapter, startAdapter: adapterFactory().startAdapter,
}); });
await registry.start(); await registry.start();
assert.equal( assert.equal(registry.getWorkspace().issue.code, "invalid_workspace_manifest");
registry.getWorkspace().issue.code,
"invalid_workspace_manifest",
);
assert.equal(await readFile(manifestPath, "utf8"), "{broken"); assert.equal(await readFile(manifestPath, "utf8"), "{broken");
await registry.stop(); await registry.stop();
}); });
@@ -1025,6 +1100,35 @@ test("manual restart ignores late extension dialogs from the replaced child", as
await registry.stop(); await registry.stop();
}); });
test("manual restart rejects exhausted recovery and marks the runtime failed", async () => {
const paths = await fixture();
const adapters = adapterFactory();
let starts = 0;
const registry = createAgentRegistry({
homeWorktree: paths.home,
sessionRoot: paths.sessionRoot,
startAdapter(options) {
starts += 1;
if (starts > 1) throw new Error("replacement failed to start");
return adapters.startAdapter(options);
},
maxRecoveryAttempts: 1,
recoveryDelayForAttempt: () => 0,
sleep: async () => {},
});
const runtime = await registry.start();
await assert.rejects(
registry.restart(runtime.id),
/Pi did not restart after the recovery attempts were exhausted/,
);
assert.equal(
registry.getWorkspace().directories[0].runtimes[0].state,
"failed",
);
await registry.stop();
});
test("child exit clears extension dialogs that recovery cannot answer", async () => { test("child exit clears extension dialogs that recovery cannot answer", async () => {
const paths = await fixture(); const paths = await fixture();
const adapters = adapterFactory(); const adapters = adapterFactory();
+11
View File
@@ -79,6 +79,15 @@ test("accepts bridge-managed directory and session operations", () => {
agentId: "agent-main", agentId: "agent-main",
}, },
); );
assert.deepEqual(
parseRequestFrame(request({ op: "renew_session", agentId: "agent-main" })),
{
version: "v1",
id: "request-1",
op: "renew_session",
agentId: "agent-main",
},
);
assert.deepEqual( assert.deepEqual(
parseRequestFrame( parseRequestFrame(
request({ request({
@@ -115,8 +124,10 @@ test("accepts additive multi-session runtime operations and numeric workspace cu
{ op: "get_workspace" }, { op: "get_workspace" },
{ op: "get_workspace_summary" }, { op: "get_workspace_summary" },
{ op: "create_session_runtime", payload: { worktreePath } }, { op: "create_session_runtime", payload: { worktreePath } },
{ op: "create_quick_runtime", payload: { worktreePath } },
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } }, { op: "open_session_runtime", payload: { worktreePath, sessionPath } },
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } }, { op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "close_quick_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "list_directory_sessions", payload: { worktreePath } }, { op: "list_directory_sessions", payload: { worktreePath } },
{ {
op: "get_session_runtime_snapshot", op: "get_session_runtime_snapshot",
+56
View File
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("Quick Pane polls snapshots, renders answers, and gates handoff", async () => {
const source = await readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
);
assert.match(source, /get_session_runtime_snapshot/);
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
assert.match(
source,
/<pre style=\{\{ whiteSpace: "pre-wrap" \}\}>\{answer\}<\/pre>/,
);
assert.match(
source,
/\{\(escalation \|\| error\) && \(\s*<button onClick=\{continueFull\}>Continue in full session<\/button>\s*\)\}/,
);
});
test("main Settings owns Quick Pane configuration while pane keeps request controls", async () => {
const [pane, app, persistence] = await Promise.all([
readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
),
readFile(new URL("../ui/src/App.tsx", import.meta.url), "utf8"),
readFile(
new URL("../ui/src/workspace/persistence.ts", import.meta.url),
"utf8",
),
]);
assert.match(app, /load_ui_state/);
assert.match(app, /save_ui_state/);
assert.match(app, /Save Quick Pane settings/);
assert.match(pane, /get_model_catalog/);
assert.match(pane, /set_thinking_level/);
assert.doesNotMatch(pane, /showSettings/);
assert.doesNotMatch(pane, /Save Quick Pane settings/);
assert.doesNotMatch(pane, /setRequest\(""\)/);
assert.match(persistence, /quickPane: current\.quickPane/);
});
test("Quick Pane routes extension responses through existing bridge command", async () => {
const source = await readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
);
assert.match(
source,
/<ExtensionDialog extension=\{extension\} onRespond=\{respond\} \/>/,
);
assert.match(source, /invoke\("respond_to_extension"/);
assert.match(source, /requestId: extension\.id/);
});
+6 -1
View File
@@ -18,11 +18,12 @@ test("wires the desktop to stable runtime workspace APIs", async () => {
"close_session_runtime", "close_session_runtime",
"list_directory_sessions", "list_directory_sessions",
"get_session_runtime_snapshot", "get_session_runtime_snapshot",
"renew_session",
"subscribe_workspace", "subscribe_workspace",
]) ])
assert.match(`${app}\n${hook}`, new RegExp(operation)); assert.match(`${app}\n${hook}`, new RegExp(operation));
assert.doesNotMatch(app, /switch_session/); assert.doesNotMatch(app, /switch_session/);
assert.doesNotMatch(app, /new_session/); assert.doesNotMatch(app, /["']new_session["']/);
assert.match(reducer, /WorkspaceState\["runtimesById"\]/); assert.match(reducer, /WorkspaceState\["runtimesById"\]/);
assert.match(reducer, /requestGeneration/); assert.match(reducer, /requestGeneration/);
assert.match(reducer, /loadedThroughSeq/); assert.match(reducer, /loadedThroughSeq/);
@@ -54,6 +55,10 @@ test("renders directory sidebar, accessible session tabs, warnings, and reduced
assert.match(app, /Many Pi sessions are open/); assert.match(app, /Many Pi sessions are open/);
assert.match(css, /prefers-reduced-motion/); assert.match(css, /prefers-reduced-motion/);
assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/); assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/);
assert.match(
css,
/\.session-row-content\s*>\s*strong\s*\{[^}]*color:\s*#f1f3f4/s,
);
}); });
test("preserves native session naming, compaction, composer, and extension responses", async () => { test("preserves native session naming, compaction, composer, and extension responses", async () => {
+3 -2
View File
@@ -1,9 +1,10 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for Pi Status windows",
"windows": [ "windows": [
"main" "main",
"quick-pane"
], ],
"permissions": [ "permissions": [
"core:default", "core:default",
+25
View File
@@ -203,6 +203,9 @@ pub async fn get_workspace(socket_path: &str) -> Result<Workspace, String> {
pub async fn get_workspace_summary(socket_path: &str) -> Result<WorkspaceSummary, String> { pub async fn get_workspace_summary(socket_path: &str) -> Result<WorkspaceSummary, String> {
typed_request(socket_path, "get_workspace_summary", None).await typed_request(socket_path, "get_workspace_summary", None).await
} }
pub async fn get_model_catalog(socket_path: &str) -> Result<Value, String> {
request(socket_path, "get_model_catalog", None, None).await
}
pub async fn create_session_runtime( pub async fn create_session_runtime(
socket_path: &str, socket_path: &str,
worktree_path: &str, worktree_path: &str,
@@ -214,6 +217,17 @@ pub async fn create_session_runtime(
) )
.await .await
} }
pub async fn create_quick_runtime(
socket_path: &str,
worktree_path: &str,
) -> Result<RuntimeResult, String> {
typed_request(
socket_path,
"create_quick_runtime",
Some(json!({ "worktreePath": worktree_path })),
)
.await
}
pub async fn open_session_runtime( pub async fn open_session_runtime(
socket_path: &str, socket_path: &str,
worktree_path: &str, worktree_path: &str,
@@ -237,6 +251,17 @@ pub async fn close_session_runtime(
) )
.await .await
} }
pub async fn close_quick_runtime(
socket_path: &str,
runtime_id: &str,
) -> Result<CloseRuntimeResult, String> {
typed_request(
socket_path,
"close_quick_runtime",
Some(json!({ "runtimeId": runtime_id })),
)
.await
}
pub async fn list_directory_sessions( pub async fn list_directory_sessions(
socket_path: &str, socket_path: &str,
worktree_path: &str, worktree_path: &str,
+54 -1
View File
@@ -45,6 +45,21 @@ fn socket_path() -> Result<String, String> {
bridge::default_socket_path() bridge::default_socket_path()
} }
fn requested_quick_pane(args: &[String]) -> bool {
args.iter().any(|arg| arg == "--quick")
}
fn show_quick_pane(app: &AppHandle) {
if let Some(main) = app.get_webview_window("main") {
let _ = main.hide();
}
if let Some(window) = app.get_webview_window("quick-pane") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
fn requested_new_worktree_argument(args: &[String]) -> Option<String> { fn requested_new_worktree_argument(args: &[String]) -> Option<String> {
args.iter() args.iter()
.position(|arg| arg == "--worktree") .position(|arg| arg == "--worktree")
@@ -107,11 +122,21 @@ async fn get_workspace_summary() -> Result<bridge::WorkspaceSummary, String> {
bridge::get_workspace_summary(&socket_path()?).await bridge::get_workspace_summary(&socket_path()?).await
} }
#[tauri::command]
async fn get_model_catalog() -> Result<Value, String> {
bridge::get_model_catalog(&socket_path()?).await
}
#[tauri::command] #[tauri::command]
async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> { async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
bridge::create_session_runtime(&socket_path()?, &worktree_path).await bridge::create_session_runtime(&socket_path()?, &worktree_path).await
} }
#[tauri::command]
async fn create_quick_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
bridge::create_quick_runtime(&socket_path()?, &worktree_path).await
}
#[tauri::command] #[tauri::command]
async fn open_session_runtime( async fn open_session_runtime(
worktree_path: String, worktree_path: String,
@@ -125,6 +150,11 @@ async fn close_session_runtime(runtime_id: String) -> Result<bridge::CloseRuntim
bridge::close_session_runtime(&socket_path()?, &runtime_id).await bridge::close_session_runtime(&socket_path()?, &runtime_id).await
} }
#[tauri::command]
async fn close_quick_runtime(runtime_id: String) -> Result<bridge::CloseRuntimeResult, String> {
bridge::close_quick_runtime(&socket_path()?, &runtime_id).await
}
#[tauri::command] #[tauri::command]
async fn list_directory_sessions( async fn list_directory_sessions(
worktree_path: String, worktree_path: String,
@@ -217,6 +247,11 @@ async fn restart(agent_id: String) -> Result<(), String> {
bridge::command(&socket_path()?, &agent_id, "restart").await bridge::command(&socket_path()?, &agent_id, "restart").await
} }
#[tauri::command]
async fn renew_session(agent_id: String) -> Result<(), String> {
bridge::command(&socket_path()?, &agent_id, "renew_session").await
}
#[tauri::command] #[tauri::command]
async fn set_model(agent_id: String, provider: String, model_id: String) -> Result<(), String> { async fn set_model(agent_id: String, provider: String, model_id: String) -> Result<(), String> {
bridge::set_model(&socket_path()?, &agent_id, &provider, &model_id).await bridge::set_model(&socket_path()?, &agent_id, &provider, &model_id).await
@@ -352,6 +387,10 @@ pub fn run() {
task: Mutex::new(None), task: Mutex::new(None),
}) })
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if requested_quick_pane(&args) {
show_quick_pane(app);
return;
}
launch_new_session(app.clone(), &args); launch_new_session(app.clone(), &args);
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
match window_action(&args, window.is_visible().unwrap_or(false)) { match window_action(&args, window.is_visible().unwrap_or(false)) {
@@ -366,15 +405,22 @@ pub fn run() {
} }
})) }))
.setup(move |app| { .setup(move |app| {
launch_new_session(app.handle().clone(), &launch_args); if requested_quick_pane(&launch_args) {
show_quick_pane(app.handle());
} else {
launch_new_session(app.handle().clone(), &launch_args);
}
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_workspace, get_workspace,
get_workspace_summary, get_workspace_summary,
get_model_catalog,
create_session_runtime, create_session_runtime,
create_quick_runtime,
open_session_runtime, open_session_runtime,
close_session_runtime, close_session_runtime,
close_quick_runtime,
list_directory_sessions, list_directory_sessions,
get_session_runtime_snapshot, get_session_runtime_snapshot,
load_ui_state, load_ui_state,
@@ -393,6 +439,7 @@ pub fn run() {
abort, abort,
retry, retry,
restart, restart,
renew_session,
set_model, set_model,
set_thinking_level, set_thinking_level,
set_session_name, set_session_name,
@@ -434,6 +481,12 @@ mod tests {
assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None); assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None);
} }
#[test]
fn quick_flag_is_detected_without_affecting_toggle() {
assert!(requested_quick_pane(&["--quick".to_owned()]));
assert!(!requested_quick_pane(&["--toggle".to_owned()]));
}
#[test] #[test]
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() { fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
let toggle = vec!["--toggle".to_owned()]; let toggle = vec!["--toggle".to_owned()];
+80
View File
@@ -37,6 +37,34 @@ pub struct LastSeenState {
pub last_activity: Option<String>, pub last_activity: Option<String>,
} }
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct QuickPaneSettings {
pub workspace: String,
pub provider: String,
pub model: String,
pub follow_up_provider: String,
pub follow_up_model: String,
pub instructions: String,
pub maximum_seconds: u32,
pub maximum_tool_calls: u32,
}
impl Default for QuickPaneSettings {
fn default() -> Self {
Self {
workspace: String::new(),
provider: String::new(),
model: String::new(),
follow_up_provider: String::new(),
follow_up_model: String::new(),
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.".to_owned(),
maximum_seconds: 60,
maximum_tool_calls: 6,
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UiStateV1 { pub struct UiStateV1 {
@@ -61,6 +89,8 @@ pub struct UiStateV1 {
pub default_new_session_worktree: Option<String>, pub default_new_session_worktree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub dismissed_collision_warning: Option<bool>, pub dismissed_collision_warning: Option<bool>,
#[serde(default)]
pub quick_pane: QuickPaneSettings,
} }
impl Default for UiStateV1 { impl Default for UiStateV1 {
@@ -77,6 +107,7 @@ impl Default for UiStateV1 {
interface_scale: None, interface_scale: None,
default_new_session_worktree: None, default_new_session_worktree: None,
dismissed_collision_warning: None, dismissed_collision_warning: None,
quick_pane: QuickPaneSettings::default(),
} }
} }
} }
@@ -182,6 +213,39 @@ pub fn validate(state: &UiStateV1) -> Result<(), String> {
if total_string_bytes > MAX_TOTAL_STRING_BYTES { if total_string_bytes > MAX_TOTAL_STRING_BYTES {
return Err("UI state strings exceed the total size limit".to_owned()); return Err("UI state strings exceed the total size limit".to_owned());
} }
let quick = &state.quick_pane;
let quick_is_default = quick.workspace.is_empty()
&& quick.provider.is_empty()
&& quick.model.is_empty()
&& quick.follow_up_provider.is_empty()
&& quick.follow_up_model.is_empty();
if !quick_is_default {
bounded(&quick.workspace, "quick-pane workspace")?;
if !Path::new(&quick.workspace).is_absolute() || !Path::new(&quick.workspace).is_dir() {
return Err("Quick-pane workspace must be an existing absolute directory".to_owned());
}
bounded(&quick.provider, "quick-pane provider")?;
bounded(&quick.model, "quick-pane model")?;
bounded(&quick.follow_up_provider, "quick-pane follow-up provider")?;
bounded(&quick.follow_up_model, "quick-pane follow-up model")?;
}
if quick.instructions.is_empty() || quick.instructions.len() > 4096 {
return Err("Quick-pane instructions must contain 1 to 4096 bytes".to_owned());
}
if !(1..=600).contains(&quick.maximum_seconds) || !(1..=64).contains(&quick.maximum_tool_calls)
{
return Err("Quick-pane limits are outside the supported range".to_owned());
}
total_string_bytes = total_string_bytes
.saturating_add(quick.workspace.len())
.saturating_add(quick.provider.len())
.saturating_add(quick.model.len())
.saturating_add(quick.follow_up_provider.len())
.saturating_add(quick.follow_up_model.len())
.saturating_add(quick.instructions.len());
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
return Err("UI state strings exceed the total size limit".to_owned());
}
if let Some(scale) = state.interface_scale { if let Some(scale) = state.interface_scale {
if !scale.is_finite() || !(0.5..=2.0).contains(&scale) { if !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
return Err("Interface scale is outside the supported range".to_owned()); return Err("Interface scale is outside the supported range".to_owned());
@@ -368,6 +432,22 @@ mod tests {
.collect() .collect()
} }
#[test]
fn quick_pane_settings_validate_complete_existing_workspace() {
let root = temp_dir("quick-pane");
fs::create_dir_all(&root).unwrap();
let mut state = UiStateV1::default();
state.quick_pane.workspace = root.to_string_lossy().into_owned();
state.quick_pane.provider = "provider".into();
state.quick_pane.model = "quick-model".into();
state.quick_pane.follow_up_provider = "provider".into();
state.quick_pane.follow_up_model = "follow-up-model".into();
assert!(validate(&state).is_ok());
state.quick_pane.maximum_seconds = 0;
assert!(validate(&state).is_err());
fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn second_save_replaces_valid_state_without_leaving_temporary_files() { fn second_save_replaces_valid_state_without_leaving_temporary_files() {
let root = temp_dir("replace"); let root = temp_dir("replace");
+15
View File
@@ -12,6 +12,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"label": "main",
"title": "Pi Status UI", "title": "Pi Status UI",
"width": 1100, "width": 1100,
"height": 840, "height": 840,
@@ -23,6 +24,20 @@
"alwaysOnTop": true, "alwaysOnTop": true,
"skipTaskbar": true, "skipTaskbar": true,
"shadow": true "shadow": true
},
{
"label": "quick-pane",
"title": "Pi Quick Pane",
"width": 520,
"height": 360,
"minWidth": 400,
"minHeight": 260,
"center": true,
"visible": false,
"decorations": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"shadow": true
} }
], ],
"security": { "security": {
+484 -89
View File
@@ -66,16 +66,17 @@ select:focus-visible {
} }
.workflow-header { .workflow-header {
display: flex; display: flex;
min-height: 24px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
cursor: grab; cursor: grab;
} }
.workspace-heading, .workspace-heading {
.status-row {
display: flex; display: flex;
min-width: 0;
align-items: center; align-items: center;
gap: 8px; gap: 7px;
} }
.brand { .brand {
color: #f0a347; color: #f0a347;
@@ -84,39 +85,17 @@ select:focus-visible {
letter-spacing: 0.16em; letter-spacing: 0.16em;
} }
.workflow-header h1 { .workflow-header h1 {
overflow: hidden;
margin: 0; margin: 0;
font-size: 16px; font-size: 14px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
} }
.status-row { .workflow-header > button {
margin-top: 1px; padding: 4px 8px;
color: #aaa6a0;
font-size: 11px; font-size: 11px;
} }
.state-pill {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #555;
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
}
.state-pill.working {
color: #ffd39a;
border-color: #f0a347;
background: #4b3420;
box-shadow:
0 0 0 1px rgba(240, 163, 71, 0.22),
0 0 10px rgba(240, 163, 71, 0.2);
}
.state-pill.recovering {
color: #e0c2a1;
border-color: #8d6745;
}
.state-pill.error {
color: #ffb4ba;
border-color: #a65b67;
}
.working-pulse { .working-pulse {
width: 7px; width: 7px;
height: 7px; height: 7px;
@@ -126,17 +105,6 @@ select:focus-visible {
box-shadow: 0 0 0 0 rgba(240, 163, 71, 0.7); box-shadow: 0 0 0 0 rgba(240, 163, 71, 0.7);
animation: work-pulse 1.35s ease-out infinite; animation: work-pulse 1.35s ease-out infinite;
} }
.working-banner {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 6px;
border: 1px solid #b76f26;
border-radius: 4px;
background: #41301f;
color: #ffd39a;
font-weight: 700;
}
.workspace-warning { .workspace-warning {
padding: 6px 8px; padding: 6px 8px;
border: 1px solid #8c713c; border: 1px solid #8c713c;
@@ -158,6 +126,18 @@ select:focus-visible {
flex: 0 0 auto; flex: 0 0 auto;
padding: 4px 8px; padding: 4px 8px;
} }
.command-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border: 1px solid #a65b67;
border-radius: 5px;
background: #3d252b;
color: #ffd7db;
font-size: 13px;
}
.workspace-layout { .workspace-layout {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -255,35 +235,46 @@ select:focus-visible {
} }
.session-tabs { .session-tabs {
min-width: 0; min-width: 0;
flex: 1; flex: 0 1 auto;
display: flex; display: flex;
overflow-x: auto; overflow-x: auto;
gap: 2px; gap: 4px;
padding-left: 4px; padding-left: 4px;
} }
.session-tab { .session-tab {
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) 28px 28px;
align-items: stretch;
flex: 0 0 auto; flex: 0 0 auto;
overflow: hidden;
border: 1px solid #555;
border-bottom: 0;
border-radius: 7px 7px 0 0;
background: #303136;
}
.session-tab.selected {
border-color: #d88735;
background: #3b3935;
box-shadow: inset 0 2px #f0a347;
} }
.session-tab > button[role="tab"] { .session-tab > button[role="tab"] {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
min-width: 124px; min-width: 112px;
max-width: 190px; max-width: 176px;
padding: 6px 8px 5px; padding: 6px 9px 5px;
border: 1px solid #555; border: 0;
border-bottom: 0; border-radius: 0;
border-radius: 8px 8px 0 0; background: transparent;
background: #303136;
color: #d5d7dc; color: #d5d7dc;
text-align: left; text-align: left;
} }
.session-tab > button[role="tab"]:hover {
background: #383a40;
}
.session-tab.selected > button[role="tab"] { .session-tab.selected > button[role="tab"] {
border-color: #d88735;
background: #3b3935;
color: #fff5e7; color: #fff5e7;
box-shadow: inset 0 2px #f0a347;
} }
.session-tab > button span { .session-tab > button span {
overflow: hidden; overflow: hidden;
@@ -299,16 +290,71 @@ select:focus-visible {
.session-tab small.recovering { .session-tab small.recovering {
color: #ffc170; color: #ffc170;
} }
.renew-session,
.close-session { .close-session {
display: grid;
place-items: center;
width: 28px;
min-width: 0;
align-self: stretch; align-self: stretch;
border: 1px solid #505257; border: 0;
border-bottom: 0; border-radius: 0;
border-left: 0; padding: 0;
border-radius: 0 8px 0 0; background: transparent;
padding: 4px 8px;
background: #303136;
color: #b8bbc1; color: #b8bbc1;
} }
.renew-session {
color: #a9f2df;
}
.renew-session-icon {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.tab-action-spinner {
width: 12px;
height: 12px;
border: 2px solid rgba(169, 242, 223, 0.35);
border-top-color: #a9f2df;
border-radius: 50%;
animation: tab-action-spin 0.8s linear infinite;
}
.close-session {
font-size: 16px;
}
.session-tab.selected .renew-session,
.session-tab.selected .close-session {
color: #fff5e7;
}
.renew-session:hover {
background: #294139;
color: #c8ffef;
}
.close-session:hover {
background: #4a3131;
color: #ffd7db;
}
.new-session-tab {
align-self: stretch;
flex: 0 0 32px;
margin-left: 4px;
padding: 0;
border: 1px solid #d88735;
border-bottom: 0;
border-radius: 8px 8px 0 0;
background: #4a3827;
color: #ffc170;
font-size: 18px;
line-height: 1;
}
.new-session-tab:hover {
background: #5a432d;
color: #ffe0b6;
}
.unread-dot { .unread-dot {
width: 7px; width: 7px;
height: 7px; height: 7px;
@@ -321,7 +367,8 @@ select:focus-visible {
.session-tab-actions { .session-tab-actions {
display: flex; display: flex;
gap: 3px; gap: 3px;
padding: 0 0 2px 5px; margin-left: auto;
padding: 0 0 2px 8px;
} }
.session-tab-actions button { .session-tab-actions button {
white-space: nowrap; white-space: nowrap;
@@ -336,11 +383,6 @@ select:focus-visible {
background: #4a4540; background: #4a4540;
color: #ffc170; color: #ffc170;
} }
.session-tab-actions .new-session {
border-color: #d88735;
background: #4a3827;
color: #ffc170;
}
.session-tab-actions span { .session-tab-actions span {
margin-right: 5px; margin-right: 5px;
font-size: 15px; font-size: 15px;
@@ -356,18 +398,68 @@ select:focus-visible {
padding: 7px; padding: 7px;
} }
.workflow { .workflow {
position: relative;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-rows: minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr) auto;
gap: 7px; gap: 7px;
} }
.session-lock {
position: absolute;
z-index: 18;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: rgba(20, 18, 16, 0.86);
backdrop-filter: blur(3px);
}
.session-lock-card {
display: flex;
align-items: center;
gap: 12px;
width: min(420px, 92%);
padding: 15px 17px;
border: 1px solid #f0a347;
border-radius: 8px;
background: #33271d;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.48);
color: #ffd9aa;
}
.session-lock-card > div {
display: grid;
gap: 3px;
}
.session-lock-card strong {
color: #fff2df;
font-size: 14px;
}
.session-lock-card p {
margin: 0;
font-weight: 700;
}
.session-lock-card small {
color: #d8b892;
}
.session-lock-bars {
flex: 0 0 auto;
transform: scale(1.3);
}
.workflow-main { .workflow-main {
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 190px; grid-template-columns: minmax(0, 1fr) 190px;
gap: 5px; gap: 5px;
} }
.transcript-container {
position: relative;
min-width: 0;
min-height: 0;
}
.transcript {
height: 100%;
}
.transcript, .transcript,
.todos-pane, .todos-pane,
.settings, .settings,
@@ -385,6 +477,33 @@ select:focus-visible {
inset 3px 0 #f0a347, inset 3px 0 #f0a347,
0 0 16px rgba(240, 163, 71, 0.25); 0 0 16px rgba(240, 163, 71, 0.25);
} }
.transcript:focus-visible {
outline: 3px solid #f0a347;
outline-offset: -3px;
}
.scroll-to-latest {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
z-index: 2;
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid #f0a347;
border-radius: 50%;
background: #3d2d1d;
box-shadow: 0 5px 14px rgba(0, 0, 0, 0.4);
color: #ffd39a;
font-size: 20px;
line-height: 1;
}
.scroll-to-latest:hover {
background: #4b3420;
color: #fff0da;
}
.agent-working { .agent-working {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -489,30 +608,62 @@ select:focus-visible {
font-size: 13px; font-size: 13px;
line-height: 1.32; line-height: 1.32;
} }
.tool-result summary { .tool-result-visible > strong,
display: flex; .tool-result-label {
align-items: center;
justify-content: space-between;
cursor: pointer;
list-style: none;
color: #a9f2df; color: #a9f2df;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
} }
.tool-result summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 4px 8px;
cursor: pointer;
list-style: none;
}
.tool-result summary::-webkit-details-marker { .tool-result summary::-webkit-details-marker {
display: none; display: none;
} }
.tool-result summary::after { .tool-result summary::after {
grid-column: 2;
grid-row: 1;
content: "▾"; content: "▾";
color: #a9f2df;
font-size: 14px; font-size: 14px;
} }
.tool-result-preview {
grid-column: 1 / -1;
display: block;
padding: 5px 6px;
border-left: 2px solid #547d72;
background: #222b29;
color: #c9d8d4;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 11px;
font-weight: 400;
line-height: 1.32;
text-transform: none;
white-space: pre-wrap;
}
.tool-result-hidden-hint {
grid-column: 1 / -1;
color: #91a69f;
font-size: 10px;
font-weight: 600;
text-transform: none;
}
.tool-result[open] summary::after { .tool-result[open] summary::after {
content: "▴"; content: "▴";
} }
.tool-result[open] summary { .tool-result[open] summary {
margin-bottom: 6px; margin-bottom: 6px;
} }
.tool-result[open] .tool-result-preview,
.tool-result[open] .tool-result-hidden-hint {
display: none;
}
.pending-message { .pending-message {
border-style: dashed; border-style: dashed;
} }
@@ -566,14 +717,16 @@ select:focus-visible {
border-radius: 5px; border-radius: 5px;
background: #303030; background: #303030;
} }
.pi-controls label { .pi-controls label,
.model-control {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto minmax(0, 1fr);
gap: 6px; gap: 6px;
align-items: center; align-items: center;
font-size: 10px; font-size: 10px;
} }
.pi-controls select { .pi-controls select,
.model-trigger {
min-width: 0; min-width: 0;
appearance: none; appearance: none;
color-scheme: dark; color-scheme: dark;
@@ -585,11 +738,13 @@ select:focus-visible {
no-repeat right 7px center; no-repeat right 7px center;
color: #f2ece4; color: #f2ece4;
} }
.pi-controls select:hover { .pi-controls select:hover,
.model-trigger:hover {
border-color: #d88735; border-color: #d88735;
background-color: #383129; background-color: #383129;
} }
.pi-controls select:focus { .pi-controls select:focus,
.model-trigger:focus {
border-color: #f0a347; border-color: #f0a347;
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22); box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
} }
@@ -601,31 +756,206 @@ select:focus-visible {
background: #4b3420; background: #4b3420;
color: #fff2df; color: #fff2df;
} }
.composer-progress { .model-selector {
position: relative;
min-width: 0;
}
.model-trigger {
width: 100%;
overflow: hidden;
text-align: left;
font-weight: 650;
}
.model-trigger > span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-menu {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 30;
display: grid;
width: min(400px, calc(100vw - 40px));
max-height: min(420px, calc(100vh - 80px));
gap: 6px;
padding: 8px;
border: 1px solid #70522f;
border-radius: 7px;
background: #292724;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
}
.model-menu input {
width: 100%;
padding: 7px 9px;
border: 1px solid #5b5146;
border-radius: 4px;
background: #1f1e1c;
color: #f2ece4;
}
.model-result-count {
margin: 0;
color: #aaa39a;
font-size: 10px;
}
.model-results {
display: grid;
gap: 7px;
min-height: 0;
overflow-y: auto;
}
.model-provider-group {
display: grid;
gap: 2px;
}
.model-provider-heading {
position: sticky;
top: 0;
z-index: 1;
padding: 4px 5px 3px;
background: #292724;
color: #f0a347;
font-size: 10px;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.model-option {
display: grid;
gap: 1px;
width: 100%;
padding: 6px 7px;
border: 1px solid transparent;
background: #35322e;
color: #f2ece4;
text-align: left;
}
.model-option:hover {
border-color: #8c6338;
background: #44382d;
}
.model-option[aria-selected="true"] {
border-color: #f0a347;
background: #4b3420;
color: #fff2df;
}
.model-option small {
overflow-wrap: anywhere;
color: #aaa39a;
font-size: 9px;
font-weight: 500;
}
.model-empty {
margin: 5px 0;
text-align: center;
}
.session-status-strip {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 6px;
border: 1px solid #454545;
border-left: 3px solid #687168;
border-radius: 5px;
background: #292a29;
color: #b7bcb7;
}
.session-status-primary,
.session-status-meta,
.status-chip {
display: flex; display: flex;
align-items: center; align-items: center;
}
.session-status-primary {
min-width: 0;
flex: 1;
gap: 6px; gap: 6px;
color: #8f988e; }
font-size: 11px; .session-status-primary strong {
flex: 0 0 auto;
color: #d8ddd8;
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.session-status-detail {
overflow: hidden;
color: #9da49d;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-status-meta {
max-width: 68%;
min-width: 0;
flex: 0 1 auto;
justify-content: flex-end;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.session-status-meta::-webkit-scrollbar {
display: none;
}
.status-chip {
flex: 0 0 auto;
gap: 4px;
max-width: 180px;
overflow: hidden;
padding: 2px 5px;
border: 1px solid #494b49;
border-radius: 999px;
background: #323332;
color: #bcc1bc;
font-size: 9px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-chip small {
color: #7f877f;
font-size: 8px;
font-weight: 750;
letter-spacing: 0.06em;
text-transform: uppercase;
} }
.work-dot { .work-dot {
width: 7px; width: 7px;
height: 7px; height: 7px;
flex: 0 0 auto;
border-radius: 50%; border-radius: 50%;
background: #687168; background: #687168;
} }
.composer-progress.working { .session-status-strip.working {
padding: 4px 6px; border-color: #60492f;
border-left: 3px solid #f0a347; border-left-color: #f0a347;
background: #3b3025; background: #332d26;
color: #ffd39a;
font-weight: 700;
} }
.composer-progress.working .work-dot { .session-status-strip.working .session-status-primary strong,
.session-status-strip.working .session-status-detail {
color: #ffd39a;
}
.session-status-strip.working .work-dot {
background: #f0a347; background: #f0a347;
box-shadow: 0 0 8px #f0a347; box-shadow: 0 0 8px #f0a347;
animation: work-pulse 1.35s ease-out infinite; animation: work-pulse 1.35s ease-out infinite;
} }
.session-status-strip.recovering {
border-left-color: #b9824b;
}
.session-status-strip.error {
border-color: #6f3f47;
border-left-color: #e47682;
background: #34272a;
}
.session-status-strip.error .session-status-primary strong,
.session-status-strip.error .session-status-detail {
color: #ffb4ba;
}
.composer { .composer {
display: flex; display: flex;
gap: 7px; gap: 7px;
@@ -654,6 +984,25 @@ select:focus-visible {
display: flex; display: flex;
gap: 7px; gap: 7px;
} }
.restart-progress {
display: flex;
align-items: center;
gap: 8px;
width: fit-content;
margin-top: 8px;
padding: 7px 9px;
border: 1px solid #b76f26;
border-radius: 5px;
background: #41301f;
color: #ffd39a;
}
.restart-progress > span:last-child {
display: grid;
gap: 1px;
}
.restart-progress small {
color: #d9b987;
}
.default-worktree-control { .default-worktree-control {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -694,6 +1043,44 @@ select:focus-visible {
color: #b9afa4; color: #b9afa4;
font-weight: 400; font-weight: 400;
} }
.quick-pane-settings {
display: grid;
gap: 8px;
max-width: 560px;
margin-top: 20px;
padding-top: 14px;
border-top: 1px solid #4b4b4b;
}
.quick-pane-settings h2 {
margin: 0;
font-size: 15px;
}
.quick-pane-settings label {
display: grid;
gap: 4px;
color: #ddd8d0;
font-size: 12px;
font-weight: 700;
}
.quick-pane-settings input,
.quick-pane-settings textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid #70522f;
border-radius: 4px;
color: #f2ece4;
background: #2c2a27;
}
.quick-pane-settings textarea {
min-height: 90px;
resize: vertical;
}
.quick-pane-settings button {
justify-self: start;
}
.quick-pane-settings p {
margin: 0;
}
.extension-widget { .extension-widget {
padding: 5px 8px; padding: 5px 8px;
border-left: 3px solid #f0a347; border-left: 3px solid #f0a347;
@@ -820,6 +1207,9 @@ select:focus-visible {
min-width: 0; min-width: 0;
gap: 3px; gap: 3px;
} }
.session-row-content > strong {
color: #f1f3f4;
}
.session-preview, .session-preview,
.session-metadata { .session-metadata {
overflow: hidden; overflow: hidden;
@@ -883,6 +1273,11 @@ select:focus-visible {
.notification { .notification {
padding: 6px; padding: 6px;
} }
@keyframes tab-action-spin {
to {
transform: rotate(360deg);
}
}
@keyframes work-pulse { @keyframes work-pulse {
70% { 70% {
box-shadow: 0 0 0 7px rgba(240, 163, 71, 0); box-shadow: 0 0 0 7px rgba(240, 163, 71, 0);
+396
View File
@@ -0,0 +1,396 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeSummary, UiState, Workspace } from "./workspace/types";
import {
initialWorkspaceState,
reconcileWorkspace,
workspaceReducer,
} from "./workspace/reducer";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
loadSnapshot: vi.fn(),
refresh: vi.fn(),
dispatch: vi.fn(),
useWorkspace: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({ startDragging: vi.fn() }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
vi.mock("./workspace/useWorkspace", () => ({
useWorkspace: mocks.useWorkspace,
}));
import App from "./App";
const summary: RuntimeSummary = {
runtimeId: "runtime-a",
agentId: "agent-a",
worktreePath: "/repo",
state: "idle",
label: "Session A",
attention: false,
queueCount: 0,
lastActivity: "2026-08-24T20:00:00.000Z",
openedAt: "2026-08-24T20:00:00.000Z",
};
const otherSummary: RuntimeSummary = {
...summary,
runtimeId: "runtime-b",
agentId: "agent-b",
label: "Session B",
};
const uiState: UiState = {
version: 1,
directoryOrder: [],
runtimeOrderByDirectory: {},
drafts: {},
scroll: {},
lastSeen: {},
};
function workspaceState() {
const workspace: Workspace = {
bridgeInstanceId: "epoch",
latestSeq: 0,
directories: [
{
worktreePath: "/repo",
isHome: true,
openCount: 2,
workingCount: 0,
attentionCount: 0,
recoveringCount: 0,
errorCount: 0,
runtimes: [summary, otherSummary],
},
],
};
const state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById[summary.runtimeId].models = [
{ provider: "openai", id: "gpt-5", name: "GPT 5" },
];
return state;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe("App command progress and errors", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.invoke.mockResolvedValue(uiState);
mocks.refresh.mockResolvedValue(undefined);
mocks.loadSnapshot.mockResolvedValue(true);
mocks.useWorkspace.mockReturnValue({
state: workspaceState(),
dispatch: mocks.dispatch,
refresh: mocks.refresh,
loadSnapshot: mocks.loadSnapshot,
});
});
it("shows explicit Pi readiness in the compact session status", () => {
render(<App />);
const status = screen.getByRole("status", { name: "Session status" });
expect(status).toHaveTextContent("Pi ready");
expect(status).toHaveTextContent("Ready for your next prompt");
});
it("shows restart progress until the replacement snapshot is restored", async () => {
const user = userEvent.setup();
const restart = deferred<unknown>();
const snapshot = deferred<boolean>();
mocks.invoke.mockImplementation((command: string) =>
command === "restart" ? restart.promise : Promise.resolve(undefined),
);
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
render(<App />);
await user.click(screen.getByRole("button", { name: "Controls" }));
await user.click(screen.getByRole("button", { name: "Restart Pi" }));
const restarting = screen.getByRole("button", { name: "Restarting Pi…" });
expect(restarting).toBeDisabled();
expect(
screen.getByText("Restoring this session and its extensions…"),
).toBeInTheDocument();
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
await user.click(restarting);
expect(mocks.invoke).toHaveBeenCalledTimes(2);
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "load_ui_state");
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "restart", {
agentId: "agent-a",
});
await act(async () => restart.resolve(undefined));
expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled();
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-a", {
throwOnError: true,
});
await act(async () => snapshot.resolve(true));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Restart Pi" })).toBeEnabled(),
);
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "false");
});
it("renews a background session from its tab without changing selection", async () => {
const user = userEvent.setup();
const renewal = deferred<unknown>();
const snapshot = deferred<boolean>();
mocks.invoke.mockImplementation((command: string) =>
command === "renew_session" ? renewal.promise : Promise.resolve(undefined),
);
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
render(<App />);
await user.click(screen.getByRole("button", { name: "Renew Session B" }));
expect(mocks.invoke).toHaveBeenCalledWith("renew_session", {
agentId: "agent-b",
});
expect(
screen.getByRole("button", { name: "Renewing Session B" }),
).toBeDisabled();
expect(screen.getByRole("tab", { name: /Session A/ })).toHaveAttribute(
"aria-selected",
"true",
);
await act(async () => renewal.resolve(undefined));
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-b", {
throwOnError: true,
});
await act(async () => snapshot.resolve(true));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Renew Session B" }),
).toBeEnabled(),
);
});
it("keeps parallel prompt submission state attached to its session", async () => {
const user = userEvent.setup();
const submissionA = deferred<{ delivery: "prompt" }>();
const submissionB = deferred<{ delivery: "prompt" }>();
let state = workspaceState();
state = workspaceReducer(state, {
type: "draftChanged",
runtimeId: "runtime-a",
draft: "Prompt A",
});
mocks.dispatch.mockImplementation((action) => {
state = workspaceReducer(state, action);
});
mocks.useWorkspace.mockImplementation(() => ({
state,
dispatch: mocks.dispatch,
refresh: mocks.refresh,
loadSnapshot: mocks.loadSnapshot,
}));
mocks.invoke.mockImplementation(
(command: string, args?: { agentId?: string }) => {
if (command !== "submit_prompt") return Promise.resolve(undefined);
return args?.agentId === "agent-a"
? submissionA.promise
: submissionB.promise;
},
);
const rendered = render(<App />);
await user.click(screen.getByRole("button", { name: "Send" }));
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-b",
});
state = workspaceReducer(state, {
type: "draftChanged",
runtimeId: "runtime-b",
draft: "Prompt B",
});
rendered.rerender(<App />);
await user.click(screen.getByRole("button", { name: "Send" }));
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-a",
});
rendered.rerender(<App />);
expect(screen.getByLabelText("Prompt")).toBeDisabled();
expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled();
expect(screen.getByText("Sending your prompt…")).toBeInTheDocument();
const sessionStatus = screen.getByRole("status", { name: "Session status" });
expect(sessionStatus).toHaveTextContent("Working");
expect(sessionStatus).toHaveTextContent("Sending prompt to Pi…");
expect(sessionStatus).toHaveTextContent("Bridge");
expect(sessionStatus).toHaveTextContent("ready");
expect(sessionStatus).toHaveClass("working");
expect(rendered.container.querySelector(".status-row")).toBeNull();
await act(async () => {
submissionA.resolve({ delivery: "prompt" });
submissionB.resolve({ delivery: "prompt" });
});
});
it("keeps prompt failures attached to the session that submitted them", async () => {
const user = userEvent.setup();
const submission = deferred<{ delivery: "prompt" }>();
let state = workspaceState();
state = workspaceReducer(state, {
type: "draftChanged",
runtimeId: "runtime-a",
draft: "Fail in A",
});
mocks.dispatch.mockImplementation((action) => {
state = workspaceReducer(state, action);
});
mocks.useWorkspace.mockImplementation(() => ({
state,
dispatch: mocks.dispatch,
refresh: mocks.refresh,
loadSnapshot: mocks.loadSnapshot,
}));
mocks.invoke.mockReturnValue(submission.promise);
const rendered = render(<App />);
await user.click(screen.getByRole("button", { name: "Send" }));
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-b",
});
rendered.rerender(<App />);
await act(async () => submission.reject(new Error("session A failed")));
expect(
screen.queryByText("Pi command failed: Error: session A failed"),
).toBeNull();
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-a",
});
rendered.rerender(<App />);
expect(
screen.getByText("Pi command failed: Error: session A failed"),
).toBeInTheDocument();
});
it("keeps command forms bound to the session that opened them", async () => {
const user = userEvent.setup();
let state = workspaceState();
state = workspaceReducer(state, {
type: "draftChanged",
runtimeId: "runtime-a",
draft: "/bash",
});
mocks.dispatch.mockImplementation((action) => {
state = workspaceReducer(state, action);
});
mocks.useWorkspace.mockImplementation(() => ({
state,
dispatch: mocks.dispatch,
refresh: mocks.refresh,
loadSnapshot: mocks.loadSnapshot,
}));
mocks.invoke.mockResolvedValue(undefined);
const rendered = render(<App />);
await user.click(screen.getByRole("button", { name: /\/bash/ }));
expect(screen.getByRole("dialog", { name: "/bash" })).toBeInTheDocument();
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-b",
});
rendered.rerender(<App />);
expect(screen.queryByRole("dialog", { name: "/bash" })).toBeNull();
state = workspaceReducer(state, {
type: "selectRuntime",
runtimeId: "runtime-a",
});
rendered.rerender(<App />);
await user.type(screen.getByLabelText("command"), "echo session-a");
await user.click(screen.getByRole("button", { name: "Run command" }));
expect(mocks.invoke).toHaveBeenCalledWith("pi_rpc_command", {
agentId: "agent-a",
command: "bash",
input: { command: "echo session-a" },
});
});
it("locks selected-session input and output throughout renewal", async () => {
const user = userEvent.setup();
const renewal = deferred<unknown>();
const snapshot = deferred<boolean>();
mocks.invoke.mockImplementation((command: string) =>
command === "renew_session" ? renewal.promise : Promise.resolve(undefined),
);
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
render(<App />);
const prompt = screen.getByLabelText("Prompt");
expect(prompt).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Renew Session A" }));
expect(prompt).toBeDisabled();
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-busy", "true");
expect(
screen.getByText("Input and output are temporarily locked."),
).toBeInTheDocument();
expect(
screen
.getByRole("region", { name: "Conversation" })
.closest(".workflow-main"),
).toHaveAttribute("inert");
expect(
screen.getByRole("button", { name: /Model Current model/i }),
).toBeDisabled();
await act(async () => renewal.resolve(undefined));
expect(prompt).toBeDisabled();
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-a", {
throwOnError: true,
});
await act(async () => snapshot.resolve(true));
await waitFor(() => expect(prompt).toBeEnabled());
expect(
screen.queryByText("Input and output are temporarily locked."),
).toBeNull();
});
it("keeps the model picker open and shows a persistent alert on RPC failure", async () => {
const user = userEvent.setup();
mocks.invoke.mockRejectedValue(new Error("provider rejected the model"));
render(<App />);
await user.click(
screen.getByRole("button", { name: /Model Current model/i }),
);
await user.click(screen.getByRole("option", { name: /GPT 5/ }));
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
agentId: "agent-a",
provider: "openai",
modelId: "gpt-5",
});
const modelError = await screen.findByText(
"Pi model command failed: Error: provider rejected the model",
);
expect(modelError.closest('[role="alert"]')).not.toBeNull();
expect(
screen.getByRole("dialog", { name: "Choose model" }),
).toBeInTheDocument();
expect(mocks.loadSnapshot).not.toHaveBeenCalled();
});
});
+549 -142
View File
@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { emitTo } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -14,7 +15,9 @@ import type {
DirectorySession, DirectorySession,
DirectoryWorkspace, DirectoryWorkspace,
Model, Model,
QuickPaneSettings,
RuntimeView, RuntimeView,
UiState,
} from "./workspace/types"; } from "./workspace/types";
import { useWorkspace } from "./workspace/useWorkspace"; import { useWorkspace } from "./workspace/useWorkspace";
@@ -22,6 +25,39 @@ const minimumUiScale = 0.9,
maximumUiScale = 1.4; maximumUiScale = 1.4;
const clamp = (value: number) => const clamp = (value: number) =>
Math.min(maximumUiScale, Math.max(minimumUiScale, value)); Math.min(maximumUiScale, Math.max(minimumUiScale, value));
const defaultQuickPaneSettings: QuickPaneSettings = {
workspace: "",
provider: "",
model: "",
followUpProvider: "",
followUpModel: "",
instructions:
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.",
maximumSeconds: 60,
maximumToolCalls: 6,
};
function validateQuickPaneSettings(settings: QuickPaneSettings) {
if (!settings.workspace.startsWith("/"))
return "Workspace must be an absolute path.";
if (!settings.provider || !settings.model)
return "Quick provider and model are required.";
if (!settings.followUpProvider || !settings.followUpModel)
return "Follow-up provider and model are required.";
if (!settings.instructions.trim())
return "Supplemental instructions are required.";
if (
!Number.isInteger(settings.maximumSeconds) ||
settings.maximumSeconds < 1 ||
settings.maximumSeconds > 600
)
return "Seconds must be an integer from 1 to 600.";
if (
!Number.isInteger(settings.maximumToolCalls) ||
settings.maximumToolCalls < 1 ||
settings.maximumToolCalls > 64
)
return "Tool calls must be an integer from 1 to 64.";
}
let submissionId = 0; let submissionId = 0;
type LocalOperation = { type LocalOperation = {
id: number; id: number;
@@ -31,15 +67,61 @@ type LocalOperation = {
| "closing" | "closing"
| "listing" | "listing"
| "submitting" | "submitting"
| "command"; | "command"
| "restarting"
| "renewing";
detail: string; detail: string;
runtimeId?: string; runtimeId?: string;
command?: string;
}; };
type CommandNotice = {
kind: "error";
message: string;
runtimeId: string;
};
const modelRpcCommands = new Set([
"get_available_models",
"set_model",
"cycle_model",
"set_thinking_level",
"cycle_thinking_level",
]);
function requestedCommand(command: string, args: Record<string, unknown>) {
return command === "pi_rpc_command" && typeof args.command === "string"
? args.command
: command;
}
function sessionStateLabel(state?: string) {
return !state || state === "idle" ? "Pi ready" : state;
}
function commandProgress(command: string) {
if (command === "restart") return "Restarting Pi and restoring the session…";
if (command === "renew_session")
return "Clearing session history and starting anew…";
if (command === "get_available_models") return "Loading available Pi models…";
if (command === "set_model") return "Changing Pi model…";
if (command === "cycle_model") return "Cycling Pi model…";
if (command === "set_thinking_level") return "Updating thinking level…";
if (command === "cycle_thinking_level") return "Cycling thinking level…";
return "Updating Pi session…";
}
export default function App() { export default function App() {
const { state, dispatch, refresh, loadSnapshot } = useWorkspace(); const { state, dispatch, refresh, loadSnapshot } = useWorkspace();
const [status, setStatus] = useState("Connecting to Pi Status Bridge…"); const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
const [commandForm, setCommandForm] = useState<RpcCommand | undefined>(); const [runtimeStatuses, setRuntimeStatuses] = useState<Record<string, string>>(
{},
);
const [commandForms, setCommandForms] = useState<Record<string, RpcCommand>>(
{},
);
const [commandNotices, setCommandNotices] = useState<
Record<string, CommandNotice>
>({});
const [view, setView] = useState<"conversation" | "settings">("conversation"); const [view, setView] = useState<"conversation" | "settings">("conversation");
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [folderPath, setFolderPath] = useState(""); const [folderPath, setFolderPath] = useState("");
@@ -63,24 +145,56 @@ export default function App() {
state.runtimesById, state.runtimesById,
], ],
); );
const previousSelected = useRef<string | undefined>(undefined);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const operationSequence = useRef(0); const operationSequence = useRef(0);
const [operation, setOperation] = useState<LocalOperation | undefined>(); const activeAgentCommands = useRef(new Set<string>());
const [abortingRuntimeId, setAbortingRuntimeId] = useState<string>(); const [operations, setOperations] = useState<Record<number, LocalOperation>>(
{},
);
const [abortingRuntimeIds, setAbortingRuntimeIds] = useState<Set<string>>(
() => new Set(),
);
const [restoreTabFocus, setRestoreTabFocus] = useState(false); const [restoreTabFocus, setRestoreTabFocus] = useState(false);
const [uiState, setUiState] = useState<UiState>();
const [quickPaneSettings, setQuickPaneSettings] = useState<QuickPaneSettings>(
defaultQuickPaneSettings,
);
const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState("");
const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>();
const setRuntimeStatus = (runtimeId: string, detail: string) =>
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
const clearRuntimeStatus = (runtimeId: string) =>
setRuntimeStatuses((current) => {
if (!(runtimeId in current)) return current;
const next = { ...current };
delete next[runtimeId];
return next;
});
const startOperation = ( const startOperation = (
kind: LocalOperation["kind"], kind: LocalOperation["kind"],
detail: string, detail: string,
runtimeId?: string, runtimeId?: string,
command?: string,
) => { ) => {
const id = ++operationSequence.current; const operation = {
setOperation({ id, kind, detail, runtimeId }); id: ++operationSequence.current,
setStatus(detail); kind,
return id; detail,
runtimeId,
command,
};
setOperations((current) => ({ ...current, [operation.id]: operation }));
if (runtimeId) clearRuntimeStatus(runtimeId);
else setStatus(detail);
return operation.id;
}; };
const finishOperation = (id: number) => const finishOperation = (id: number) =>
setOperation((current) => (current?.id === id ? undefined : current)); setOperations((current) => {
if (!(id in current)) return current;
const next = { ...current };
delete next[id];
return next;
});
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
@@ -90,33 +204,76 @@ export default function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!selected || operation) return; let active = true;
setStatus( void invoke<UiState>("load_ui_state")
selected.summary.state === "streaming" .then((loaded) => {
? "Pi working" if (!active) return;
: (selected.error ?? "Pi ready"), setUiState(loaded);
); setQuickPaneSettings({
if (previousSelected.current !== selected.summary.runtimeId) ...defaultQuickPaneSettings,
previousSelected.current = selected.summary.runtimeId; ...loaded.quickPane,
});
})
.catch((error) => {
if (active)
setQuickPaneSettingsError(
`Could not load Quick Pane settings: ${String(error)}`,
);
});
return () => {
active = false;
};
}, []);
const selectedRuntimeId = selected?.summary.runtimeId;
useEffect(() => {
if (!selectedRuntimeId) return;
requestAnimationFrame(() => requestAnimationFrame(() =>
dispatch({ type: "markRead", runtimeId: selected.summary.runtimeId }), dispatch({ type: "markRead", runtimeId: selectedRuntimeId }),
); );
}, [selected, dispatch, operation]); }, [selectedRuntimeId, dispatch]);
useEffect(() => { useEffect(() => {
if ( setAbortingRuntimeIds((current) => {
abortingRuntimeId && const next = new Set(
state.runtimesById[abortingRuntimeId]?.summary.state !== "streaming" [...current].filter(
) (runtimeId) =>
setAbortingRuntimeId(undefined); state.runtimesById[runtimeId]?.summary.state === "streaming",
}, [abortingRuntimeId, state.runtimesById]); ),
);
return next.size === current.size ? current : next;
});
}, [state.runtimesById]);
async function saveQuickPaneSettings() {
const validationError = validateQuickPaneSettings(quickPaneSettings);
if (validationError) {
setQuickPaneSettingsError(validationError);
return;
}
if (!uiState) return;
try {
const current = await invoke<UiState>("load_ui_state");
const next = { ...current, quickPane: quickPaneSettings };
await invoke("save_ui_state", { state: next });
void emitTo(
"quick-pane",
"quick-pane-settings-saved",
quickPaneSettings,
).catch(() => undefined);
setUiState(next);
setQuickPaneSettingsError(undefined);
setQuickPaneSettingsStatus("Quick Pane settings saved.");
} catch (error) {
setQuickPaneSettingsError(
`Could not save Quick Pane settings: ${String(error)}`,
);
}
}
async function createRuntime(path = state.selectedDirectoryPath) { async function createRuntime(path = state.selectedDirectoryPath) {
if (!path || !mountedRef.current) return; if (!path || !mountedRef.current) return;
const operationId = startOperation( const operationId = startOperation("creating", "Starting a new Pi session…");
"creating",
"Starting a new Pi session…",
);
try { try {
const result = await invoke<{ runtime: RuntimeView["summary"] }>( const result = await invoke<{ runtime: RuntimeView["summary"] }>(
"create_session_runtime", "create_session_runtime",
@@ -157,7 +314,10 @@ export default function App() {
if (mountedRef.current) setRestoreTabFocus(true); if (mountedRef.current) setRestoreTabFocus(true);
} catch (error) { } catch (error) {
if (mountedRef.current) if (mountedRef.current)
setStatus(`Could not close session: ${String(error)}`); setRuntimeStatus(
runtime.summary.runtimeId,
`Could not close session: ${String(error)}`,
);
} finally { } finally {
if (mountedRef.current) finishOperation(operationId); if (mountedRef.current) finishOperation(operationId);
} }
@@ -237,20 +397,50 @@ export default function App() {
const runtimeId = target?.summary.runtimeId, const runtimeId = target?.summary.runtimeId,
agentId = target?.summary.agentId; agentId = target?.summary.agentId;
if (!runtimeId || !agentId) { if (!runtimeId || !agentId) {
setStatus("This session is not currently available"); if (runtimeId)
setRuntimeStatus(runtimeId, "This session is not currently available");
else setStatus("This session is not currently available");
return false; return false;
} }
if (activeAgentCommands.current.has(runtimeId)) return false;
const operationCommand = requestedCommand(command, args);
const restarting = operationCommand === "restart";
const renewing = operationCommand === "renew_session";
activeAgentCommands.current.add(runtimeId);
setCommandNotices((current) => {
if (!(runtimeId in current)) return current;
const next = { ...current };
delete next[runtimeId];
return next;
});
const operationId = showOperation const operationId = showOperation
? startOperation("command", "Updating Pi session…", runtimeId) ? startOperation(
renewing ? "renewing" : restarting ? "restarting" : "command",
commandProgress(operationCommand),
runtimeId,
operationCommand,
)
: undefined; : undefined;
try { try {
await invoke(command, { agentId, ...args }); await invoke(command, { agentId, ...args });
if (reload) await loadSnapshot(runtimeId); if (reload) await loadSnapshot(runtimeId, { throwOnError: true });
return true; return true;
} catch (error) { } catch (error) {
setStatus(`Pi command failed: ${String(error)}`); const subject = modelRpcCommands.has(operationCommand)
? "Pi model command failed"
: renewing
? "Session renewal failed"
: restarting
? "Pi restart failed"
: "Pi command failed";
const message = `${subject}: ${String(error)}`;
setCommandNotices((current) => ({
...current,
[runtimeId]: { kind: "error", message, runtimeId },
}));
return false; return false;
} finally { } finally {
activeAgentCommands.current.delete(runtimeId);
if (mountedRef.current && operationId) finishOperation(operationId); if (mountedRef.current && operationId) finishOperation(operationId);
} }
} }
@@ -279,7 +469,10 @@ export default function App() {
const name = text.match(/^\/name\s+([\s\S]+)$/)?.[1]?.trim(); const name = text.match(/^\/name\s+([\s\S]+)$/)?.[1]?.trim();
if (text === "/name" || name) { if (text === "/name" || name) {
if (!name) { if (!name) {
setStatus("Enter a session name after /name"); setRuntimeStatus(
selected.summary.runtimeId,
"Enter a session name after /name",
);
return; return;
} }
await agentCommand("set_session_name", { name }); await agentCommand("set_session_name", { name });
@@ -334,13 +527,9 @@ export default function App() {
); );
const delivery = result.delivery ?? initialDelivery; const delivery = result.delivery ?? initialDelivery;
dispatch({ type: "submissionSent", runtimeId, id, delivery }); dispatch({ type: "submissionSent", runtimeId, id, delivery });
setStatus( clearRuntimeStatus(runtimeId);
delivery === "follow_up"
? "Follow-up queued · waiting for Pi…"
: "Prompt accepted · waiting for Pi…",
);
} catch (error) { } catch (error) {
setStatus(`Pi command failed: ${String(error)}`); setRuntimeStatus(runtimeId, `Pi command failed: ${String(error)}`);
dispatch({ type: "submissionRemoved", runtimeId, id }); dispatch({ type: "submissionRemoved", runtimeId, id });
dispatch({ type: "draftChanged", runtimeId, draft: text }); dispatch({ type: "draftChanged", runtimeId, draft: text });
} finally { } finally {
@@ -350,22 +539,39 @@ export default function App() {
async function abortSelected() { async function abortSelected() {
const target = selected; const target = selected;
if (!target?.summary.agentId) return; if (!target?.summary.agentId) return;
setAbortingRuntimeId(target.summary.runtimeId); const runtimeId = target.summary.runtimeId;
setStatus("Aborting Pi…"); setAbortingRuntimeIds((current) => new Set(current).add(runtimeId));
clearRuntimeStatus(runtimeId);
try { try {
await invoke("abort", { agentId: target.summary.agentId }); await invoke("abort", { agentId: target.summary.agentId });
} catch (error) { } catch (error) {
setStatus(`Pi command failed: ${String(error)}`); setRuntimeStatus(runtimeId, `Pi command failed: ${String(error)}`);
setAbortingRuntimeId(undefined); setAbortingRuntimeIds((current) => {
const next = new Set(current);
next.delete(runtimeId);
return next;
});
} }
} }
async function runRpcCommand( async function runRpcCommand(
command: RpcCommand, command: RpcCommand,
input: Record<string, unknown> = {}, input: Record<string, unknown> = {},
target = selected,
) { ) {
if (!selected) return; if (!target) return;
await agentCommand("pi_rpc_command", { command: command.command, input }); const runtimeId = target.summary.runtimeId;
setCommandForm(undefined); const succeeded = await agentCommand(
"pi_rpc_command",
{ command: command.command, input },
true,
target,
);
if (succeeded)
setCommandForms((current) => {
const next = { ...current };
delete next[runtimeId];
return next;
});
} }
async function respond(response: Record<string, unknown>) { async function respond(response: Record<string, unknown>) {
const target = selected; const target = selected;
@@ -379,11 +585,54 @@ export default function App() {
); );
} }
const closePicker = useCallback(() => setSessions(undefined), []); const closePicker = useCallback(() => setSessions(undefined), []);
const activeOperations = Object.values(operations);
const globalOperation = activeOperations
.filter((entry) => !entry.runtimeId)
.reduce<LocalOperation | undefined>(
(latest, entry) => (!latest || entry.id > latest.id ? entry : latest),
undefined,
);
const selectedOperations = selectedRuntimeId
? activeOperations.filter((entry) => entry.runtimeId === selectedRuntimeId)
: [];
const selectedOperation = selectedOperations.reduce<
LocalOperation | undefined
>(
(latest, entry) => (!latest || entry.id > latest.id ? entry : latest),
undefined,
);
const pendingSubmissions = selected?.pendingSubmissions ?? []; const pendingSubmissions = selected?.pendingSubmissions ?? [];
const pendingSubmission = pendingSubmissions[pendingSubmissions.length - 1]; const pendingSubmission = pendingSubmissions[pendingSubmissions.length - 1];
const isSubmitting =
pendingSubmissions.some((entry) => entry.phase === "sending") ||
selectedOperations.some((entry) => entry.kind === "submitting");
const isRestarting = selectedOperations.some(
(entry) => entry.kind === "restarting",
);
const isRenewing = selectedOperations.some(
(entry) => entry.kind === "renewing",
);
const isAborting = selectedRuntimeId
? abortingRuntimeIds.has(selectedRuntimeId)
: false;
const isSessionLocked = isRestarting || isRenewing;
const visibleCommandForm = selectedRuntimeId
? commandForms[selectedRuntimeId]
: undefined;
const commandOperation = selectedOperations.find(
(entry) => entry.kind === "command",
);
const commandFormPending = Boolean(commandOperation);
const modelCommandPending =
commandFormPending && modelRpcCommands.has(commandOperation?.command ?? "");
const visibleCommandNotice = selectedRuntimeId
? commandNotices[selectedRuntimeId]
: undefined;
const displayedStatus = const displayedStatus =
operation?.detail ??
state.newSessionLaunch?.detail ?? state.newSessionLaunch?.detail ??
globalOperation?.detail ??
(isAborting ? "Aborting Pi…" : undefined) ??
selectedOperation?.detail ??
(pendingSubmission (pendingSubmission
? pendingSubmission.phase === "sending" ? pendingSubmission.phase === "sending"
? pendingSubmission.delivery === "follow_up" ? pendingSubmission.delivery === "follow_up"
@@ -393,73 +642,74 @@ export default function App() {
? "Follow-up queued · waiting for Pi…" ? "Follow-up queued · waiting for Pi…"
: "Prompt accepted · waiting for Pi…" : "Prompt accepted · waiting for Pi…"
: undefined) ?? : undefined) ??
(selectedRuntimeId ? runtimeStatuses[selectedRuntimeId] : undefined) ??
(selected?.progress.phase === "working" || (selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering" selected?.progress.phase === "recovering"
? selected.progress.detail ? selected.progress.detail
: undefined) ?? : undefined) ??
selected?.error ??
selected?.summary.error?.message ??
selected?.progress.detail ??
status; status;
const isWorking = const isWorking =
state.newSessionLaunch?.phase === "starting" || state.newSessionLaunch?.phase === "starting" ||
isSessionLocked ||
isSubmitting ||
selected?.progress.phase === "working" || selected?.progress.phase === "working" ||
selected?.progress.phase === "recovering"; selected?.progress.phase === "recovering";
const sessionStatusLabel =
state.newSessionLaunch?.phase === "starting"
? "Starting"
: isRenewing
? "Renewing"
: isRestarting
? "Restarting"
: isWorking
? "Working"
: sessionStateLabel(selected?.summary.state);
const sessionStatusPhase = isWorking
? ("working" as const)
: (selected?.progress.phase ?? "idle");
const isStartingSession = const isStartingSession =
state.newSessionLaunch?.phase === "starting" || state.newSessionLaunch?.phase === "starting" ||
operation?.kind === "creating"; activeOperations.some((entry) => entry.kind === "creating");
const sessionTabsBusy = activeOperations.some(
(entry) =>
!entry.runtimeId &&
(entry.kind === "creating" ||
entry.kind === "opening" ||
entry.kind === "listing"),
);
const closingRuntimeIds = new Set(
activeOperations
.filter((entry) => entry.kind === "closing" && entry.runtimeId)
.map((entry) => entry.runtimeId as string),
);
const renewingRuntimeIds = new Set(
activeOperations
.filter((entry) => entry.kind === "renewing" && entry.runtimeId)
.map((entry) => entry.runtimeId as string),
);
const sameDirectoryCollision = directory && directory.openCount > 1; const sameDirectoryCollision = directory && directory.openCount > 1;
return ( return (
<main <main
className="app-shell" className="app-shell"
style={{ zoom: state.interfaceScale }} style={{ zoom: state.interfaceScale }}
aria-busy={isStartingSession} aria-busy={isStartingSession || isSessionLocked}
> >
<header <header
className="workflow-header" className="workflow-header"
onMouseDown={(event) => { onMouseDown={(event) => {
if ( if (
event.button === 0 && event.button === 0 &&
!(event.target as HTMLElement).closest( !(event.target as HTMLElement).closest("button,input,select,textarea")
"button,input,select,textarea",
)
) )
void getCurrentWindow().startDragging(); void getCurrentWindow().startDragging();
}} }}
> >
<div> <div className="workspace-heading">
<div className="workspace-heading"> <span className="brand">PI</span>
<span className="brand">PI</span> <h1>{selected?.summary.label ?? "Pi workspace"}</h1>
<h1>{selected?.summary.label ?? "Pi workspace"}</h1>
{selected && (
<span className={`state-pill ${selected.progress.phase}`}>
{isWorking && (
<span className="working-pulse" aria-hidden="true" />
)}
{state.newSessionLaunch?.phase === "starting"
? "Starting"
: isWorking
? "Working"
: selected.summary.state}
</span>
)}
</div>
<div className="status-row">
{isWorking && (
<span className="working-banner" role="status">
<span className="working-pulse" aria-hidden="true" />{" "}
{state.newSessionLaunch?.phase === "starting"
? "Starting new session"
: "Pi is working"}
</span>
)}
<span role="status" aria-live="polite">
{displayedStatus}
</span>
<span>{state.bridge.status}</span>
{Object.values(selected?.extensionStatuses ?? {}).map((entry) => (
<span className="extension-status" key={entry}>
{entry}
</span>
))}
</div>
</div> </div>
<button <button
className="quiet" className="quiet"
@@ -478,8 +728,8 @@ export default function App() {
{sameDirectoryCollision && !state.dismissedCollisionWarning && ( {sameDirectoryCollision && !state.dismissedCollisionWarning && (
<div className="workspace-warning collision" role="alert"> <div className="workspace-warning collision" role="alert">
<span> <span>
Multiple sessions are open in the same directory. Concurrent work Multiple sessions are open in the same directory. Concurrent work can
can collide in files and Git; separate worktrees are safer. collide in files and Git; separate worktrees are safer.
</span> </span>
<button <button
type="button" type="button"
@@ -490,6 +740,30 @@ export default function App() {
</button> </button>
</div> </div>
)} )}
{visibleCommandNotice && !visibleCommandForm && (
<div className="command-error" role="alert">
<span>{visibleCommandNotice.message}</span>
<button
type="button"
className="quiet warning-dismiss"
onClick={() => {
if (!selectedRuntimeId) return;
setCommandNotices((current) => {
const next = { ...current };
delete next[selectedRuntimeId];
return next;
});
}}
>
Dismiss
</button>
</div>
)}
{selected?.modelError && (
<div className="command-error" role="alert">
<span>{selected.modelError}</span>
</div>
)}
{selected?.notifications.map((notice, index) => ( {selected?.notifications.map((notice, index) => (
<div <div
className={`notification ${notice.notifyType ?? "info"}`} className={`notification ${notice.notifyType ?? "info"}`}
@@ -533,11 +807,7 @@ export default function App() {
Browse… Browse…
</button> </button>
<button type="submit">Add</button> <button type="submit">Add</button>
<button <button type="button" className="quiet" onClick={() => setAdding(false)}>
type="button"
className="quiet"
onClick={() => setAdding(false)}
>
Cancel Cancel
</button> </button>
</form> </form>
@@ -545,23 +815,21 @@ export default function App() {
<SessionTabs <SessionTabs
runtimes={runtimes} runtimes={runtimes}
selectedId={state.selectedRuntimeId} selectedId={state.selectedRuntimeId}
onSelect={(runtimeId) => onSelect={(runtimeId) => dispatch({ type: "selectRuntime", runtimeId })}
dispatch({ type: "selectRuntime", runtimeId })
}
onClose={(runtime) => void closeRuntime(runtime)} onClose={(runtime) => void closeRuntime(runtime)}
onRenew={(runtime) =>
void agentCommand("renew_session", {}, true, runtime)
}
onNew={() => void createRuntime()} onNew={() => void createRuntime()}
onResume={() => void showSessions()} onResume={() => void showSessions()}
busy={ busy={sessionTabsBusy}
operation?.kind === "creating" || operation?.kind === "listing" closingRuntimeIds={closingRuntimeIds}
} renewingRuntimeIds={renewingRuntimeIds}
closingRuntimeId={
operation?.kind === "closing" ? operation.runtimeId : undefined
}
restoreFocus={restoreTabFocus} restoreFocus={restoreTabFocus}
onFocusRestored={() => setRestoreTabFocus(false)} onFocusRestored={() => setRestoreTabFocus(false)}
/> />
{view === "settings" ? ( {view === "settings" ? (
<section className="settings"> <section className="settings" aria-busy={isSessionLocked}>
<h2>Session controls</h2> <h2>Session controls</h2>
<label className="default-worktree-control"> <label className="default-worktree-control">
Default new-session directory Default new-session directory
@@ -582,10 +850,127 @@ export default function App() {
))} ))}
</select> </select>
<small> <small>
Used by Ctrl+Super+Space. Only currently open directories are Used by Ctrl+Super+Space. Only currently open directories are listed.
listed.
</small> </small>
</label> </label>
<form
className="quick-pane-settings"
onSubmit={(event) => {
event.preventDefault();
void saveQuickPaneSettings();
}}
>
<h2>Quick Pane</h2>
<label>
Workspace
<input
value={quickPaneSettings.workspace}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
workspace: event.currentTarget.value,
})
}
/>
</label>
<label>
Quick provider
<input
value={quickPaneSettings.provider}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
provider: event.currentTarget.value,
})
}
/>
</label>
<label>
Quick model
<input
value={quickPaneSettings.model}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
model: event.currentTarget.value,
})
}
/>
</label>
<label>
Follow-up provider
<input
value={quickPaneSettings.followUpProvider}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
followUpProvider: event.currentTarget.value,
})
}
/>
</label>
<label>
Follow-up model
<input
value={quickPaneSettings.followUpModel}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
followUpModel: event.currentTarget.value,
})
}
/>
</label>
<label>
Maximum seconds
<input
type="number"
min="1"
max="600"
value={quickPaneSettings.maximumSeconds}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
maximumSeconds: Number(event.currentTarget.value),
})
}
/>
</label>
<label>
Maximum tool calls
<input
type="number"
min="1"
max="64"
value={quickPaneSettings.maximumToolCalls}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
maximumToolCalls: Number(event.currentTarget.value),
})
}
/>
</label>
<label>
Supplemental instructions
<textarea
value={quickPaneSettings.instructions}
onChange={(event) =>
setQuickPaneSettings({
...quickPaneSettings,
instructions: event.currentTarget.value,
})
}
/>
</label>
<button type="submit" disabled={!uiState}>
Save Quick Pane settings
</button>
{quickPaneSettingsStatus && (
<p role="status">{quickPaneSettingsStatus}</p>
)}
{quickPaneSettingsError && <p role="alert">{quickPaneSettingsError}</p>}
</form>
{selected ? ( {selected ? (
<> <>
<p>{selected.summary.worktreePath}</p> <p>{selected.summary.worktreePath}</p>
@@ -594,16 +979,29 @@ export default function App() {
{selected.summary.queueCount} queued {selected.summary.queueCount} queued
</p> </p>
<div className="settings-actions"> <div className="settings-actions">
<button onClick={() => void agentCommand("retry")}> <button
disabled={isSessionLocked}
onClick={() => void agentCommand("retry")}
>
Retry Retry
</button> </button>
<button <button
className="danger" className="danger"
disabled={isSessionLocked}
onClick={() => void agentCommand("restart")} onClick={() => void agentCommand("restart")}
> >
Restart Pi {isRestarting ? "Restarting Pi…" : "Restart Pi"}
</button> </button>
</div> </div>
{isRestarting && (
<div className="restart-progress" role="status" aria-live="polite">
<span className="working-pulse" aria-hidden="true" />
<span>
<strong>Restarting Pi</strong>
<small>Restoring this session and its extensions…</small>
</span>
</div>
)}
<label> <label>
Interface scale{" "} Interface scale{" "}
<input <input
@@ -653,35 +1051,39 @@ export default function App() {
); );
}} }}
onModel={(model: Model) => onModel={(model: Model) =>
void agentCommand("set_model", { agentCommand("set_model", {
provider: model.provider, provider: model.provider,
modelId: model.id, modelId: model.id,
}) })
} }
onThinking={(level) => onThinking={(level) => agentCommand("set_thinking_level", { level })}
void agentCommand("set_thinking_level", { level }) isModelUpdating={modelCommandPending}
} locked={isSessionLocked}
isSubmitting={ lockReason={isRenewing ? "renewing" : "restarting"}
operation?.kind === "submitting" && status={{
operation.runtimeId === selected.summary.runtimeId label: sessionStatusLabel,
} detail: displayedStatus,
isAborting={abortingRuntimeId === selected.summary.runtimeId} phase: sessionStatusPhase,
onCommand={(command) => bridge: state.bridge.status,
command.input?.length extensions: Object.values(selected.extensionStatuses),
? setCommandForm(command) }}
: void runRpcCommand(command) isSubmitting={isSubmitting}
} isAborting={isAborting}
onCommand={(command) => {
if (command.input?.length)
setCommandForms((current) => ({
...current,
[selected.summary.runtimeId]: command,
}));
else void runRpcCommand(command, {}, selected);
}}
/> />
) : ( ) : (
<section className="empty-workspace"> <section className="empty-workspace">
<h2>No open sessions</h2> <h2>No open sessions</h2>
<p> <p>Start a session in this directory, or add another directory.</p>
Start a session in this directory, or add another directory.
</p>
{state.selectedDirectoryPath && ( {state.selectedDirectoryPath && (
<button onClick={() => void createRuntime()}> <button onClick={() => void createRuntime()}>New session</button>
New session
</button>
)} )}
</section> </section>
)} )}
@@ -695,10 +1097,20 @@ export default function App() {
/> />
)} )}
<CommandFormDialog <CommandFormDialog
command={commandForm} command={visibleCommandForm}
onCancel={() => setCommandForm(undefined)} pending={commandFormPending}
commandError={visibleCommandNotice?.message}
onCancel={() => {
if (!selectedRuntimeId) return;
setCommandForms((current) => {
const next = { ...current };
delete next[selectedRuntimeId];
return next;
});
}}
onSubmit={(input) => onSubmit={(input) =>
commandForm && void runRpcCommand(commandForm, input) visibleCommandForm &&
void runRpcCommand(visibleCommandForm, input, selected)
} }
/> />
<ExtensionDialog <ExtensionDialog
@@ -722,13 +1134,8 @@ export default function App() {
</span> </span>
<div> <div>
<strong id="startup-blocker-title">Starting new session</strong> <strong id="startup-blocker-title">Starting new session</strong>
<p> <p>{state.newSessionLaunch?.detail ?? "Creating a fresh Pi runtime…"}</p>
{state.newSessionLaunch?.detail ?? <small>Pi is loading its session and extensions. Please wait.</small>
"Creating a fresh Pi runtime…"}
</p>
<small>
Pi is loading its session and extensions. Please wait.
</small>
</div> </div>
</div> </div>
</div> </div>
+23 -12
View File
@@ -3,9 +3,8 @@ import type { RpcCommand, RpcField } from "../commands/rpc";
const controls = (container: HTMLElement | null) => const controls = (container: HTMLElement | null) =>
Array.from( Array.from(
container?.querySelectorAll<HTMLElement>( container?.querySelectorAll<HTMLElement>("button, input, select, textarea") ??
"button, input, select, textarea", [],
) ?? [],
).filter((node) => !node.hasAttribute("disabled")); ).filter((node) => !node.hasAttribute("disabled"));
function valueFor(field: RpcField, value: string | boolean): unknown { function valueFor(field: RpcField, value: string | boolean): unknown {
@@ -19,23 +18,25 @@ export function CommandFormDialog({
command, command,
onSubmit, onSubmit,
onCancel, onCancel,
pending = false,
commandError,
}: { }: {
command?: RpcCommand; command?: RpcCommand;
onSubmit: (input: Record<string, unknown>) => void; onSubmit: (input: Record<string, unknown>) => void;
onCancel: () => void; onCancel: () => void;
pending?: boolean;
commandError?: string;
}) { }) {
const ref = useRef<HTMLElement>(null); const ref = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null); const opener = useRef<HTMLElement | null>(null);
const [values, setValues] = useState<Record<string, string | boolean>>({}); const [values, setValues] = useState<Record<string, string | boolean>>({});
const [error, setError] = useState<string>(); const [validationError, setValidationError] = useState<string>();
useEffect(() => { useEffect(() => {
if (!command) return; if (!command) return;
opener.current = document.activeElement as HTMLElement | null; opener.current = document.activeElement as HTMLElement | null;
setValues({}); setValues({});
setError(undefined); setValidationError(undefined);
const frame = requestAnimationFrame(() => const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
controls(ref.current)[0]?.focus(),
);
return () => { return () => {
cancelAnimationFrame(frame); cancelAnimationFrame(frame);
opener.current?.focus(); opener.current?.focus();
@@ -44,6 +45,7 @@ export function CommandFormDialog({
if (!command) return null; if (!command) return null;
const fields = command.input ?? []; const fields = command.input ?? [];
const submit = () => { const submit = () => {
if (pending) return;
try { try {
const input: Record<string, unknown> = {}; const input: Record<string, unknown> = {};
for (const field of fields) { for (const field of fields) {
@@ -57,7 +59,7 @@ export function CommandFormDialog({
} }
onSubmit(input); onSubmit(input);
} catch (cause) { } catch (cause) {
setError(cause instanceof Error ? cause.message : "Invalid input"); setValidationError(cause instanceof Error ? cause.message : "Invalid input");
} }
}; };
return ( return (
@@ -68,6 +70,7 @@ export function CommandFormDialog({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="command-form-title" aria-labelledby="command-form-title"
aria-busy={pending}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
@@ -99,6 +102,7 @@ export function CommandFormDialog({
aria-label={field.label} aria-label={field.label}
type="checkbox" type="checkbox"
checked={values[field.name] === true} checked={values[field.name] === true}
disabled={pending}
onChange={(event) => onChange={(event) =>
setValues({ setValues({
...values, ...values,
@@ -110,6 +114,7 @@ export function CommandFormDialog({
<select <select
aria-label={field.label} aria-label={field.label}
value={(values[field.name] as string) ?? ""} value={(values[field.name] as string) ?? ""}
disabled={pending}
onChange={(event) => onChange={(event) =>
setValues({ setValues({
...values, ...values,
@@ -126,6 +131,7 @@ export function CommandFormDialog({
<textarea <textarea
aria-label={field.label} aria-label={field.label}
value={(values[field.name] as string) ?? ""} value={(values[field.name] as string) ?? ""}
disabled={pending}
onChange={(event) => onChange={(event) =>
setValues({ setValues({
...values, ...values,
@@ -137,6 +143,7 @@ export function CommandFormDialog({
<input <input
aria-label={field.label} aria-label={field.label}
value={(values[field.name] as string) ?? ""} value={(values[field.name] as string) ?? ""}
disabled={pending}
onChange={(event) => onChange={(event) =>
setValues({ setValues({
...values, ...values,
@@ -147,10 +154,14 @@ export function CommandFormDialog({
)} )}
</label> </label>
))} ))}
{error && <p role="alert">{error}</p>} {(validationError || commandError) && (
<p role="alert">{validationError || commandError}</p>
)}
<div className="extension-options"> <div className="extension-options">
<button onClick={submit}>Run command</button> <button disabled={pending} onClick={submit}>
<button className="quiet" onClick={onCancel}> {pending ? "Running…" : "Run command"}
</button>
<button className="quiet" disabled={pending} onClick={onCancel}>
Cancel Cancel
</button> </button>
</div> </div>
+280 -90
View File
@@ -2,6 +2,15 @@ import { useEffect, useMemo, useRef } from "react";
import type { Model, RuntimeView, TodoTask } from "../workspace/types"; import type { Model, RuntimeView, TodoTask } from "../workspace/types";
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc"; import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
import { CommandPalette } from "./CommandPalette"; import { CommandPalette } from "./CommandPalette";
import { ModelSelector } from "./ModelSelector";
type SessionStatus = {
label: string;
detail: string;
phase: RuntimeView["progress"]["phase"];
bridge: string;
extensions: string[];
};
export function messageText(message: RuntimeView["messages"][number]) { export function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content; if (typeof message.content === "string") return message.content;
@@ -15,21 +24,77 @@ export function messageText(message: RuntimeView["messages"][number]) {
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
} }
function visibleLines(text: string) {
const lines = text.replace(/\r\n?/g, "\n").split("\n");
while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
return lines;
}
function compactCount(value: number) {
const units = [
{ threshold: 1_000_000_000, divisor: 1_000_000_000, suffix: "b" },
{ threshold: 1_000_000, divisor: 1_000_000, suffix: "m" },
{ threshold: 1_000, divisor: 1_000, suffix: "k" },
];
const unit = units.find((candidate) => Math.abs(value) >= candidate.threshold);
if (!unit) return String(value);
const scaled = Math.round((value / unit.divisor) * 10) / 10;
return `${scaled}${unit.suffix}`;
}
function runtimeStatusLabel(isWorking: boolean, state: string) {
if (isWorking) return "Working";
return state === "idle" ? "Pi ready" : state;
}
function plainStatusText(value: string) {
return value
.normalize("NFC")
.replace(/\u001B\[[0-?]*[ -/]*[@-~]/gu, "")
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/gu, "")
.replace(
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uE000-\uF8FF\uFEFF\uFFFD]/gu,
"",
)
.replace(/\s+/gu, " ")
.trim();
}
function TranscriptMessage({ function TranscriptMessage({
message, message,
}: { }: {
message: RuntimeView["messages"][number]; message: RuntimeView["messages"][number];
}) { }) {
const role = message.role ?? "system"; const role = message.role ?? "system";
if (role === "toolResult") if (role === "toolResult") {
const text = messageText(message);
const lines = visibleLines(text);
const label = `Tool result · ${message.toolName ?? "tool"}`;
if (lines.length <= 2)
return (
<article className={`message ${role} tool-result-visible`}>
<strong>{label}</strong>
<pre>{text}</pre>
</article>
);
return ( return (
<article className={`message ${role}`}> <article className={`message ${role}`}>
<details className="tool-result"> <details className="tool-result">
<summary>Tool result · {message.toolName ?? "tool"}</summary> <summary>
<pre>{messageText(message)}</pre> <span className="tool-result-label">{label}</span>
<span className="tool-result-preview">
{lines.slice(0, 2).join("\n")}
</span>
<span className="tool-result-hidden-hint">
{lines.length - 2} more {lines.length - 2 === 1 ? "line" : "lines"}{" "}
hidden · Expand to view
</span>
</summary>
<pre className="tool-result-full">{text}</pre>
</details> </details>
</article> </article>
); );
}
return ( return (
<article className={`message ${role}`}> <article className={`message ${role}`}>
<strong>{message.role ?? "message"}</strong> <strong>{message.role ?? "message"}</strong>
@@ -77,6 +142,10 @@ export function ConversationWorkspace({
onThinking, onThinking,
isSubmitting = false, isSubmitting = false,
isAborting = false, isAborting = false,
isModelUpdating = false,
locked = false,
lockReason = "renewing",
status,
onCommand, onCommand,
}: { }: {
runtime: RuntimeView; runtime: RuntimeView;
@@ -84,90 +153,146 @@ export function ConversationWorkspace({
onSubmit: () => void; onSubmit: () => void;
onAbort: () => void; onAbort: () => void;
onScroll: (atBottom: boolean, scrollTop: number) => void; onScroll: (atBottom: boolean, scrollTop: number) => void;
onModel: (model: Model) => void; onModel: (model: Model) => Promise<boolean>;
onThinking: (level: string) => void; onThinking: (level: string) => Promise<boolean>;
isSubmitting?: boolean; isSubmitting?: boolean;
isAborting?: boolean; isAborting?: boolean;
isModelUpdating?: boolean;
locked?: boolean;
lockReason?: "renewing" | "restarting";
status?: SessionStatus;
onCommand: (command: RpcCommand) => void; onCommand: (command: RpcCommand) => void;
}) { }) {
const ref = useRef<HTMLElement>(null); const ref = useRef<HTMLElement>(null);
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]); const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
const runtimeIsWorking = const runtimeIsWorking =
runtime.summary.state === "streaming" || runtime.summary.state === "streaming" || runtime.progress.phase === "working";
runtime.progress.phase === "working";
const isWorking = const isWorking =
isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering"; isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
const presentedStatus = status ?? {
label: runtimeStatusLabel(isWorking, runtime.summary.state),
detail: isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail,
phase: isWorking ? ("working" as const) : runtime.progress.phase,
bridge: "unknown",
extensions: Object.values(runtime.extensionStatuses),
};
const visibleStatus = {
label: plainStatusText(presentedStatus.label) || "Pi status",
detail: plainStatusText(presentedStatus.detail) || "No status details",
bridge: plainStatusText(presentedStatus.bridge) || "unknown",
extensions: presentedStatus.extensions.map(plainStatusText).filter(Boolean),
};
const activeTool = runtime.progress.tool
? plainStatusText(runtime.progress.tool)
: undefined;
const inputTokens = runtime.stats.tokens?.input;
const outputTokens = runtime.stats.tokens?.output;
const cacheReadTokens = runtime.stats.tokens?.cacheRead;
const undividedTokens =
inputTokens === undefined && outputTokens === undefined
? runtime.stats.tokens?.total
: undefined;
const context = runtime.stats.contextUsage;
const contextPercent =
context?.percent ??
(context?.tokens != null && context.contextWindow
? (context.tokens / context.contextWindow) * 100
: undefined);
let contextDetail: string | undefined;
if (context?.tokens != null) {
contextDetail = compactCount(context.tokens);
if (context.contextWindow !== undefined)
contextDetail += ` / ${compactCount(context.contextWindow)}`;
if (contextPercent !== undefined)
contextDetail += ` · ${Math.round(contextPercent)}%`;
}
useEffect(() => { useEffect(() => {
if (runtime.scroll.atBottom && ref.current) if (runtime.scroll.atBottom && ref.current)
ref.current.scrollTop = ref.current.scrollHeight; ref.current.scrollTop = ref.current.scrollHeight;
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]); }, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
const scrollToLatest = () => {
const transcript = ref.current;
if (!transcript) return;
transcript.scrollTop = transcript.scrollHeight;
onScroll(true, transcript.scrollTop);
transcript.focus();
};
return ( return (
<div <div
className="workflow" className="workflow"
id="session-workspace" id="session-workspace"
role="tabpanel" role="tabpanel"
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`} aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
aria-busy={locked}
> >
<div className="workflow-main"> <div className="workflow-main" inert={locked ? true : undefined}>
<section <div className="transcript-container">
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`} <section
aria-label="Conversation" className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
ref={ref} aria-label="Conversation"
onScroll={(event) => { ref={ref}
const node = event.currentTarget; tabIndex={-1}
onScroll( onScroll={(event) => {
node.scrollHeight - node.scrollTop - node.clientHeight <= 4, const node = event.currentTarget;
node.scrollTop, onScroll(
); node.scrollHeight - node.scrollTop - node.clientHeight <= 4,
}} node.scrollTop,
> );
{isWorking && ( }}
<div className="agent-working" role="status" aria-live="polite"> >
<span className="working-bars" aria-hidden="true"> {isWorking && (
<i /> <div className="agent-working" role="status" aria-live="polite">
<i /> <span className="working-bars" aria-hidden="true">
<i /> <i />
</span> <i />
<div> <i />
<strong>Pi is working</strong>
<span>
{isSubmitting
? "Sending your prompt…"
: runtime.progress.detail}
</span> </span>
{runtime.progress.tool && ( <div>
<small>Using {runtime.progress.tool}</small> <strong>Pi is working</strong>
)} <span>
{isSubmitting ? "Sending your prompt…" : runtime.progress.detail}
</span>
{runtime.progress.tool && <small>Using {runtime.progress.tool}</small>}
</div>
</div> </div>
</div> )}
)} {runtime.loading && !runtime.messages.length && (
{runtime.loading && !runtime.messages.length && ( <p className="muted">Loading session…</p>
<p className="muted">Loading session…</p> )}
)} {!runtime.loading && !runtime.messages.length && (
{!runtime.loading && !runtime.messages.length && ( <p className="muted">No messages yet.</p>
<p className="muted">No messages yet.</p> )}
)} {runtime.messages.map((message, index) => (
{runtime.messages.map((message, index) => ( <TranscriptMessage key={index} message={message} />
<TranscriptMessage key={index} message={message} /> ))}
))} {runtime.pendingSubmissions.map((pending) => (
{runtime.pendingSubmissions.map((pending) => ( <article
<article className={`message user pending-message ${pending.phase}`}
className={`message user pending-message ${pending.phase}`} key={pending.id}
key={pending.id} >
<strong>
You <span>{pendingStatus(pending)}</span>
</strong>
<pre>{pending.text}</pre>
</article>
))}
</section>
{!runtime.scroll.atBottom && (
<button
type="button"
className="scroll-to-latest"
aria-label="Scroll to latest message"
title="Scroll to latest message"
onClick={scrollToLatest}
> >
<strong> <span aria-hidden="true">↓</span>
You <span>{pendingStatus(pending)}</span> </button>
</strong> )}
<pre>{pending.text}</pre> </div>
</article>
))}
</section>
<aside className="todos-pane" aria-label="Current todos"> <aside className="todos-pane" aria-label="Current todos">
<div className="todos-heading"> <div className="todos-heading">
<strong>Todos</strong> <strong>Todos</strong>
<span> <span>{tasks.filter((task) => task.status !== "completed").length}</span>
{tasks.filter((task) => task.status !== "completed").length}
</span>
</div> </div>
{tasks.length ? ( {tasks.length ? (
<ol className="todo-list"> <ol className="todo-list">
@@ -186,7 +311,7 @@ export function ConversationWorkspace({
)} )}
</aside> </aside>
</div> </div>
<footer className="workflow-footer"> <footer className="workflow-footer" inert={locked ? true : undefined}>
{Object.values(runtime.extensionWidgets) {Object.values(runtime.extensionWidgets)
.flatMap((widget) => widget.lines) .flatMap((widget) => widget.lines)
.map((line, index) => ( .map((line, index) => (
@@ -197,32 +322,20 @@ export function ConversationWorkspace({
<section <section
className="pi-controls" className="pi-controls"
aria-label="Model and thinking controls" aria-label="Model and thinking controls"
aria-busy={isModelUpdating || locked}
> >
<label> <ModelSelector
Model models={runtime.models}
<select current={runtime.state.model}
value={`${runtime.state.model?.provider ?? ""}/${runtime.state.model?.id ?? ""}`} onSelect={onModel}
onChange={(event) => { pending={isModelUpdating || locked}
const [provider, id] = event.currentTarget.value.split("/"); />
onModel({ provider, id });
}}
>
<option value="">Current model</option>
{runtime.models.map((model) => (
<option
key={`${model.provider}/${model.id}`}
value={`${model.provider}/${model.id}`}
>
{model.provider}/{model.id ?? model.name}
</option>
))}
</select>
</label>
<label> <label>
Thinking Thinking
<select <select
value={runtime.state.thinkingLevel ?? "medium"} value={runtime.state.thinkingLevel ?? "medium"}
onChange={(event) => onThinking(event.currentTarget.value)} disabled={isModelUpdating || locked}
onChange={(event) => void onThinking(event.currentTarget.value)}
> >
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map( {["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
(level) => ( (level) => (
@@ -233,15 +346,69 @@ export function ConversationWorkspace({
</label> </label>
</section> </section>
<div <div
className={`composer-progress ${isSubmitting ? "working" : runtime.progress.phase}`} className={`session-status-strip ${presentedStatus.phase}`}
role="status" role="status"
aria-label="Session status"
aria-live="polite" aria-live="polite"
> >
<span className="work-dot" /> <span className="session-status-primary">
<span> <span className="work-dot" aria-hidden="true" />
{isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail} <strong>{visibleStatus.label}</strong>
<span className="session-status-detail">{visibleStatus.detail}</span>
</span>
<span className="session-status-meta" aria-label="Status details">
{inputTokens !== undefined && (
<span className="status-chip" title="Input tokens">
<small>In</small>
{compactCount(inputTokens)}
</span>
)}
{outputTokens !== undefined && (
<span className="status-chip" title="Output tokens">
<small>Out</small>
{compactCount(outputTokens)}
</span>
)}
{cacheReadTokens !== undefined && (
<span className="status-chip" title="Cache-read tokens">
<small>Cache read</small>
{compactCount(cacheReadTokens)}
</span>
)}
{undividedTokens !== undefined && (
<span className="status-chip" title="Total tokens">
<small>Tokens</small>
{compactCount(undividedTokens)}
</span>
)}
{contextDetail && (
<span className="status-chip" title="Current context window usage">
<small>Context</small>
{contextDetail}
</span>
)}
{runtime.summary.queueCount > 0 && (
<span className="status-chip">
<small>Queue</small>
{runtime.summary.queueCount}
</span>
)}
<span className="status-chip bridge-status">
<small>Bridge</small>
{visibleStatus.bridge}
</span>
{activeTool && (
<span className="status-chip">
<small>Tool</small>
{activeTool}
</span>
)}
{visibleStatus.extensions.map((entry, index) => (
<span className="status-chip extension-status" key={`${entry}-${index}`}>
{entry}
</span>
))}
</span> </span>
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
</div> </div>
<div className="composer"> <div className="composer">
<CommandPalette <CommandPalette
@@ -252,7 +419,7 @@ export function ConversationWorkspace({
<textarea <textarea
aria-label="Prompt" aria-label="Prompt"
value={runtime.draft} value={runtime.draft}
disabled={isSubmitting} disabled={isSubmitting || locked}
onChange={(event) => onDraft(event.currentTarget.value)} onChange={(event) => onDraft(event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
@@ -267,7 +434,7 @@ export function ConversationWorkspace({
<button <button
type="button" type="button"
className="quiet" className="quiet"
disabled={isAborting} disabled={isAborting || locked}
onClick={onAbort} onClick={onAbort}
> >
{isAborting ? "Aborting…" : "Abort"} {isAborting ? "Aborting…" : "Abort"}
@@ -276,6 +443,7 @@ export function ConversationWorkspace({
<button <button
type="button" type="button"
disabled={ disabled={
locked ||
isSubmitting || isSubmitting ||
!runtime.summary.agentId || !runtime.summary.agentId ||
!runtime.draft.trim() !runtime.draft.trim()
@@ -287,6 +455,28 @@ export function ConversationWorkspace({
</div> </div>
</div> </div>
</footer> </footer>
{locked && (
<div className="session-lock" role="status" aria-live="polite">
<div className="session-lock-card">
<span className="working-bars session-lock-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
<div>
<strong>
{lockReason === "renewing" ? "Renewing session" : "Restarting Pi"}
</strong>
<p>
{lockReason === "renewing"
? "Clearing active history and starting a fresh Pi session…"
: "Restoring this Pi session and its extensions…"}
</p>
<small>Input and output are temporarily locked.</small>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+94
View File
@@ -0,0 +1,94 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { Model } from "../workspace/types";
import { ModelSelector } from "./ModelSelector";
const models: Model[] = [
{ provider: "anthropic", id: "claude-sonnet-4", name: "Claude Sonnet" },
{ provider: "openai", id: "organization/gpt-5", name: "GPT 5" },
{ provider: "openai", id: "o3" },
{ provider: "google", name: "Missing ID" },
];
describe("ModelSelector", () => {
it("groups valid models by provider and marks the current model", async () => {
const user = userEvent.setup();
render(
<ModelSelector
models={models}
current={{ provider: "anthropic", id: "claude-sonnet-4" }}
onSelect={vi.fn().mockResolvedValue(true)}
/>,
);
await user.click(
screen.getByRole("button", { name: /anthropic · Claude Sonnet/i }),
);
expect(screen.getByRole("group", { name: "anthropic" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "openai" })).toBeInTheDocument();
expect(screen.queryByRole("group", { name: "google" })).toBeNull();
expect(screen.getByRole("status")).toHaveTextContent("3 models");
expect(screen.getByRole("option", { name: /Claude Sonnet/ })).toHaveAttribute(
"aria-selected",
"true",
);
});
it("searches provider, name, and ID and preserves IDs containing slashes", async () => {
const user = userEvent.setup();
const onSelect = vi.fn().mockResolvedValue(true);
render(<ModelSelector models={models} onSelect={onSelect} />);
await user.click(screen.getByRole("button", { name: /Current model/i }));
const search = screen.getByRole("searchbox", { name: "Search models" });
await user.type(search, "OPENAI");
expect(screen.getByRole("status")).toHaveTextContent("2 models");
expect(screen.queryByRole("group", { name: "anthropic" })).toBeNull();
await user.clear(search);
await user.type(search, "gpt 5");
expect(screen.getByRole("status")).toHaveTextContent("1 model");
await user.clear(search);
await user.type(search, "ORGANIZATION/GPT-5");
await user.click(screen.getByRole("option", { name: /GPT 5/ }));
expect(onSelect).toHaveBeenCalledWith(models[1]);
expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull();
});
it("shows an empty state and closes on Escape with focus restored", async () => {
const user = userEvent.setup();
render(
<ModelSelector models={models} onSelect={vi.fn().mockResolvedValue(true)} />,
);
const trigger = screen.getByRole("button", { name: /Current model/i });
await user.click(trigger);
const search = screen.getByRole("searchbox", { name: "Search models" });
await waitFor(() => expect(search).toHaveFocus());
await user.type(search, "missing-model");
expect(screen.getByText(/No models match/)).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("0 models");
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull();
await waitFor(() => expect(trigger).toHaveFocus());
});
it("keeps the selector open and restores controls when an update fails", async () => {
const user = userEvent.setup();
const onSelect = vi.fn().mockResolvedValue(false);
render(<ModelSelector models={models} onSelect={onSelect} />);
await user.click(screen.getByRole("button", { name: /Current model/i }));
await user.click(screen.getByRole("option", { name: /Claude Sonnet/ }));
expect(onSelect).toHaveBeenCalledWith(models[0]);
expect(
screen.getByRole("dialog", { name: "Choose model" }),
).toBeInTheDocument();
expect(
screen.getByRole("searchbox", { name: "Search models" }),
).toBeEnabled();
});
});
+234
View File
@@ -0,0 +1,234 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { AgentState, Model } from "../workspace/types";
type SelectableModel = Model & { provider: string; id: string };
type ModelGroup = { provider: string; models: SelectableModel[] };
function isSelectableModel(model: Model): model is SelectableModel {
return Boolean(model.provider?.trim() && model.id?.trim());
}
function modelName(model: SelectableModel) {
return model.name?.trim() || model.id;
}
function modelMatches(model: SelectableModel, query: string) {
if (!query) return true;
return [model.provider, model.id, model.name]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLocaleLowerCase().includes(query));
}
function groupModels(models: SelectableModel[], query: string): ModelGroup[] {
const byProvider = new Map<string, SelectableModel[]>();
for (const model of models) {
if (!modelMatches(model, query)) continue;
const providerModels = byProvider.get(model.provider) ?? [];
providerModels.push(model);
byProvider.set(model.provider, providerModels);
}
return [...byProvider.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([provider, providerModels]) => ({
provider,
models: providerModels.sort((left, right) =>
modelName(left).localeCompare(modelName(right)),
),
}));
}
function ProviderGroup({
group,
current,
headingId,
onSelect,
disabled,
}: {
group: ModelGroup;
current?: AgentState["model"];
headingId: string;
onSelect: (model: SelectableModel) => void;
disabled: boolean;
}) {
return (
<div
className="model-provider-group"
role="group"
aria-labelledby={headingId}
>
<div className="model-provider-heading" id={headingId}>
{group.provider}
</div>
{group.models.map((model) => {
const name = modelName(model);
return (
<button
type="button"
className="model-option"
role="option"
aria-selected={
model.provider === current?.provider && model.id === current?.id
}
key={`${model.provider}/${model.id}`}
disabled={disabled}
onClick={() => onSelect(model)}
>
<span>{name}</span>
{name !== model.id && <small>{model.id}</small>}
</button>
);
})}
</div>
);
}
export function ModelSelector({
models,
current,
onSelect,
pending = false,
}: {
models: Model[];
current?: AgentState["model"];
onSelect: (model: Model) => Promise<boolean>;
pending?: boolean;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [selecting, setSelecting] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const labelId = useId();
const valueId = useId();
const menuId = useId();
const availableModels = useMemo(
() => models.filter(isSelectableModel),
[models],
);
const groups = useMemo(
() => groupModels(availableModels, query.trim().toLocaleLowerCase()),
[availableModels, query],
);
const resultCount = groups.reduce(
(count, group) => count + group.models.length,
0,
);
const selectedModel = availableModels.find(
(model) => model.provider === current?.provider && model.id === current?.id,
);
const selectedName = selectedModel
? modelName(selectedModel)
: current?.name?.trim() || current?.id;
const selectedLabel = current?.provider
? `${current.provider} · ${selectedName ?? "Current model"}`
: "Current model";
const busy = pending || selecting;
useEffect(() => {
if (!open) return;
const focusFrame = window.requestAnimationFrame(() =>
searchRef.current?.focus(),
);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
setOpen(false);
window.requestAnimationFrame(() => triggerRef.current?.focus());
};
const closeOutside = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
window.addEventListener("pointerdown", closeOutside);
return () => {
window.cancelAnimationFrame(focusFrame);
window.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("pointerdown", closeOutside);
};
}, [open]);
const chooseModel = async (model: SelectableModel) => {
if (busy) return;
setSelecting(true);
try {
if (!(await onSelect(model))) return;
setOpen(false);
window.requestAnimationFrame(() => triggerRef.current?.focus());
} finally {
setSelecting(false);
}
};
return (
<div className="model-control" aria-busy={busy}>
<span id={labelId}>Model</span>
<div className="model-selector" ref={rootRef}>
<button
ref={triggerRef}
type="button"
className="model-trigger"
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={open ? menuId : undefined}
aria-labelledby={`${labelId} ${valueId}`}
disabled={availableModels.length === 0 || busy}
onClick={() => {
setQuery("");
setOpen((value) => !value);
}}
>
<span id={valueId}>{selectedLabel}</span>
</button>
{open && (
<div
id={menuId}
className="model-menu"
role="dialog"
aria-label="Choose model"
>
<label className="sr-only" htmlFor={`${menuId}-search`}>
Search models
</label>
<input
ref={searchRef}
id={`${menuId}-search`}
type="search"
value={query}
disabled={busy}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="Search provider, name, or model ID"
aria-controls={`${menuId}-results`}
/>
<p className="model-result-count" role="status" aria-live="polite">
{busy
? "Updating Pi model…"
: `${resultCount} model${resultCount === 1 ? "" : "s"}`}
</p>
{groups.length ? (
<div
id={`${menuId}-results`}
className="model-results"
role="listbox"
aria-label="Available models"
>
{groups.map((group, index) => (
<ProviderGroup
key={group.provider}
group={group}
current={current}
headingId={`${menuId}-provider-${index}`}
onSelect={(model) => void chooseModel(model)}
disabled={busy}
/>
))}
</div>
) : (
<p className="model-empty muted">No models match “{query.trim()}”.</p>
)}
</div>
)}
</div>
</div>
);
}
+47 -17
View File
@@ -6,10 +6,12 @@ type Props = {
selectedId?: string; selectedId?: string;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onClose: (runtime: RuntimeView) => void; onClose: (runtime: RuntimeView) => void;
onRenew: (runtime: RuntimeView) => void;
onNew: () => void; onNew: () => void;
onResume: () => void; onResume: () => void;
busy?: boolean; busy?: boolean;
closingRuntimeId?: string; closingRuntimeIds?: ReadonlySet<string>;
renewingRuntimeIds?: ReadonlySet<string>;
restoreFocus?: boolean; restoreFocus?: boolean;
onFocusRestored?: () => void; onFocusRestored?: () => void;
}; };
@@ -18,10 +20,12 @@ export function SessionTabs({
selectedId, selectedId,
onSelect, onSelect,
onClose, onClose,
onRenew,
onNew, onNew,
onResume, onResume,
busy, busy,
closingRuntimeId, closingRuntimeIds = new Set(),
renewingRuntimeIds = new Set(),
restoreFocus, restoreFocus,
onFocusRestored, onFocusRestored,
}: Props) { }: Props) {
@@ -52,6 +56,8 @@ export function SessionTabs({
{runtimes.map((runtime, index) => { {runtimes.map((runtime, index) => {
const summary = runtime.summary; const summary = runtime.summary;
const selected = summary.runtimeId === selectedId; const selected = summary.runtimeId === selectedId;
const renewing = renewingRuntimeIds.has(summary.runtimeId);
const closing = closingRuntimeIds.has(summary.runtimeId);
return ( return (
<div <div
className={`session-tab ${selected ? "selected" : ""}`} className={`session-tab ${selected ? "selected" : ""}`}
@@ -72,9 +78,7 @@ export function SessionTabs({
<span>{summary.label}</span> <span>{summary.label}</span>
<small className={summary.state}> <small className={summary.state}>
{summary.state} {summary.state}
{summary.queueCount {summary.queueCount ? ` · ${summary.queueCount} queued` : ""}
? ` · ${summary.queueCount} queued`
: ""}
</small> </small>
{runtime.unread && ( {runtime.unread && (
<i className="unread-dot" aria-label="Unread activity" /> <i className="unread-dot" aria-label="Unread activity" />
@@ -85,32 +89,58 @@ export function SessionTabs({
</b> </b>
)} )}
</button> </button>
<button
className="renew-session"
aria-label={
renewing ? `Renewing ${summary.label}` : `Renew ${summary.label}`
}
aria-busy={renewing}
title={`Renew ${summary.label} and clear active history`}
disabled={renewing || closing}
onClick={() => onRenew(runtime)}
>
{renewing ? (
<span className="tab-action-spinner" aria-hidden="true" />
) : (
<svg
className="renew-session-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M20 11a8 8 0 1 1-2.34-5.66M20 4v7h-7" />
</svg>
)}
</button>
<button <button
className="close-session" className="close-session"
aria-label={`Close ${summary.label}`} aria-label={
disabled={closingRuntimeId === summary.runtimeId} closing ? `Closing ${summary.label}` : `Close ${summary.label}`
}
aria-busy={closing}
disabled={renewing || closing}
onClick={() => onClose(runtime)} onClick={() => onClose(runtime)}
> >
{closingRuntimeId === summary.runtimeId ? "…" : "×"} {closing ? "…" : "×"}
</button> </button>
</div> </div>
); );
})} })}
</div> </div>
<button
className="new-session-tab"
ref={newSessionRef}
aria-label={busy ? "Starting new session" : "New session"}
title="New session"
disabled={busy}
onClick={onNew}
>
<span aria-hidden="true">{busy ? "…" : "+"}</span>
</button>
<div className="session-tab-actions" aria-label="Session actions"> <div className="session-tab-actions" aria-label="Session actions">
<button className="load-session" disabled={busy} onClick={onResume}> <button className="load-session" disabled={busy} onClick={onResume}>
<span aria-hidden="true">⌄</span> <span aria-hidden="true">⌄</span>
{busy ? "Working…" : "Load"} {busy ? "Working…" : "Load"}
</button> </button>
<button
className="new-session"
ref={newSessionRef}
disabled={busy}
onClick={onNew}
>
<span aria-hidden="true">+</span>
{busy ? "Starting…" : "New"}
</button>
</div> </div>
</div> </div>
</div> </div>
+169 -14
View File
@@ -42,6 +42,8 @@ describe("multi-session navigation", () => {
it("renders directory aggregates and accessible keyboard session tabs", async () => { it("renders directory aggregates and accessible keyboard session tabs", async () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace); const state = reconcileWorkspace(initialWorkspaceState, workspace);
const onSelect = vi.fn(); const onSelect = vi.fn();
const onNew = vi.fn();
const onRenew = vi.fn();
const { rerender } = render( const { rerender } = render(
<> <>
<DirectorySidebar <DirectorySidebar
@@ -58,7 +60,8 @@ describe("multi-session navigation", () => {
selectedId="a" selectedId="a"
onSelect={onSelect} onSelect={onSelect}
onClose={vi.fn()} onClose={vi.fn()}
onNew={vi.fn()} onRenew={onRenew}
onNew={onNew}
onResume={vi.fn()} onResume={vi.fn()}
/> />
</>, </>,
@@ -66,6 +69,15 @@ describe("multi-session navigation", () => {
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument(); expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
const tabs = screen.getAllByRole("tab"); const tabs = screen.getAllByRole("tab");
expect(tabs[0]).toHaveAttribute("aria-selected", "true"); expect(tabs[0]).toHaveAttribute("aria-selected", "true");
const closeFirst = screen.getByRole("button", { name: "Close a" });
const renewFirst = screen.getByRole("button", { name: "Renew a" });
expect(tabs[0].parentElement?.lastElementChild).toBe(closeFirst);
expect(closeFirst.previousElementSibling).toBe(renewFirst);
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
const newSession = screen.getByRole("button", { name: "New session" });
expect(tablist.nextElementSibling).toBe(newSession);
await userEvent.click(newSession);
expect(onNew).toHaveBeenCalledOnce();
await userEvent.click(tabs[0]); await userEvent.click(tabs[0]);
await userEvent.keyboard("{ArrowRight}"); await userEvent.keyboard("{ArrowRight}");
expect(onSelect).toHaveBeenCalledWith("b"); expect(onSelect).toHaveBeenCalledWith("b");
@@ -75,12 +87,31 @@ describe("multi-session navigation", () => {
selectedId="b" selectedId="b"
onSelect={onSelect} onSelect={onSelect}
onClose={vi.fn()} onClose={vi.fn()}
onRenew={vi.fn()}
onNew={vi.fn()} onNew={vi.fn()}
onResume={vi.fn()} onResume={vi.fn()}
/>, />,
); );
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/); expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
}); });
it("announces close progress only on the closing session", () => {
render(
<SessionTabs
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
selectedId="a"
onSelect={vi.fn()}
onClose={vi.fn()}
onRenew={vi.fn()}
onNew={vi.fn()}
onResume={vi.fn()}
closingRuntimeIds={new Set(["b"])}
/>,
);
const closing = screen.getByRole("button", { name: "Closing b" });
expect(closing).toBeDisabled();
expect(closing).toHaveAttribute("aria-busy", "true");
expect(screen.getByRole("button", { name: "Close a" })).toBeEnabled();
});
it("restores focus to the selected adjacent tab after close", () => { it("restores focus to the selected adjacent tab after close", () => {
const onFocusRestored = vi.fn(); const onFocusRestored = vi.fn();
render( render(
@@ -89,6 +120,7 @@ describe("multi-session navigation", () => {
selectedId="b" selectedId="b"
onSelect={vi.fn()} onSelect={vi.fn()}
onClose={vi.fn()} onClose={vi.fn()}
onRenew={vi.fn()}
onNew={vi.fn()} onNew={vi.fn()}
onResume={vi.fn()} onResume={vi.fn()}
restoreFocus restoreFocus
@@ -98,12 +130,61 @@ describe("multi-session navigation", () => {
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus(); expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
expect(onFocusRestored).toHaveBeenCalled(); expect(onFocusRestored).toHaveBeenCalled();
}); });
it("collapses a tool result until its summary is expanded", async () => { it("shows two-line tool results and previews longer collapsed results", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const runtime = runtimeView(summary("tool-result")); const runtime = runtimeView(summary("tool-result"));
runtime.messages = [ runtime.messages = [
{ role: "toolResult", toolName: "read", content: "Detailed tool output" }, { role: "toolResult", toolName: "read", content: "first\nsecond\n" },
]; ];
const props = {
onDraft: vi.fn(),
onSubmit: vi.fn(),
onAbort: vi.fn(),
onScroll: vi.fn(),
onModel: vi.fn().mockResolvedValue(true),
onThinking: vi.fn().mockResolvedValue(true),
onCommand: vi.fn(),
};
const { container, rerender } = render(
<ConversationWorkspace runtime={runtime} {...props} />,
);
expect(container.querySelector("details")).toBeNull();
expect(container.querySelector(".tool-result-visible pre")).toHaveTextContent(
"first second",
);
const longRuntime = {
...runtime,
messages: [
{
role: "toolResult",
toolName: "read",
content: "first\nsecond\nthird\n",
},
],
};
rerender(<ConversationWorkspace runtime={longRuntime} {...props} />);
const details = container.querySelector("details");
expect(details).not.toHaveAttribute("open");
expect(container.querySelector(".tool-result-preview")?.textContent).toBe(
"first\nsecond",
);
expect(container.querySelector(".tool-result-full")?.textContent).toBe(
"first\nsecond\nthird\n",
);
expect(
screen.getByText("1 more line hidden · Expand to view"),
).toBeInTheDocument();
await user.click(screen.getByText("Tool result · read"));
expect(details).toHaveAttribute("open");
});
it("packs session status between model controls and the composer", () => {
const runtime = runtimeView(summary("status", "streaming"));
runtime.summary.queueCount = 2;
runtime.stats = {
tokens: { input: 10_000, output: 2_450, cacheRead: 8_000, total: 12_450 },
contextUsage: { tokens: 32_000, contextWindow: 200_000, percent: 16 },
};
const { container } = render( const { container } = render(
<ConversationWorkspace <ConversationWorkspace
runtime={runtime} runtime={runtime}
@@ -114,12 +195,81 @@ describe("multi-session navigation", () => {
onModel={vi.fn()} onModel={vi.fn()}
onThinking={vi.fn()} onThinking={vi.fn()}
onCommand={vi.fn()} onCommand={vi.fn()}
status={{
label: "Working",
detail: "Thinking and preparing a response",
phase: "working",
bridge: "ready",
extensions: ["\u001B[32mLSP\u001B[0m \uE0B0\uFFFDactive\u0007"],
}}
/>, />,
); );
const details = container.querySelector("details"); const modelControls = screen.getByRole("region", {
expect(details).not.toHaveAttribute("open"); name: "Model and thinking controls",
await user.click(screen.getByText("Tool result · read")); });
expect(details).toHaveAttribute("open"); const status = screen.getByRole("status", { name: "Session status" });
const composer = container.querySelector(".composer");
for (const text of [
"Working",
"Thinking and preparing a response",
"Bridge",
"ready",
"In",
"10k",
"Out",
"2.5k",
"Cache read",
"8k",
"Context",
"32k / 200k · 16%",
"Queue",
"2",
"LSP active",
])
expect(status).toHaveTextContent(text);
expect(status.textContent).not.toMatch(/[\u001B\u0007\uE0B0\uFFFD]/u);
expect(modelControls.nextElementSibling).toBe(status);
expect(status.nextElementSibling).toBe(composer);
});
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
const user = userEvent.setup();
const onScroll = vi.fn();
const atBottom = runtimeView(summary("scroll"));
const props = {
onDraft: vi.fn(),
onSubmit: vi.fn(),
onAbort: vi.fn(),
onScroll,
onModel: vi.fn().mockResolvedValue(true),
onThinking: vi.fn().mockResolvedValue(true),
onCommand: vi.fn(),
};
const { rerender } = render(
<ConversationWorkspace runtime={atBottom} {...props} />,
);
expect(
screen.queryByRole("button", { name: "Scroll to latest message" }),
).toBeNull();
const scrolledUp = {
...atBottom,
scroll: { atBottom: false, scrollTop: 125 },
};
rerender(<ConversationWorkspace runtime={scrolledUp} {...props} />);
const transcript = screen.getByRole("region", { name: "Conversation" });
Object.defineProperty(transcript, "scrollHeight", {
configurable: true,
value: 600,
});
transcript.scrollTop = 125;
await user.click(
screen.getByRole("button", { name: "Scroll to latest message" }),
);
expect(transcript.scrollTop).toBe(600);
expect(onScroll).toHaveBeenCalledWith(true, 600);
expect(transcript).toHaveFocus();
}); });
it("shows an aborting control and labels an accepted follow-up", () => { it("shows an aborting control and labels an accepted follow-up", () => {
const runtime = runtimeView(summary("working", "streaming")); const runtime = runtimeView(summary("working", "streaming"));
@@ -191,9 +341,7 @@ describe("multi-session navigation", () => {
await user.type(search, "release"); await user.type(search, "release");
expect(screen.getByRole("status")).toHaveTextContent("1 saved session"); expect(screen.getByRole("status")).toHaveTextContent("1 saved session");
expect( expect(screen.getByRole("button", { name: /Planning/ })).toBeInTheDocument();
screen.getByRole("button", { name: /Planning/ }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Investigate/ })).toBeNull(); expect(screen.queryByRole("button", { name: /Investigate/ })).toBeNull();
await user.clear(search); await user.clear(search);
await user.type(search, "INCIDENT-42"); await user.type(search, "INCIDENT-42");
@@ -235,8 +383,7 @@ describe("multi-session navigation", () => {
extension={{ extension={{
method: "confirm", method: "confirm",
title: "Run a shell command", title: "Run a shell command",
message: message: "rm -rf ./build\nThis removes the generated build directory.",
"rm -rf ./build\nThis removes the generated build directory.",
}} }}
onRespond={onRespond} onRespond={onRespond}
/>, />,
@@ -264,9 +411,10 @@ describe("multi-session navigation", () => {
"Existing request", "Existing request",
); );
}); });
it("routes new, saved-open, and close controls through callbacks", async () => { it("routes new, saved-open, renew, and close controls through callbacks", async () => {
const onNew = vi.fn(), const onNew = vi.fn(),
onResume = vi.fn(), onResume = vi.fn(),
onRenew = vi.fn(),
onClose = vi.fn(); onClose = vi.fn();
render( render(
<SessionTabs <SessionTabs
@@ -274,15 +422,22 @@ describe("multi-session navigation", () => {
selectedId="a" selectedId="a"
onSelect={vi.fn()} onSelect={vi.fn()}
onClose={onClose} onClose={onClose}
onRenew={onRenew}
onNew={onNew} onNew={onNew}
onResume={onResume} onResume={onResume}
/>, />,
); );
await userEvent.click(screen.getByRole("button", { name: "New" })); await userEvent.click(screen.getByRole("button", { name: "New session" }));
await userEvent.click(screen.getByRole("button", { name: "Load" })); await userEvent.click(screen.getByRole("button", { name: "Load" }));
await userEvent.click(screen.getByLabelText("Renew a"));
await userEvent.click(screen.getByLabelText("Close a")); await userEvent.click(screen.getByLabelText("Close a"));
expect(onNew).toHaveBeenCalled(); expect(onNew).toHaveBeenCalled();
expect(onResume).toHaveBeenCalled(); expect(onResume).toHaveBeenCalled();
expect(onRenew).toHaveBeenCalledWith(
expect.objectContaining({
summary: expect.objectContaining({ runtimeId: "a" }),
}),
);
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
}); });
+3 -1
View File
@@ -1,9 +1,11 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import App from "./App"; import App from "./App";
import QuickPane from "./quick-pane/QuickPane";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> {getCurrentWindow().label === "quick-pane" ? <QuickPane /> : <App />}
</React.StrictMode>, </React.StrictMode>,
); );
+355
View File
@@ -0,0 +1,355 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useMemo, useRef, useState } from "react";
import { ExtensionDialog } from "../components/ExtensionDialog";
import "./quick-pane.css";
import type {
Extension,
Model,
QuickPaneSettings,
RuntimeSnapshot,
UiState,
} from "../workspace/types";
const defaults: QuickPaneSettings = {
workspace: "",
provider: "",
model: "",
followUpProvider: "",
followUpModel: "",
instructions:
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.",
maximumSeconds: 60,
maximumToolCalls: 6,
};
type Runtime = { agentId: string; runtimeId: string };
function settingsError(settings: QuickPaneSettings) {
if (!settings.workspace.startsWith("/"))
return "Workspace must be an absolute path.";
if (!settings.provider || !settings.model)
return "Quick provider and model are required.";
if (!settings.followUpProvider || !settings.followUpModel)
return "Follow-up provider and model are required.";
if (!settings.instructions.trim())
return "Supplemental instructions are required.";
if (
!Number.isInteger(settings.maximumSeconds) ||
settings.maximumSeconds < 1 ||
settings.maximumSeconds > 600
)
return "Seconds must be an integer from 1 to 600.";
if (
!Number.isInteger(settings.maximumToolCalls) ||
settings.maximumToolCalls < 1 ||
settings.maximumToolCalls > 64
)
return "Tool calls must be an integer from 1 to 64.";
}
function assistantText(snapshot: RuntimeSnapshot) {
const messages = snapshot.transcript?.data?.messages ?? [];
const message = [...messages]
.reverse()
.find((candidate) => candidate.role === "assistant");
if (!message) return "";
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
.map((part) => part.text ?? "")
.filter(Boolean)
.join("\n");
}
export default function QuickPane() {
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [request, setRequest] = useState("");
const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [status, setStatus] = useState("Loading Quick Pane settings…");
const [error, setError] = useState<string>();
const [runtime, setRuntime] = useState<Runtime>();
const [answer, setAnswer] = useState("");
const [escalation, setEscalation] = useState<string>();
const [extension, setExtension] = useState<Extension>();
const runtimeRef = useRef<Runtime | undefined>(undefined);
const requestRef = useRef(request);
const phaseRef = useRef<"idle" | "starting" | "running">("idle");
const invalid = useMemo(() => settingsError(settings), [settings]);
const dismissPane = () => getCurrentWindow().hide();
useEffect(() => {
let active = true;
invoke<UiState>("load_ui_state")
.then((state) => {
if (!active) return;
const nextSettings = { ...defaults, ...state.quickPane };
setSettings(nextSettings);
setSettingsLoaded(true);
setSelectedModel(`${nextSettings.provider}/${nextSettings.model}`);
setStatus("");
return invoke<{ models?: Model[] }>("get_model_catalog");
})
.then((catalog) => {
if (active) setModels(catalog?.models ?? []);
})
.catch(
(reason) =>
active && setError(`Could not load settings: ${String(reason)}`),
);
return () => {
active = false;
const current = runtimeRef.current;
if (current)
invoke("close_quick_runtime", { runtimeId: current.runtimeId }).catch(
() => undefined,
);
};
}, []);
useEffect(() => {
const paneWindow = getCurrentWindow();
const settingsListener = listen<QuickPaneSettings>(
"quick-pane-settings-saved",
(event) => {
const nextSettings = { ...defaults, ...event.payload };
setSettings(nextSettings);
setSelectedModel(`${nextSettings.provider}/${nextSettings.model}`);
},
);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") void dismissPane();
};
window.addEventListener("keydown", onKeyDown);
const focusListener = paneWindow.onFocusChanged((event) => {
if (
!event.payload &&
phaseRef.current === "idle" &&
!requestRef.current.trim()
)
void dismissPane();
});
const closeListener = paneWindow.onCloseRequested((event) => {
event.preventDefault();
void dismissPane();
});
return () => {
window.removeEventListener("keydown", onKeyDown);
void settingsListener.then((unlisten) => unlisten());
void focusListener.then((unlisten) => unlisten());
void closeListener.then((unlisten) => unlisten());
};
}, []);
useEffect(() => {
if (!runtime) return;
let active = true;
const refresh = async () => {
try {
const snapshot = await invoke<RuntimeSnapshot>(
"get_session_runtime_snapshot",
{
runtimeId: runtime.runtimeId,
},
);
if (!active) return;
const text = assistantText(snapshot);
if (text) {
setAnswer(text);
if (text.includes("[ESCALATE]")) {
setEscalation(
text.replace("[ESCALATE]", "").trim() ||
"Quick agent requested a full session.",
);
} else if (!snapshot.state?.data?.isStreaming) {
setStatus("Quick answer ready.");
}
}
setExtension(snapshot.extensions[0]);
if (snapshot.runtime.error?.message) {
setError(snapshot.runtime.error.message);
setEscalation(
"Quick runtime failed. Continue in a full session if needed.",
);
}
} catch (reason) {
if (active)
setError(`Could not refresh quick result: ${String(reason)}`);
}
};
void refresh();
const interval = window.setInterval(() => void refresh(), 800);
return () => {
active = false;
window.clearInterval(interval);
};
}, [runtime]);
useEffect(() => {
if (!runtime) return;
const timeout = window.setTimeout(() => {
setEscalation(
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
);
setStatus(
"Quick time budget reached. Showing partial result if available.",
);
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
}, settings.maximumSeconds * 1000);
return () => window.clearTimeout(timeout);
}, [runtime, settings.maximumSeconds]);
async function run() {
if (invalid || !request.trim())
return setError(invalid ?? "Enter a request.");
const previous = runtimeRef.current;
if (previous)
await invoke("close_quick_runtime", {
runtimeId: previous.runtimeId,
}).catch(() => undefined);
setError(undefined);
setAnswer("");
setEscalation(undefined);
setExtension(undefined);
phaseRef.current = "starting";
setStatus("Starting advisory quick agent…");
try {
const result = await invoke<{ runtime: Runtime }>(
"create_quick_runtime",
{
worktreePath: settings.workspace,
},
);
runtimeRef.current = result.runtime;
setRuntime(result.runtime);
const [provider, modelId] = selectedModel.split("/", 2);
await invoke("set_model", {
agentId: result.runtime.agentId,
provider: provider || settings.provider,
modelId: modelId || settings.model,
});
await invoke("set_thinking_level", {
agentId: result.runtime.agentId,
level: thinkingLevel,
});
const guardrails = `[Quick Pane advisory mode: target ${settings.maximumSeconds}s and ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more budget, start final response with [ESCALATE] and explain why.]\n${settings.instructions}\n\nUser request: ${request}`;
await invoke("submit_prompt", {
agentId: result.runtime.agentId,
message: guardrails,
});
phaseRef.current = "running";
setStatus(
"Quick request running. Advisory limits are not trusted enforcement.",
);
} catch (reason) {
phaseRef.current = "idle";
setError(`Could not start quick request: ${String(reason)}`);
setStatus("");
}
}
async function respond(response: Record<string, unknown>) {
if (!runtime || !extension?.id) return;
try {
await invoke("respond_to_extension", {
agentId: runtime.agentId,
requestId: extension.id,
response,
});
setExtension(undefined);
} catch (reason) {
setError(`Could not answer Pi request: ${String(reason)}`);
}
}
async function continueFull() {
if (invalid || !request.trim())
return setError(invalid ?? "Enter a request.");
try {
const result = await invoke<{ runtime: Runtime }>(
"create_session_runtime",
{
worktreePath: settings.workspace,
},
);
await invoke("set_model", {
agentId: result.runtime.agentId,
provider: settings.followUpProvider,
modelId: settings.followUpModel,
});
await invoke("submit_prompt", {
agentId: result.runtime.agentId,
message: `${request}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`,
});
setStatus("Full session started with follow-up model.");
} catch (reason) {
setError(`Could not start full session: ${String(reason)}`);
}
}
return (
<main className="quick-pane">
<header style={{ display: "flex", justifyContent: "space-between" }}>
<strong>Quick Pane</strong>
<button onClick={dismissPane}>Close</button>
</header>
<textarea
autoFocus
value={request}
onChange={(e) => {
requestRef.current = e.target.value;
setRequest(e.target.value);
}}
placeholder="Ask a quick question or give a quick instruction"
style={{ width: "100%", minHeight: 90, marginTop: 14 }}
/>
<label>
Model
<select
value={selectedModel}
onChange={(event) => setSelectedModel(event.currentTarget.value)}
>
<option value={`${settings.provider}/${settings.model}`}>
{settings.provider}/{settings.model}
</option>
{models.map((model) => (
<option
key={`${model.provider}/${model.id}`}
value={`${model.provider}/${model.id}`}
>
{model.provider}/{model.id ?? model.name}
</option>
))}
</select>
</label>
<label>
Thinking
<select
value={thinkingLevel}
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<button
disabled={!settingsLoaded || !!invalid || !request.trim()}
onClick={run}
>
Ask
</button>
{(escalation || error) && (
<button onClick={continueFull}>Continue in full session</button>
)}
{status && <p>{status}</p>}
{answer && <pre style={{ whiteSpace: "pre-wrap" }}>{answer}</pre>}
{error && <p role="alert">{error}</p>}
<ExtensionDialog extension={extension} onRespond={respond} />
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
:root { color-scheme: dark; }
body { margin: 0; background: transparent; }
.quick-pane {
box-sizing: border-box;
min-height: 100vh;
padding: 18px;
color: #f4f2ee;
font: 14px/1.4 system-ui, sans-serif;
background: radial-gradient(circle at top, #46494f, #23252a 62%);
border: 1px solid rgba(255,255,255,.16);
border-radius: 14px;
box-shadow: 0 22px 70px rgba(0,0,0,.55);
}
.quick-pane header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; color:#c9c7c2; font-size:12px; letter-spacing:.08em; text-transform:uppercase; }
.quick-pane button, .quick-pane select { border:0; border-radius:7px; padding:7px 10px; color:inherit; background:#45484e; }
.quick-pane textarea { box-sizing:border-box; width:100%; min-height:92px; padding:14px; border:0; border-radius:10px; outline:1px solid rgba(255,255,255,.14); color:#fff; background:rgba(0,0,0,.24); font:inherit; font-size:16px; resize:none; }
.quick-pane textarea:focus { outline:2px solid #8aa9d6; background:rgba(0,0,0,.38); }
.quick-pane > label { display:inline-flex; align-items:center; gap:7px; margin:10px 8px 10px 0; color:#c9c7c2; }
.quick-pane > pre, .quick-pane > p { margin:10px 0 0; padding:10px 12px; border-radius:9px; white-space:pre-wrap; background:rgba(0,0,0,.22); }
.quick-pane [role="alert"] { color:#ffb8bd; background:rgba(119,42,52,.42); }
+4 -1
View File
@@ -42,7 +42,10 @@ export async function loadPresentation() {
return invoke<UiState>("load_ui_state"); return invoke<UiState>("load_ui_state");
} }
export async function savePresentation(state: UiState) { export async function savePresentation(state: UiState) {
await invoke("save_ui_state", { state }); const current = await loadPresentation();
await invoke("save_ui_state", {
state: { ...state, quickPane: current.quickPane },
});
} }
export function legacyPreferences(storage: Pick<Storage, "getItem">) { export function legacyPreferences(storage: Pick<Storage, "getItem">) {
const path = storage.getItem(selectedPathKey) ?? undefined; const path = storage.getItem(selectedPathKey) ?? undefined;
+160 -5
View File
@@ -74,6 +74,126 @@ describe("workspace reducer", () => {
expect(state.runtimesById.b.messages[0].content).toBe("b"); expect(state.runtimesById.b.messages[0].content).toBe("b");
expect(state.runtimesById.b.unread).toBe(true); expect(state.runtimesById.b.unread).toBe(true);
}); });
it("uses the runtime summary as the authoritative snapshot working state", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 1,
runtime: { ...runtime("a"), state: "streaming" },
state: { data: { isStreaming: false } },
extensions: [],
},
});
expect(state.runtimesById.a.progress.phase).toBe("working");
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 2,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 2,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 2,
runtime: runtime("a"),
state: { data: { isStreaming: true } },
extensions: [],
},
});
expect(state.runtimesById.a.progress.phase).toBe("idle");
});
it("requests another snapshot when a live event overtakes the current load", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
state = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
type: "runtime_event",
data: {
runtimeId: "a",
eventType: "queue",
eventData: { event: { pendingMessageCount: 1 } },
},
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 0,
runtime: runtime("a"),
extensions: [],
},
});
expect(state.runtimesById.a.loading).toBe(false);
expect(state.runtimesById.a.snapshotRetry).toBe(true);
expect(state.runtimesById.a.summary.queueCount).toBe(1);
});
it("preserves known models and exposes model discovery failures", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 1,
runtime: runtime("a"),
models: {
data: { models: [{ provider: "openai", id: "gpt-5" }] },
},
extensions: [],
},
});
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 2,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 2,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 1,
runtime: runtime("a"),
models: {
data: { models: [] },
error: "RPC timed out",
},
extensions: [],
},
});
expect(state.runtimesById.a.models).toEqual([
{ provider: "openai", id: "gpt-5" },
]);
expect(state.runtimesById.a.modelError).toContain("RPC timed out");
});
it("removes the optimistic submission when Pi returns its structured user message", () => { it("removes the optimistic submission when Pi returns its structured user message", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace); let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, { state = workspaceReducer(state, {
@@ -259,8 +379,8 @@ describe("workspace reducer", () => {
], ],
}; };
expect( expect(
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed.summary
.summary.agentId, .agentId,
).toBeUndefined(); ).toBeUndefined();
}); });
it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => { it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => {
@@ -318,9 +438,7 @@ describe("workspace reducer", () => {
it("clears stale extension and transcript state for every runtime after an epoch change", () => { it("clears stale extension and transcript state for every runtime after an epoch change", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace); const state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById.b.messages = [{ role: "assistant", content: "old" }]; state.runtimesById.b.messages = [{ role: "assistant", content: "old" }];
state.runtimesById.b.extensions = [ state.runtimesById.b.extensions = [{ id: "dead-request", method: "confirm" }];
{ id: "dead-request", method: "confirm" },
];
state.runtimesById.b.unread = true; state.runtimesById.b.unread = true;
const reset = workspaceReducer(state, { const reset = workspaceReducer(state, {
type: "hostEvent", type: "hostEvent",
@@ -340,6 +458,43 @@ describe("workspace reducer", () => {
expect(restored.runtimesById.b.extensions).toEqual([]); expect(restored.runtimesById.b.extensions).toEqual([]);
expect(restored.runtimesById.b.unread).toBe(false); expect(restored.runtimesById.b.unread).toBe(false);
}); });
it("clears active history and input when a runtime is renewed", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById.a.messages = [
{ role: "user", content: "old conversation" },
];
state.runtimesById.a.pendingSubmissions = [
{
id: 1,
text: "old pending input",
userMessageCount: 1,
phase: "sent",
},
];
state.runtimesById.a.extensions = [
{ id: "old-extension", method: "confirm" },
];
state.runtimesById.a.models = [{ provider: "openai", id: "old-model" }];
state.runtimesById.a.draft = "old draft";
state.runtimesById.a.scroll = { atBottom: false, scrollTop: 88 };
state.runtimesById.a.unread = true;
const renewed = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
type: "runtime_renewed",
data: { runtimeId: "a", runtime: runtime("a") },
});
expect(renewed.runtimesById.a.messages).toEqual([]);
expect(renewed.runtimesById.a.pendingSubmissions).toEqual([]);
expect(renewed.runtimesById.a.extensions).toEqual([]);
expect(renewed.runtimesById.a.models).toEqual([]);
expect(renewed.runtimesById.a.draft).toBe("");
expect(renewed.runtimesById.a.scroll).toEqual({ atBottom: true });
expect(renewed.runtimesById.a.unread).toBe(false);
expect(renewed.bridge.status).toBe("resyncing");
});
it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => { it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace); const state = reconcileWorkspace(initialWorkspaceState, workspace);
const next = applyWorkspaceEvent(state, { const next = applyWorkspaceEvent(state, {
+68 -19
View File
@@ -16,6 +16,28 @@ const idleProgress = {
toolCount: 0, toolCount: 0,
}; };
function snapshotProgress(runtime: RuntimeSummary) {
if (runtime.state === "streaming")
return {
phase: "working" as const,
detail: "Thinking and preparing a response",
toolCount: 0,
};
if (runtime.state === "recovering")
return {
phase: "recovering" as const,
detail: "Restoring this Pi session",
toolCount: 0,
};
if (runtime.state === "failed")
return {
phase: "error" as const,
detail: runtime.error?.message ?? "This Pi session failed",
toolCount: 0,
};
return idleProgress;
}
function messageText(message: RuntimeView["messages"][number]) { function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content; if (typeof message.content === "string") return message.content;
return (message.content ?? []) return (message.content ?? [])
@@ -57,6 +79,7 @@ export function runtimeView(
messages: previous?.messages ?? [], messages: previous?.messages ?? [],
commands: previous?.commands ?? [], commands: previous?.commands ?? [],
models: previous?.models ?? [], models: previous?.models ?? [],
modelError: previous?.modelError,
extensions: previous?.extensions ?? [], extensions: previous?.extensions ?? [],
extensionStatuses: previous?.extensionStatuses ?? {}, extensionStatuses: previous?.extensionStatuses ?? {},
extensionWidgets: previous?.extensionWidgets ?? {}, extensionWidgets: previous?.extensionWidgets ?? {},
@@ -67,6 +90,7 @@ export function runtimeView(
scroll: previous?.scroll ?? { atBottom: true }, scroll: previous?.scroll ?? { atBottom: true },
unread: previous?.unread ?? false, unread: previous?.unread ?? false,
loading: previous?.loading ?? false, loading: previous?.loading ?? false,
snapshotRetry: previous?.snapshotRetry ?? false,
error: previous?.error, error: previous?.error,
requestGeneration: previous?.requestGeneration ?? 0, requestGeneration: previous?.requestGeneration ?? 0,
loadedThroughSeq: previous?.loadedThroughSeq ?? 0, loadedThroughSeq: previous?.loadedThroughSeq ?? 0,
@@ -84,6 +108,7 @@ function resetRuntimeView(
messages: [], messages: [],
commands: [], commands: [],
models: [], models: [],
modelError: undefined,
extensions: [], extensions: [],
extensionStatuses: {}, extensionStatuses: {},
extensionWidgets: {}, extensionWidgets: {},
@@ -92,6 +117,7 @@ function resetRuntimeView(
unread: false, unread: false,
progress: idleProgress, progress: idleProgress,
loading: false, loading: false,
snapshotRetry: false,
error: undefined, error: undefined,
requestGeneration: 0, requestGeneration: 0,
loadedThroughSeq: 0, loadedThroughSeq: 0,
@@ -147,9 +173,7 @@ export function reconcileWorkspace(
ids, ids,
); );
} }
const paths = workspace.directories.map( const paths = workspace.directories.map((directory) => directory.worktreePath);
(directory) => directory.worktreePath,
);
const directoryOrder = ordered( const directoryOrder = ordered(
persisted?.directoryOrder ?? state.directoryOrder, persisted?.directoryOrder ?? state.directoryOrder,
paths, paths,
@@ -158,9 +182,7 @@ export function reconcileWorkspace(
const selectedRuntimeId = const selectedRuntimeId =
preferred && runtimesById[preferred] preferred && runtimesById[preferred]
? preferred ? preferred
: directoryOrder.flatMap( : directoryOrder.flatMap((path) => runtimeOrderByDirectory[path] ?? [])[0];
(path) => runtimeOrderByDirectory[path] ?? [],
)[0];
const selectedDirectoryPath = selectedRuntimeId const selectedDirectoryPath = selectedRuntimeId
? runtimesById[selectedRuntimeId]?.summary.worktreePath ? runtimesById[selectedRuntimeId]?.summary.worktreePath
: directoryOrder[0]; : directoryOrder[0];
@@ -261,6 +283,26 @@ export function applyWorkspaceEvent(
}, },
}; };
const id = event.data?.runtimeId; const id = event.data?.runtimeId;
if (
event.type === "runtime_renewed" &&
id &&
state.runtimesById[id] &&
event.data?.runtime
) {
const renewed = resetRuntimeView(event.data.runtime, state.runtimesById[id]);
return {
...state,
bridge: { ...state.bridge, latestSeq: event.seq, status: "resyncing" },
runtimesById: {
...state.runtimesById,
[id]: {
...renewed,
draft: "",
scroll: { atBottom: true },
},
},
};
}
if ( if (
["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes( ["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes(
event.type ?? "", event.type ?? "",
@@ -456,8 +498,7 @@ export function workspaceReducer(
return { return {
...state, ...state,
selectedDirectoryPath: action.worktreePath, selectedDirectoryPath: action.worktreePath,
selectedRuntimeId: selectedRuntimeId: state.runtimeOrderByDirectory[action.worktreePath]?.[0],
state.runtimeOrderByDirectory[action.worktreePath]?.[0],
}; };
if (action.type === "selectRuntime") if (action.type === "selectRuntime")
return { return {
@@ -483,20 +524,29 @@ export function workspaceReducer(
const view = { ...state.runtimesById[runtimeId] }; const view = { ...state.runtimesById[runtimeId] };
if (action.type === "snapshotRequested") { if (action.type === "snapshotRequested") {
view.loading = true; view.loading = true;
view.snapshotRetry = false;
view.error = undefined; view.error = undefined;
view.requestGeneration = action.generation; view.requestGeneration = action.generation;
} else if (action.type === "snapshotFailed") { } else if (action.type === "snapshotFailed") {
if (view.requestGeneration !== action.generation) return state; if (view.requestGeneration !== action.generation) return state;
view.loading = false; view.loading = false;
view.snapshotRetry = false;
view.error = action.error; view.error = action.error;
} else if (action.type === "snapshotLoaded") { } else if (action.type === "snapshotLoaded") {
if (view.requestGeneration !== action.generation) return state;
if ( if (
view.requestGeneration !== action.generation ||
action.snapshot.bridgeInstanceId !== state.bridge.instanceId || action.snapshot.bridgeInstanceId !== state.bridge.instanceId ||
action.snapshot.latestSeq < view.loadedThroughSeq action.snapshot.latestSeq < view.loadedThroughSeq
) ) {
return state; view.loading = false;
view.snapshotRetry = true;
return {
...state,
runtimesById: { ...state.runtimesById, [runtimeId]: view },
};
}
view.loading = false; view.loading = false;
view.snapshotRetry = false;
view.summary = action.snapshot.runtime; view.summary = action.snapshot.runtime;
view.state = action.snapshot.state?.data ?? {}; view.state = action.snapshot.state?.data ?? {};
view.stats = action.snapshot.stats?.data ?? {}; view.stats = action.snapshot.stats?.data ?? {};
@@ -506,16 +556,15 @@ export function workspaceReducer(
view.messages, view.messages,
); );
view.commands = action.snapshot.commands?.data?.commands ?? []; view.commands = action.snapshot.commands?.data?.commands ?? [];
view.models = action.snapshot.models?.data?.models ?? []; if (action.snapshot.models?.error) {
view.modelError = `Could not load available Pi models: ${action.snapshot.models.error}`;
} else {
view.models = action.snapshot.models?.data?.models ?? [];
view.modelError = undefined;
}
view.extensions = action.snapshot.extensions ?? []; view.extensions = action.snapshot.extensions ?? [];
view.loadedThroughSeq = action.snapshot.latestSeq; view.loadedThroughSeq = action.snapshot.latestSeq;
view.progress = view.state.isStreaming view.progress = snapshotProgress(action.snapshot.runtime);
? {
phase: "working",
detail: "Thinking and preparing a response",
toolCount: 0,
}
: idleProgress;
} else if (action.type === "draftChanged") view.draft = action.draft; } else if (action.type === "draftChanged") view.draft = action.draft;
else if (action.type === "scrollChanged") else if (action.type === "scrollChanged")
view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop }; view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop };
+15 -1
View File
@@ -129,7 +129,7 @@ export type RuntimeSnapshot = {
stats?: { data?: SessionStats }; stats?: { data?: SessionStats };
transcript?: { data?: { messages?: Message[] } }; transcript?: { data?: { messages?: Message[] } };
commands?: { data?: { commands?: Command[] } }; commands?: { data?: { commands?: Command[] } };
models?: { data?: { models?: Model[] } }; models?: { data?: { models?: Model[] }; error?: string };
extensions: Extension[]; extensions: Extension[];
}; };
export type DirectorySession = { export type DirectorySession = {
@@ -180,6 +180,7 @@ export type RuntimeView = {
messages: Message[]; messages: Message[];
commands: Command[]; commands: Command[];
models: Model[]; models: Model[];
modelError?: string;
extensions: Extension[]; extensions: Extension[];
extensionStatuses: Record<string, string>; extensionStatuses: Record<string, string>;
extensionWidgets: Record< extensionWidgets: Record<
@@ -193,6 +194,7 @@ export type RuntimeView = {
scroll: { atBottom: boolean; scrollTop?: number }; scroll: { atBottom: boolean; scrollTop?: number };
unread: boolean; unread: boolean;
loading: boolean; loading: boolean;
snapshotRetry: boolean;
error?: string; error?: string;
requestGeneration: number; requestGeneration: number;
loadedThroughSeq: number; loadedThroughSeq: number;
@@ -219,6 +221,17 @@ export type WorkspaceState = {
interfaceScale: number; interfaceScale: number;
}; };
export type QuickPaneSettings = {
workspace: string;
provider: string;
model: string;
followUpProvider: string;
followUpModel: string;
instructions: string;
maximumSeconds: number;
maximumToolCalls: number;
};
export type UiState = { export type UiState = {
version: 1; version: 1;
directoryOrder: string[]; directoryOrder: string[];
@@ -234,4 +247,5 @@ export type UiState = {
interfaceScale?: number; interfaceScale?: number;
defaultNewSessionWorktree?: string; defaultNewSessionWorktree?: string;
dismissedCollisionWarning?: boolean; dismissedCollisionWarning?: boolean;
quickPane?: QuickPaneSettings;
}; };
+41 -22
View File
@@ -29,28 +29,46 @@ export function useWorkspace() {
const disposed = useRef(true); const disposed = useRef(true);
const refreshGeneration = useRef(0); const refreshGeneration = useRef(0);
const loadSnapshot = useCallback(async (runtimeId: string) => { const loadSnapshot = useCallback(
if (disposed.current) return; async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1; if (disposed.current) return false;
loadGeneration.current[runtimeId] = generation; const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
dispatch({ type: "snapshotRequested", runtimeId, generation }); loadGeneration.current[runtimeId] = generation;
try { dispatch({ type: "snapshotRequested", runtimeId, generation });
const snapshot = await invoke<RuntimeSnapshot>( try {
"get_session_runtime_snapshot", const snapshot = await invoke<RuntimeSnapshot>(
{ runtimeId }, "get_session_runtime_snapshot",
); { runtimeId },
if (!disposed.current) );
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot }); if (!disposed.current)
} catch (error) { dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
if (!disposed.current) return true;
dispatch({ } catch (error) {
type: "snapshotFailed", if (!disposed.current)
runtimeId, dispatch({
generation, type: "snapshotFailed",
error: String(error), runtimeId,
}); generation,
} error: String(error),
}, []); });
if (options.throwOnError) throw error;
return false;
}
},
[],
);
const snapshotRetryKey = Object.values(state.runtimesById)
.flatMap((runtime) =>
runtime.snapshotRetry ? [runtime.summary.runtimeId] : [],
)
.sort((left, right) => left.localeCompare(right))
.join("\n");
useEffect(() => {
if (!snapshotRetryKey) return;
for (const runtimeId of snapshotRetryKey.split("\n"))
void loadSnapshot(runtimeId);
}, [snapshotRetryKey, loadSnapshot]);
const scheduleSnapshot = useCallback( const scheduleSnapshot = useCallback(
(runtimeId: string) => { (runtimeId: string) => {
@@ -177,6 +195,7 @@ export function useWorkspace() {
"runtime_opened", "runtime_opened",
"runtime_closed", "runtime_closed",
"runtime_recovered", "runtime_recovered",
"runtime_renewed",
"runtime_restore_failed", "runtime_restore_failed",
].includes(eventType ?? "") ].includes(eventType ?? "")
) { ) {