Compare commits
6 Commits
9f2c840ae7
...
bab754714e
| Author | SHA1 | Date | |
|---|---|---|---|
| bab754714e | |||
| 67063464e7 | |||
| dd23898769 | |||
| 612b146fd6 | |||
| 4f57114f19 | |||
| 166b065ad4 |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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()];
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ?? "")
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user