39 Commits

Author SHA1 Message Date
alex 636ef5dcc2 fix(quick-pane): recover catalog after bridge restarts
Treat missing stale runtimes as already closed and defer thinking reconciliation until the refreshed model catalog is ready.
2026-09-02 10:07:04 +02:00
alex 56fe4e734d fix(bridge): restore labels from session history
Hydrate missing runtime names from persisted session metadata so opened and restored conversations no longer remain titled New session.
2026-09-02 10:06:55 +02:00
alex 35719e0961 feat(ui): streamline multi-session workflows
Keep working status at the transcript tail, replace the tab strip with accessible overflow navigation, focus the prompt on activation and new sessions, and add session keyboard shortcuts.
2026-09-02 10:06:26 +02:00
alex 6385c20f91 refactor!: integrate quick pane simplification
Merge the validated feature branch after the Ctrl+N session shortcut landed on main.

BREAKING CHANGE: The Noctalia relay no longer accepts --agent, and the Tauri host no longer exposes the legacy load_agent command.
2026-08-31 19:31:23 +02:00
alex e181b2ab12 feat(session): add Ctrl+N new-session shortcut 2026-08-31 17:41:33 +02:00
alex 0949c6f0d7 refactor!: remove redundant compatibility layers
Use native browser and Tauri behavior instead of custom selector, dialog, and window-state implementations. Remove obsolete bridge, relay, asset, and dependency paths to keep the supported surface small.

BREAKING CHANGE: The Noctalia relay no longer accepts --agent, and the Tauri host no longer exposes the legacy load_agent command.
2026-08-31 17:35:07 +02:00
alex a4ee3eb7a8 fix(workspace): hide ephemeral quick runtimes 2026-08-31 14:39:17 +02:00
alex 087c177e8e feat(thinking-selector): add keyboard popup menu 2026-08-28 15:27:46 +02:00
alex d448152e7f style(quick-pane): use standard titlebar drag 2026-08-28 14:43:43 +02:00
alex 9345b67c9f style(quick-pane): soften drag grip 2026-08-28 14:36:04 +02:00
alex 35d5698f1a style(quick-pane): widen drag grip 2026-08-28 14:32:17 +02:00
alex ce8e2e1332 style(quick-pane): restore compact drag grip 2026-08-28 14:27:58 +02:00
alex a180ac9a2c style(quick-pane): use top padding as drag target 2026-08-28 14:23:28 +02:00
alex 5642d9be91 feat(quick-pane): persist default thinking level 2026-08-28 14:14:46 +02:00
alex 1ce03ef986 feat(quick-pane): promote chats into full sessions 2026-08-28 13:58:03 +02:00
alex 0eb163b6c4 feat(quick-pane): promote chats into full sessions 2026-08-28 13:43:49 +02:00
alex b44b1be4b7 feat(quick-pane): promote active runtime 2026-08-28 12:07:50 +02:00
alex 0a318f5ab6 feat(model-selector): support popup menu lifecycle 2026-08-27 23:38:14 +02:00
alex 5e9074605d feat(quick-pane): add compact runtime controls 2026-08-27 23:37:47 +02:00
alex e869a0bfcb style(quick-pane): emphasize drag strip 2026-08-27 21:53:12 +02:00
alex aa37a2f60e fix(quick-pane): hide empty exchange frame 2026-08-27 21:34:25 +02:00
alex 0da45b468e style(quick-pane): outline chat exchange 2026-08-27 20:52:47 +02:00
alex 880cddf3cf feat(quick-pane): polish compact chat controls 2026-08-27 17:18:16 +02:00
alex 60fb91f375 feat(quick-pane): add follow-up chat renewal 2026-08-27 16:26:17 +02:00
alex 05874a0be1 fix(quick-pane): load fresh categorized models 2026-08-26 18:27:05 +02:00
alex ad66067a31 fix(quick-pane): refresh runtime model catalog 2026-08-26 17:59:31 +02:00
alex 4ec951bc05 fix(quick-pane): refresh models and window lifecycle 2026-08-26 15:58:01 +02:00
alex d34099771c feat(quick-pane): add chat-style exchange 2026-08-26 15:26:03 +02:00
alex 0ec7d224a2 fix(quick-pane): serialize native visibility 2026-08-25 23:17:50 +02:00
alex 4fc9c60ade fix(quick-pane): preserve lifecycle and answer history 2026-08-25 23:08:45 +02:00
alex 1b0b34fad8 fix(quick-pane): restore model catalog controls 2026-08-25 22:34:58 +02:00
alex ab482d3daa fix(quick-pane): keep pane open on blur 2026-08-25 22:02:41 +02:00
alex a204eea7da fix(quick-pane): polish spotlight lifecycle 2026-08-25 21:53:00 +02:00
alex bab754714e test(quick-pane): cover settings load 2026-08-25 19:27:00 +02:00
alex 67063464e7 feat(quick-pane): refine spotlight workflow 2026-08-25 19:23:59 +02:00
alex dd23898769 feat(quick-pane): merge constrained agent window 2026-08-25 18:34:29 +02:00
alex 612b146fd6 feat(status-ui): add compact session status strip 2026-08-25 18:32:33 +02:00
alex 4f57114f19 feat(quick-pane): add constrained agent window 2026-08-25 18:30:39 +02:00
alex 166b065ad4 feat(status-ui): improve concurrent session workflows
Add in-place session renewal, searchable model selection, clearer progress feedback, tool-result previews, and per-session presentation state.

Keep commands, errors, loading, and lifecycle indicators attached to their owning runtime so parallel tabs cannot interfere.
2026-08-25 14:56:42 +02:00
64 changed files with 13278 additions and 1390 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ The request payload is the local protocol JSON. See [`DESIGN.md`](DESIGN.md) for
node src/client/noctalia-relay-cli.js --socket "$PI_STATUS_BRIDGE_SOCKET"
```
The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests. `--agent <agent-id>` retains the former agent-scoped relay only for external compatibility callers.
The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests.
## Keep the bridge running in the background (recommended)
+72
View File
@@ -0,0 +1,72 @@
# Quick Pane v1 Design
## Goal
Quick Pane is a small native window opened by a desktop/window-manager hotkey. It keeps a lightweight chat for short requests and returns useful answers and offers an explicit manual full-session continuation when needed.
## 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, and read-only tool-call guidance.
- 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 Quick Pane window. `Esc` and **Close** hide the window without clearing an idle runtime, draft, model catalog, or transcript. Reopening within three minutes preserves that chat. Focusing an idle chat after more than three minutes closes it and prepares a fresh runtime; active work is never renewed on this timer.
## 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 short requests, read-only inspection, zero subagents, and configured tool-call budget. Current Pi RPC cannot trusted-enforce those instructions.
4. Supplemental user instructions append beneath those prompt guardrails. Pane maps each exact submitted raw prompt back to entered user text, so transcript rendering never parses delimiter-like text from instructions or requests. Unknown reflected messages with the Quick Pane advisory prefix are hidden.
5. Pane streams the full ordered user/assistant transcript, tool status, and inline extension requests. Follow-ups reuse the same runtime until explicit or stale renewal. Starting each follow-up clears only prior turn's handoff answer state; transcript messages remain visible.
6. After first completed assistant answer, it displays a prominent manual **Continue in full session** action. Escalation and runtime-error notices remain visible and also offer handoff. No answer triggers a full session automatically.
## Chat reset and layout
`Ctrl+N` starts a new chat: it aborts active work when needed, closes the old runtime with retryable cleanup, clears draft/transcript and submitted-prompt mappings, then prepares a fresh runtime and model catalog. Automatic stale renewal performs the same idle-chat replacement while preserving the current draft.
Quick Pane opens at 640×360 with a 520×360 minimum. Frontend content measurement requests native growth up to 520px tall after transcript updates; beyond that cap, transcript and model results scroll inside pane while outer document and pane remain clipped.
## Escalation
Mutations, subagents, multi-step work, and complex work preflight-escalate. The quick agent explains why. The user clicks **Continue in full session** to open a new normal session with:
- original request;
- transcript-derived current-chat context and 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. Hiding the pane preserves an idle chat for up to three minutes. `Ctrl+N`, stale idle renewal, app teardown, or another explicit chat replacement clears in-memory chat state and requests 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
Settings-startup and model-catalog preparation errors render an inline **Retry** action. Retry completes failed-runtime cleanup before preparing a replacement; model selection and Send remain disabled until a valid runtime catalog is ready. Request/runtime failures offer **Continue** only when a valid request and handoff configuration exist. An empty catalog remains visible with model selection and Send disabled. No silent fallback model is used.
## Settings validation
Settings reject invalid model, workspace, custom prompt, or tool-call limit. Failed save keeps previously stored configuration and marks invalid fields.
## Acceptance
From cold or hot desktop state, Quick Pane starts an unlimited chat without a time-based abort. Automated component, bridge, persistence, and native tests cover behavior; a manual cold-launch/hotkey smoke test verifies desktop integration.
+47
View File
@@ -0,0 +1,47 @@
# 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/component logic for request, selected model/workspace, progress, full ordered transcript, current-turn partial answer, escalation, extension UI, and errors. Clear prior answer/handoff state before every follow-up while retaining transcript messages.
- Keep pane transcript and exact raw-prompt-to-visible-request mappings in component memory only. Hide unknown reflected Quick Pane advisory prompts rather than delimiter-parsing them.
- Make `Esc` and **Close** native hide actions. Preserve idle runtime, draft, catalog, and transcript when reopened within three minutes; renew stale idle chats on focus.
- Add `Ctrl+N` reset semantics: abort active work, close with cleanup retry, clear draft/transcript/prompt mappings, and prepare a fresh catalog/runtime.
- Open native window at 640×360 with a 520×360 minimum; use ResizeObserver/RAF content measurement to request native growth only up to 520px tall, then use internal scrolling.
- Add visible Retry recovery for settings-startup and model-catalog preparation failures. Finish failed-runtime cleanup before replacement preparation, and keep model/Send disabled until ready.
- 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 advisory tool-call guidance only; 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 complex requests.
## 5. Route interaction and handoff
- Reuse existing extension request/response protocol for inline Quick Pane permission and clarification dialogs.
- After first completed assistant answer, show manual continuation with valid handoff configuration; never open a session automatically.
- Prefill normal session with transcript-derived current-chat context, workspace, and follow-up model.
## 6. Test and document
- Add unit tests for settings validation/persistence and guardrail classification.
- Add component tests for pane states, full transcript and exact prompt reflection, picker behavior, inline prompts, hide/renew/reset behavior, runtime and extension errors, stale-created cleanup retry, and handoff.
- Add Rust/bridge tests for window command routing, bounded content resizing, 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.
+49
View File
@@ -0,0 +1,49 @@
# 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, and tool-call 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 complex requests are instructed to preflight-escalate.
- Quick Pane has no time-based abort; configured tool-call guidance remains prompt-only.
- Full ordered user/assistant transcript remains visible for follow-ups. Each follow-up clears prior answer/partial-handoff state before model, thinking, or prompt submission.
- Reflected user messages are resolved by exact submitted raw-prompt mapping. Supplemental instructions and entered requests containing `\n\nUser request:` render only the exact entered multiline request; unknown Quick Pane advisory prompts are hidden and ordinary user text remains visible.
- 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 remain visible inline and send correct response payloads; runtime errors do not erase partial transcript or pending extensions.
- `Esc` and **Close** hide without cleanup. Reopening an idle chat at or under three minutes preserves runtime, draft/catalog, and transcript; reopening after more than three minutes renews it.
- `Ctrl+N` prevents browser default, aborts active work when needed, retries queued cleanup, clears draft/transcript/raw-prompt mappings, and prepares a fresh runtime/catalog. Idle reset does not send an abort.
- Stale runtimes created by superseded preparation are queued after cleanup failure and retried before later stale-chat renewal.
- Quick Pane opens at 640×360 with a 520×360 native minimum. Content-driven native growth stops at 520px tall; transcript and model results scroll internally after that.
- Settings-startup and model-catalog errors show **Retry**. Catalog retry cleans up failed runtime before replacement preparation; model selection and Send stay disabled until ready.
- **Continue in full session** appears after first completed assistant answer with valid handoff settings, plus preserved request/runtime escalation notices. It never starts automatically. Handoff creates normal session with transcript-derived current-chat context, workspace, and follow-up model. Current answer extraction starts only after exact mapped submitted prompt in snapshot transcript; failed follow-ups with no current assistant message never reuse prior answer as partial findings.
## Manual smoke checklist
1. Configure default workspace, models, prompt, and tool-call guidance.
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 a short answer-only request; verify no time-based abort and visible manual **Continue in full session** after completion.
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 and reopen within three minutes; verify idle runtime, draft/catalog, and full transcript remain. Repeat after more than three idle minutes; verify fresh chat/runtime appears.
9. Press `Ctrl+N` while idle and active; verify fresh empty chat in both cases and active work aborts before replacement.
10. Verify pane opens at 640×360, grows only to 520px tall as transcript grows, then keeps transcript/model results scrolling internally.
11. Induce settings-startup or model-catalog failure; verify visible **Retry**, disabled model/Send controls until recovery, and successful retry. Verify empty catalog stays blocked without unsupported actions.
## Exit criteria
All targeted automated tests pass. Manual cold-launch/hotkey smoke completes. No regressions in normal sessions, `--toggle`, or `--new` behavior.
+424 -110
View File
@@ -5,8 +5,11 @@ import {
readdir,
readFile,
realpath,
rename,
rm,
stat,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
@@ -51,8 +54,7 @@ async function readSessionReference(sessionDir) {
);
return {
sessionPath:
typeof value?.sessionPath === "string" &&
path.isAbsolute(value.sessionPath)
typeof value?.sessionPath === "string" && path.isAbsolute(value.sessionPath)
? value.sessionPath
: undefined,
worktreePath:
@@ -75,6 +77,69 @@ async function persistSessionReference(sessionDir, sessionPath, worktreePath) {
);
}
export async function promoteQuickSessionDirectory(
quickDir,
sessionDir,
{ renameFile = rename, makeSymlink = symlink } = {},
) {
const stagingDir = `${quickDir}.promoting-${randomUUID()}`;
const moved = [];
let quickStaged = false;
let quickLinked = false;
async function rollback() {
if (quickLinked) await unlink(quickDir).catch(() => {});
for (const entry of moved.reverse())
await renameFile(path.join(sessionDir, entry), path.join(stagingDir, entry));
if (quickStaged) await renameFile(stagingDir, quickDir);
}
try {
await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE });
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
await renameFile(quickDir, stagingDir);
quickStaged = true;
await makeSymlink(sessionDir, quickDir, "dir");
quickLinked = true;
for (const entry of await readdir(stagingDir)) {
const destination = path.join(sessionDir, entry);
try {
await stat(destination);
throw new Error("quick session conflicts with an existing session file");
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
await renameFile(path.join(stagingDir, entry), destination);
moved.push(entry);
}
} catch (error) {
try {
await rollback();
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
"could not promote quick session directory; staging retained",
);
}
throw error;
}
return {
async rollback() {
try {
await rollback();
} catch (error) {
throw new Error(
`could not roll back quick session promotion; staging retained: ${error.message}`,
);
}
},
async finalize() {
await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
},
};
}
function sessionPreview(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
@@ -110,11 +175,7 @@ async function readSessionSummary(sessionPath, currentPath) {
firstMessage = sessionPreview(entry.message.content);
}
}
if (
!header ||
typeof header.id !== "string" ||
typeof header.cwd !== "string"
)
if (!header || typeof header.id !== "string" || typeof header.cwd !== "string")
return undefined;
return {
path: sessionPath,
@@ -250,6 +311,7 @@ export function createAgentRegistry({
homeWorktree,
sessionRoot,
startAdapter = startPiRpcAdapter,
workspaceStoreFactory = createWorkspaceStore,
eventLimit = DEFAULT_EVENT_LIMIT,
maxRecoveryAttempts,
recoveryDelayForAttempt,
@@ -262,9 +324,11 @@ export function createAgentRegistry({
throw new TypeError("sessionRoot must be an absolute path");
if (typeof startAdapter !== "function")
throw new TypeError("startAdapter must be a function");
if (typeof workspaceStoreFactory !== "function")
throw new TypeError("workspaceStoreFactory must be a function");
const bridgeInstanceId = randomUUID();
const workspaceStore = createWorkspaceStore(sessionRoot);
const workspaceStore = workspaceStoreFactory(sessionRoot);
const runtimesByRuntimeId = new Map();
const runtimesByAgentId = new Map();
const runtimesByWorktreePath = new Map();
@@ -277,10 +341,13 @@ export function createAgentRegistry({
const workspaceEvents = [];
let workspaceSequence = 0;
let manifestIssue;
let manifestWriteQueue = Promise.resolve();
let canonicalHomeWorktree;
let lastSuccessfulModelCatalog;
let stopping = false;
function publishWorkspace(type, runtime, data = {}) {
if (runtime?.ephemeral) return undefined;
const event = {
version: "v1",
bridgeInstanceId,
@@ -309,24 +376,32 @@ export function createAgentRegistry({
version: 2,
migrated: true,
directories: [...managedWorktrees].sort(),
runtimes: [...runtimesByRuntimeId.values()].map((runtime) => ({
runtimeId: runtime.runtimeId,
worktreePath: runtime.worktreePath,
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
...(runtime.legacyDefault ? { legacyDefault: true } : {}),
openedAt: runtime.openedAt,
})),
runtimes: [...runtimesByRuntimeId.values()]
.filter((runtime) => !runtime.ephemeral)
.map((runtime) => ({
runtimeId: runtime.runtimeId,
worktreePath: runtime.worktreePath,
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
...(runtime.legacyDefault ? { legacyDefault: true } : {}),
openedAt: runtime.openedAt,
})),
};
}
async function persistWorkspace() {
await workspaceStore.save(manifest());
manifestIssue = undefined;
function persistWorkspace() {
const operation = manifestWriteQueue
.catch(() => {})
.then(async () => {
await workspaceStore.save(manifest());
manifestIssue = undefined;
});
manifestWriteQueue = operation.catch(() => {});
return operation;
}
function addWorktreeRuntime(runtime) {
managedWorktrees.add(runtime.worktreePath);
if (!runtime.ephemeral) managedWorktrees.add(runtime.worktreePath);
let runtimes = runtimesByWorktreePath.get(runtime.worktreePath);
if (!runtimes) {
runtimes = new Set();
@@ -380,8 +455,7 @@ export function createAgentRegistry({
}
function enqueue(runtime, operation) {
if (stopping)
return Promise.reject(new Error("agent registry is stopping"));
if (stopping) return Promise.reject(new Error("agent registry is stopping"));
if (
runtime.state === "closing" ||
runtime.state === "stopped" ||
@@ -420,10 +494,7 @@ export function createAgentRegistry({
} catch (error) {
if (error?.code !== "ENOENT" || !allowMissing) throw error;
const resolvedParent = await realpath(path.dirname(sessionPath));
resolvedSessionPath = path.join(
resolvedParent,
path.basename(sessionPath),
);
resolvedSessionPath = path.join(resolvedParent, path.basename(sessionPath));
}
const relative = path.relative(resolvedSessionDir, resolvedSessionPath);
if (
@@ -436,10 +507,23 @@ export function createAgentRegistry({
return resolvedSessionPath;
}
async function projectQuickSessionPath(quickDir, sessionDir, sessionPath) {
const activePath = await ownedSessionPath(quickDir, sessionPath);
const relativePath = path.relative(await realpath(quickDir), activePath);
return ownedSessionPath(sessionDir, path.join(sessionDir, relativePath), {
allowMissing: true,
});
}
async function updateRuntimeIdentity(
runtime,
stateResponse,
{ expectedSessionPath, adapter = runtime.adapter } = {},
{
expectedSessionPath,
adapter = runtime.adapter,
persist = true,
indexSession = true,
} = {},
) {
if (
runtime.stopped ||
@@ -461,9 +545,7 @@ export function createAgentRegistry({
resolved = candidate;
} catch (error) {
if (error?.code !== "ENOENT")
throw new Error(
"Pi reported a session outside its managed directory",
);
throw new Error("Pi reported a session outside its managed directory");
}
}
if (expectedSessionPath && resolved !== expectedSessionPath)
@@ -483,18 +565,26 @@ export function createAgentRegistry({
throw new Error("session runtime is closing");
if (typeof state.sessionName === "string")
runtime.sessionName = state.sessionName;
if (typeof state.sessionId === "string")
runtime.sessionId = state.sessionId;
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
if (resolved && !runtime.sessionName && !runtime.firstMessage) {
const summary = await readSessionSummary(resolved, resolved).catch(
() => undefined,
);
if (summary?.name) runtime.sessionName = summary.name;
if (summary?.firstMessage) runtime.firstMessage = summary.firstMessage;
}
if (resolved) {
if (
runtime.sessionPath &&
runtime.sessionPath !== resolved &&
runtimesBySessionPath.get(runtime.sessionPath) === runtime
)
runtimesBySessionPath.delete(runtime.sessionPath);
if (indexSession) {
if (
runtime.sessionPath &&
runtime.sessionPath !== resolved &&
runtimesBySessionPath.get(runtime.sessionPath) === runtime
)
runtimesBySessionPath.delete(runtime.sessionPath);
runtimesBySessionPath.set(resolved, runtime);
releaseSessionReservation(resolved, runtime);
}
runtime.sessionPath = resolved;
runtimesBySessionPath.set(resolved, runtime);
releaseSessionReservation(resolved, runtime);
if (runtime.legacyDefault)
await persistSessionReference(
runtime.sessionDir,
@@ -502,7 +592,16 @@ export function createAgentRegistry({
runtime.worktreePath,
);
}
await persistWorkspace();
if (persist) await persistWorkspace();
}
async function restoreSessionReference(sessionDir, previous) {
const referencePath = path.join(sessionDir, SESSION_REFERENCE_FILE);
if (previous === undefined) {
await rm(referencePath, { force: true });
return;
}
await writeFile(referencePath, previous, { encoding: "utf8", mode: 0o600 });
}
function publishAgent(runtime, type, data) {
@@ -532,12 +631,8 @@ export function createAgentRegistry({
}
}
if (type === "queue") {
const followUp = Array.isArray(event.followUp)
? event.followUp.length
: 0;
const steering = Array.isArray(event.steering)
? event.steering.length
: 0;
const followUp = Array.isArray(event.followUp) ? event.followUp.length : 0;
const steering = Array.isArray(event.steering) ? event.steering.length : 0;
runtime.queueCount =
typeof event.pendingMessageCount === "number"
? event.pendingMessageCount
@@ -553,8 +648,7 @@ export function createAgentRegistry({
const method = event.method;
if (["select", "confirm", "input", "editor"].includes(method)) {
runtime.attention = true;
if (typeof event.id === "string")
runtime.extensions.set(event.id, event);
if (typeof event.id === "string") runtime.extensions.set(event.id, event);
}
}
const message = event.message;
@@ -573,6 +667,7 @@ export function createAgentRegistry({
sessionPath,
sessionId,
legacyDefault = false,
ephemeral = false,
openedAt = new Date().toISOString(),
}) {
return {
@@ -583,6 +678,7 @@ export function createAgentRegistry({
sessionPath,
sessionId,
legacyDefault,
ephemeral,
openedAt,
state: "starting",
stateVersion: 0,
@@ -599,6 +695,8 @@ export function createAgentRegistry({
adapter: undefined,
supervisor: undefined,
stopped: false,
promoting: false,
promotionPromise: undefined,
recoveryPromise: undefined,
};
}
@@ -624,8 +722,7 @@ export function createAgentRegistry({
const state = await adapter.send({ type: "get_state" });
await updateRuntimeIdentity(runtime, state, { adapter });
}).catch((error) => {
if (runtime.stopped || runtime.state === "closing" || stopping)
return;
if (runtime.stopped || runtime.state === "closing" || stopping) return;
runtime.error = {
code: "identity_refresh",
message: error.message,
@@ -719,13 +816,16 @@ export function createAgentRegistry({
sessionPath,
sessionId,
legacyDefault = false,
ephemeral = false,
openedAt,
},
{ restore = false } = {},
) {
if (stopping) throw new Error("agent registry is stopping");
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 chmod(sessionDir, SESSION_DIRECTORY_MODE);
let ownedPath;
@@ -757,6 +857,7 @@ export function createAgentRegistry({
sessionPath: ownedPath,
sessionId,
legacyDefault,
ephemeral,
openedAt,
});
if (stopping) throw new Error("agent registry is stopping");
@@ -778,7 +879,7 @@ export function createAgentRegistry({
});
try {
await launch(runtime);
await persistWorkspace();
if (!runtime.ephemeral) await persistWorkspace();
publishWorkspace("runtime_opened", runtime, {
runtime: publicRuntime(runtime),
});
@@ -829,8 +930,7 @@ export function createAgentRegistry({
}
function openRuntime(record, options) {
if (stopping)
return Promise.reject(new Error("agent registry is stopping"));
if (stopping) return Promise.reject(new Error("agent registry is stopping"));
const operation = openRuntimeInternal(record, options);
pendingOpenOperations.add(operation);
void operation
@@ -867,14 +967,8 @@ export function createAgentRegistry({
let canonicalSession;
if (record.sessionPath) {
const canonicalWorktree = await realpath(record.worktreePath);
const sessionDir = sessionDirectoryFor(
sessionRoot,
canonicalWorktree,
);
canonicalSession = await ownedSessionPath(
sessionDir,
record.sessionPath,
);
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalWorktree);
canonicalSession = await ownedSessionPath(sessionDir, record.sessionPath);
}
prepared.push({ record, canonicalSession });
} catch (error) {
@@ -974,7 +1068,7 @@ export function createAgentRegistry({
// Remove desired-open/UI intent now, but retain the session lease until
// the old child can no longer write to its JSONL file.
unindexRuntime(runtime, { releaseSessionLease: false });
await persistWorkspace();
if (!runtime.ephemeral) await persistWorkspace();
runtime.stopped = true;
runtime.supervisor?.stop();
let stopConfirmed = true;
@@ -1002,6 +1096,10 @@ export function createAgentRegistry({
}
}
if (stopConfirmed) releaseClosedSessionLease(runtime);
if (runtime.ephemeral && stopConfirmed)
await rm(runtime.sessionDir, { recursive: true, force: true }).catch(
() => {},
);
runtime.state = "stopped";
publishWorkspace("runtime_closed", runtime, {
runtimeId: runtime.runtimeId,
@@ -1012,6 +1110,8 @@ export function createAgentRegistry({
function closeRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (runtime.promoting)
return runtime.promotionPromise.then(() => closeRuntime(runtimeId));
if (runtime.state === "closing" || runtime.state === "stopped")
return Promise.reject(new Error("session runtime is closing"));
const wasWorkingAtRequest =
@@ -1030,6 +1130,136 @@ export function createAgentRegistry({
return result;
}
function promoteQuickRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (!runtime.ephemeral) return Promise.resolve(publicRuntime(runtime));
if (runtime.state === "closing" || runtime.state === "stopped")
return Promise.reject(new Error("session runtime is closing"));
if (runtime.promotionPromise) return runtime.promotionPromise;
runtime.promoting = true;
const promotion = runtime.operationQueue.then(async () => {
if (!runtime.ephemeral) return publicRuntime(runtime);
if (runtime.state === "closing" || runtime.state === "stopped")
throw new Error("session runtime is closing");
const adapter = runtime.adapter;
if (!adapter) throw new Error("Pi is not currently running");
const quickDir = runtime.sessionDir;
const sessionDir = sessionDirectoryFor(sessionRoot, runtime.worktreePath);
const previous = {
sessionDir: runtime.sessionDir,
sessionPath: runtime.sessionPath,
sessionId: runtime.sessionId,
sessionName: runtime.sessionName,
sessionLease: runtime.sessionPath
? runtimesBySessionPath.get(runtime.sessionPath) === runtime
: false,
managed: managedWorktrees.has(runtime.worktreePath),
};
let directoryPromotion;
let previousReference;
let reservedSessionPath;
let manifestStateChanged = false;
try {
await mkdir(sessionDir, {
recursive: true,
mode: SESSION_DIRECTORY_MODE,
});
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
if (runtime.sessionPath) {
reservedSessionPath = await projectQuickSessionPath(
quickDir,
sessionDir,
runtime.sessionPath,
);
reserveSessionPath(reservedSessionPath, runtime);
}
directoryPromotion = await promoteQuickSessionDirectory(
quickDir,
sessionDir,
);
runtime.sessionDir = sessionDir;
if (reservedSessionPath) runtime.sessionPath = reservedSessionPath;
const state = await adapter.send({ type: "get_state" });
await updateRuntimeIdentity(runtime, state, {
adapter,
persist: false,
indexSession: false,
});
// A changed identity still needs a lease through manifest commit.
if (runtime.sessionPath && runtime.sessionPath !== reservedSessionPath)
reserveSessionPath(runtime.sessionPath, runtime);
if (
previous.sessionPath &&
previous.sessionPath !== runtime.sessionPath &&
runtimesBySessionPath.get(previous.sessionPath) === runtime
)
runtimesBySessionPath.delete(previous.sessionPath);
runtime.ephemeral = false;
managedWorktrees.add(runtime.worktreePath);
manifestStateChanged = true;
try {
previousReference = await readFile(
path.join(sessionDir, SESSION_REFERENCE_FILE),
"utf8",
);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
await persistSessionReference(
sessionDir,
runtime.sessionPath,
runtime.worktreePath,
);
await persistWorkspace();
if (runtime.sessionPath)
runtimesBySessionPath.set(runtime.sessionPath, runtime);
if (reservedSessionPath)
releaseSessionReservation(reservedSessionPath, runtime);
if (runtime.sessionPath !== reservedSessionPath)
releaseSessionReservation(runtime.sessionPath, runtime);
await directoryPromotion.finalize();
publishWorkspace("runtime_promoted", runtime, {
runtime: publicRuntime(runtime),
});
return publicRuntime(runtime);
} catch (error) {
if (directoryPromotion)
await restoreSessionReference(sessionDir, previousReference).catch(
() => {},
);
if (reservedSessionPath)
releaseSessionReservation(reservedSessionPath, runtime);
if (runtime.sessionPath !== reservedSessionPath)
releaseSessionReservation(runtime.sessionPath, runtime);
if (
runtime.sessionPath &&
runtimesBySessionPath.get(runtime.sessionPath) === runtime
)
runtimesBySessionPath.delete(runtime.sessionPath);
runtime.sessionDir = previous.sessionDir;
runtime.sessionPath = previous.sessionPath;
runtime.sessionId = previous.sessionId;
runtime.sessionName = previous.sessionName;
runtime.ephemeral = true;
if (previous.sessionLease && previous.sessionPath)
runtimesBySessionPath.set(previous.sessionPath, runtime);
if (previous.managed) managedWorktrees.add(runtime.worktreePath);
else managedWorktrees.delete(runtime.worktreePath);
if (manifestStateChanged) await persistWorkspace().catch(() => {});
if (directoryPromotion) await directoryPromotion.rollback();
throw error;
}
});
const tracked = promotion.finally(() => {
runtime.promoting = false;
runtime.promotionPromise = undefined;
});
runtime.operationQueue = tracked.catch(() => {});
runtime.promotionPromise = tracked;
return tracked;
}
async function refreshAfterLegacySessionChange(
runtime,
response,
@@ -1057,6 +1287,13 @@ export function createAgentRegistry({
runtime.sessionId = undefined;
runtime.sessionName = 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)
await persistSessionReference(
runtime.sessionDir,
@@ -1066,6 +1303,12 @@ export function createAgentRegistry({
await persistWorkspace();
const state = await runtime.adapter.send({ type: "get_state" });
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;
}
@@ -1087,6 +1330,7 @@ export function createAgentRegistry({
[...managedWorktrees].map((worktreePath) => [worktreePath, []]),
);
for (const runtime of runtimesByRuntimeId.values()) {
if (runtime.ephemeral) continue;
let group = grouped.get(runtime.worktreePath);
if (!group) {
group = [];
@@ -1105,17 +1349,14 @@ export function createAgentRegistry({
workingCount: runtimes.filter((entry) => entry.state === "streaming")
.length,
attentionCount: runtimes.filter((entry) => entry.attention).length,
recoveringCount: runtimes.filter(
(entry) => entry.state === "recovering",
).length,
recoveringCount: runtimes.filter((entry) => entry.state === "recovering")
.length,
errorCount: runtimes.filter((entry) =>
["error", "failed"].includes(entry.state),
).length,
runtimes,
}))
.sort((left, right) =>
left.worktreePath.localeCompare(right.worktreePath),
),
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath)),
...(manifestIssue ? { issue: manifestIssue } : {}),
};
}
@@ -1130,10 +1371,7 @@ export function createAgentRegistry({
const loaded = await workspaceStore.load();
manifestIssue = loaded.issue;
if (loaded.migrating) {
const sessionDir = sessionDirectoryFor(
sessionRoot,
canonicalHomeWorktree,
);
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalHomeWorktree);
await mkdir(sessionDir, {
recursive: true,
mode: SESSION_DIRECTORY_MODE,
@@ -1143,9 +1381,7 @@ export function createAgentRegistry({
runtimeId: randomUUID(),
worktreePath: canonicalHomeWorktree,
legacyDefault: true,
...(reference.sessionPath
? { sessionPath: reference.sessionPath }
: {}),
...(reference.sessionPath ? { sessionPath: reference.sessionPath } : {}),
};
if (reference.sessionPath) await restoreRecords([migrationRecord]);
else await openRuntime(migrationRecord);
@@ -1170,6 +1406,15 @@ export function createAgentRegistry({
});
await restoreRecords(records);
}
if (
!loaded.issue &&
![...runtimesByAgentId.values()].some((runtime) => runtime.adapter)
) {
await openRuntime({
worktreePath: canonicalHomeWorktree,
legacyDefault: true,
});
}
return this.listAgents()[0];
},
async selectWorktree(worktreePath) {
@@ -1179,8 +1424,7 @@ export function createAgentRegistry({
...(runtimesByWorktreePath.get(canonicalPath) ?? []),
].filter((runtime) => runtime.agentId);
const existing =
liveRuntimes.find((runtime) => runtime.legacyDefault) ??
liveRuntimes[0];
liveRuntimes.find((runtime) => runtime.legacyDefault) ?? liveRuntimes[0];
if (existing) {
if (!existing.legacyDefault) {
await setLegacyDefault(existing);
@@ -1197,25 +1441,62 @@ export function createAgentRegistry({
const opened = await openRuntime({
worktreePath: canonicalPath,
legacyDefault: true,
...(reference.sessionPath
? { sessionPath: reference.sessionPath }
: {}),
...(reference.sessionPath ? { sessionPath: reference.sessionPath } : {}),
});
return publicAgent(getRuntime(opened.runtimeId));
},
async createSessionRuntime(worktreePath) {
return openRuntime({ worktreePath });
},
async createQuickRuntime(worktreePath) {
return openRuntime({ worktreePath, ephemeral: true });
},
promoteQuickRuntime,
async openSessionRuntime(worktreePath, sessionPath) {
return openRuntime({ worktreePath, sessionPath });
},
closeSessionRuntime: closeRuntime,
async closeQuickRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (runtime.promoting) {
await runtime.promotionPromise;
return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath };
}
if (!runtime.ephemeral)
return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath };
return closeRuntime(runtimeId);
},
async getModelCatalog() {
const candidates = [...runtimesByAgentId.values()].filter(
(runtime) =>
runtime.adapter &&
!runtime.stopped &&
!["closing", "stopped", "failed"].includes(runtime.state),
);
let lastError;
for (const runtime of candidates) {
const adapter = runtime.adapter;
try {
const response = await enqueue(runtime, () =>
adapter.send({ type: "get_available_models" }),
);
if (response?.success !== true || !Array.isArray(response?.data?.models)) {
continue;
}
lastSuccessfulModelCatalog = response.data.models;
return response.data.models;
} catch (error) {
lastError = error;
}
}
if (lastSuccessfulModelCatalog) return lastSuccessfulModelCatalog;
if (lastError) throw lastError;
return [];
},
listAgents() {
return [...runtimesByAgentId.values()]
.map(publicAgent)
.sort((left, right) =>
left.worktreePath.localeCompare(right.worktreePath),
);
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath));
},
async listDirectories() {
let entries = [];
@@ -1240,9 +1521,9 @@ export function createAgentRegistry({
]);
return [...paths]
.map((worktreePath) => {
const runtime = [
...(runtimesByWorktreePath.get(worktreePath) ?? []),
].find((entry) => entry.agentId);
const runtime = [...(runtimesByWorktreePath.get(worktreePath) ?? [])].find(
(entry) => entry.agentId,
);
return {
worktreePath,
state: runtime?.state ?? "inactive",
@@ -1250,9 +1531,7 @@ export function createAgentRegistry({
...(runtime ? { agentId: runtime.agentId } : {}),
};
})
.sort((left, right) =>
left.worktreePath.localeCompare(right.worktreePath),
);
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath));
},
async forgetDirectory(worktreePath) {
let canonicalPath = path.normalize(worktreePath);
@@ -1292,14 +1571,15 @@ export function createAgentRegistry({
},
getWorkspace: workspace,
getWorkspaceSummary() {
const runtimes = [...runtimesByRuntimeId.values()];
const runtimes = [...runtimesByRuntimeId.values()].filter(
(runtime) => !runtime.ephemeral,
);
return {
bridgeInstanceId,
latestSeq: workspaceSequence,
openCount: runtimes.length,
workingCount: runtimes.filter(
(runtime) => runtime.state === "streaming",
).length,
workingCount: runtimes.filter((runtime) => runtime.state === "streaming")
.length,
attentionCount: runtimes.filter((runtime) => runtime.attention).length,
recoveringCount: runtimes.filter(
(runtime) => runtime.state === "recovering",
@@ -1323,11 +1603,11 @@ export function createAgentRegistry({
};
return enqueue(runtime, async () => {
const adapter = runtime.adapter;
const safe = async (command, fallback) => {
const safe = async (command, fallback, { includeError = false } = {}) => {
try {
return await adapter.send({ type: command });
} catch {
return fallback;
} catch (error) {
return includeError ? { ...fallback, error: String(error) } : fallback;
}
};
const state = await safe("get_state", { data: {} });
@@ -1336,9 +1616,11 @@ export function createAgentRegistry({
data: { messages: [] },
});
const commands = await safe("get_commands", { data: { commands: [] } });
const models = await safe("get_available_models", {
data: { models: [] },
});
const models = await safe(
"get_available_models",
{ data: { models: [] } },
{ includeError: true },
);
await updateRuntimeIdentity(runtime, state, { adapter });
return {
bridgeInstanceId,
@@ -1392,10 +1674,23 @@ export function createAgentRegistry({
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) {
const runtime = getAgent(agentId);
return enqueue(runtime, async () => {
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
// presentation state that belongs to the replacement generation.
runtime.adapter = undefined;
@@ -1406,13 +1701,36 @@ export function createAgentRegistry({
runtime.attention = runtime.errorAttention;
runtime.supervisor.markHealthy();
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);
});
},
async route(agentId, operation, payload = {}) {
const runtime = getAgent(agentId);
if (operation === "abort") return runtime.adapter.send({ type: "abort" });
if (operation === "abort" && !runtime.promoting)
return runtime.adapter.send({ type: "abort" });
return enqueue(runtime, async () => {
if (operation === "switch_session") {
if (runtime.state === "streaming")
@@ -1458,9 +1776,7 @@ export function createAgentRegistry({
operation === "extension_response" &&
!runtime.extensions.has(payload.requestId)
)
throw new Error(
"extension request is not actionable for this runtime",
);
throw new Error("extension request is not actionable for this runtime");
const response = await routeCommand(
runtime.adapter,
operation,
@@ -1473,8 +1789,7 @@ export function createAgentRegistry({
}
if (operation === "extension_response") {
runtime.extensions.delete(payload.requestId);
runtime.attention =
runtime.errorAttention || runtime.extensions.size > 0;
runtime.attention = runtime.errorAttention || runtime.extensions.size > 0;
}
return delivery ? { ...response, delivery } : response;
} catch (error) {
@@ -1491,7 +1806,7 @@ export function createAgentRegistry({
async stop() {
stopping = true;
while (pendingOpenOperations.size > 0)
await Promise.allSettled([...pendingOpenOperations]);
await Promise.allSettled(pendingOpenOperations);
const runtimes = [...runtimesByRuntimeId.values()];
await Promise.all(runtimes.map((runtime) => runtime.operationQueue));
await Promise.all(
@@ -1507,5 +1822,4 @@ export function createAgentRegistry({
};
}
export const sessionDirectoryMode = SESSION_DIRECTORY_MODE;
export const sessionDirectoryPath = sessionDirectoryFor;
+2 -8
View File
@@ -1,20 +1,14 @@
import { startBridgeService } from "./service.js";
export async function startBridgeDaemon(options) {
return startBridgeService(options);
}
export async function runBridgeDaemon(options) {
const daemon = await startBridgeDaemon(options);
const daemon = await startBridgeService(options);
let stopping = false;
const stop = async () => {
if (stopping) return;
stopping = true;
await daemon.close();
};
process.stdout.write(
`${JSON.stringify({ socketPath: daemon.socketPath })}\n`,
);
process.stdout.write(`${JSON.stringify({ socketPath: daemon.socketPath })}\n`);
await new Promise((resolve) => {
const shutdown = async () => {
await stop();
+5 -10
View File
@@ -67,7 +67,8 @@ function normalizePiEvent(event) {
case "extension_ui_request":
return { type: "extension_ui_request", data: { event } };
case "extension_error":
// Extension errors are diagnostics; the Pi process can still be healthy.
case "thinking_level_changed":
// Informational Pi notifications do not change runtime health.
return { type: "transcript", data: { event } };
default:
return undefined;
@@ -209,6 +210,7 @@ export function startPiRpcAdapter({
"unsupported_event",
`Pi RPC emitted unsupported event: ${event.type}`,
),
{ terminal: false },
);
return;
}
@@ -229,7 +231,6 @@ export function startPiRpcAdapter({
"frame_too_large",
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
),
{ terminal: false },
);
const consumeStdout = (text) => {
let remaining = text;
@@ -303,10 +304,7 @@ export function startPiRpcAdapter({
send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) {
if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
return Promise.reject(
new PiRpcError(
"invalid_command",
"Pi RPC command must include a type",
),
new PiRpcError("invalid_command", "Pi RPC command must include a type"),
);
}
if (Object.hasOwn(commandInput, "id")) {
@@ -382,10 +380,7 @@ export function startPiRpcAdapter({
if (closed) return exited;
intentionalStop = true;
rejectPending(
new PiRpcError(
"child_stopped",
"Pi RPC child was stopped by the bridge",
),
new PiRpcError("child_stopped", "Pi RPC child was stopped by the bridge"),
);
child.kill("SIGTERM");
return exited;
-2
View File
@@ -66,5 +66,3 @@ export async function startBridgeServer({
throw error;
}
}
export const runtimeDirectoryMode = RUNTIME_DIRECTORY_MODE;
+16
View File
@@ -40,12 +40,24 @@ export async function startBridgeService({
return registry.getWorkspace();
case "get_workspace_summary":
return registry.getWorkspaceSummary();
case "get_model_catalog":
return { models: await registry.getModelCatalog() };
case "create_session_runtime":
return {
runtime: await registry.createSessionRuntime(
request.payload.worktreePath,
),
};
case "create_quick_runtime":
return {
runtime: await registry.createQuickRuntime(
request.payload.worktreePath,
),
};
case "promote_quick_runtime":
return {
runtime: await registry.promoteQuickRuntime(request.payload.runtimeId),
};
case "open_session_runtime":
return {
runtime: await registry.openSessionRuntime(
@@ -55,6 +67,8 @@ export async function startBridgeService({
};
case "close_session_runtime":
return registry.closeSessionRuntime(request.payload.runtimeId);
case "close_quick_runtime":
return registry.closeQuickRuntime(request.payload.runtimeId);
case "list_directory_sessions":
return {
sessions: await registry.listDirectorySessions(
@@ -76,6 +90,8 @@ export async function startBridgeService({
return { agent: await registry.retry(request.agentId) };
case "restart":
return { agent: await registry.restart(request.agentId) };
case "renew_session":
return { agent: await registry.renewSession(request.agentId) };
case "list_sessions":
return { sessions: await registry.listSessions(request.agentId) };
default:
-2
View File
@@ -163,5 +163,3 @@ export async function startUnixSocketServer({
},
};
}
export const socketMode = OWNER_ONLY_MODE;
+3 -11
View File
@@ -41,8 +41,7 @@ function normalizeManifest(value) {
(value.directories !== undefined &&
(!Array.isArray(value.directories) ||
!value.directories.every(
(directory) =>
typeof directory === "string" && path.isAbsolute(directory),
(directory) => typeof directory === "string" && path.isAbsolute(directory),
)))
)
throw new Error("workspace manifest has an unsupported or invalid shape");
@@ -54,9 +53,7 @@ function normalizeManifest(value) {
for (const runtime of value.runtimes) directories.add(runtime.worktreePath);
for (const runtime of value.runtimes) {
if (runtimeIds.has(runtime.runtimeId))
throw new Error(
`workspace manifest repeats runtime ${runtime.runtimeId}`,
);
throw new Error(`workspace manifest repeats runtime ${runtime.runtimeId}`);
runtimeIds.add(runtime.runtimeId);
if (runtime.sessionPath) {
if (sessionPaths.has(runtime.sessionPath))
@@ -85,9 +82,7 @@ export function createWorkspaceStore(sessionRoot) {
await chmod(sessionRoot, DIRECTORY_MODE);
try {
return {
manifest: normalizeManifest(
JSON.parse(await readFile(filePath, "utf8")),
),
manifest: normalizeManifest(JSON.parse(await readFile(filePath, "utf8"))),
migrating: false,
};
} catch (error) {
@@ -148,6 +143,3 @@ export function createWorkspaceStore(sessionRoot) {
return { filePath, load, save };
}
export const workspaceManifestVersion = WORKSPACE_VERSION;
export const workspaceManifestFile = WORKSPACE_FILE;
+10 -14
View File
@@ -10,23 +10,17 @@ function flag(args, name) {
async function run() {
const args = process.argv.slice(2);
const socketPath = flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
const agentId = flag(args, "--agent");
const socketPath =
flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
if (!socketPath)
throw new Error("Usage: pi-status-bridge-noctalia-relay --socket <path> [--agent <id>]");
throw new Error("Usage: pi-status-bridge-noctalia-relay --socket <path>");
const client = await connectLocalClient({ socketPath });
let agent;
if (agentId) {
const agents = await client.request("list_agents");
agent = agents.agents.find((candidate) => candidate.id === agentId);
if (!agent) throw new Error(`unknown agent: ${agentId}`);
}
const relay = createNoctaliaStateRelay({
client,
...(agent ? { agent } : {}),
onState: (state) => void publishNoctaliaState(state).catch((error) =>
process.stderr.write(`${error.message}\n`),
),
onState: (state) =>
void publishNoctaliaState(state).catch((error) =>
process.stderr.write(`${error.message}\n`),
),
});
await relay.start();
await new Promise((resolve) => {
@@ -39,6 +33,8 @@ async function run() {
}
run().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`);
process.stderr.write(
`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`,
);
process.exitCode = 1;
});
+13 -60
View File
@@ -1,5 +1,3 @@
import path from "node:path";
function detailForSummary(summary) {
return [
`${summary.openCount} open`,
@@ -30,75 +28,30 @@ function summaryState(summary) {
};
}
function legacyDetail(state) {
switch (state) {
case "streaming": return "Streaming";
case "recovering": return "Recovering Pi session";
case "failed": return "Recovery failed";
case "error": return "Bridge error";
default: return "Idle";
}
}
/**
* Relays workspace-wide presentation state to Noctalia. Passing `agent` keeps
* the former agent-scoped behavior for external compatibility callers only.
*/
export function createNoctaliaStateRelay({ client, agent, onState, pollMs = 2_000 }) {
export function createNoctaliaStateRelay({ client, onState, pollMs = 2_000 }) {
if (!client || typeof client.request !== "function")
throw new TypeError("client must support request");
if (agent && (!agent.id || !agent.worktreePath))
throw new TypeError("agent id and worktreePath are required");
if (!agent && (!Number.isSafeInteger(pollMs) || pollMs < 250))
if (!Number.isSafeInteger(pollMs) || pollMs < 250)
throw new TypeError("pollMs must be an integer of at least 250ms");
if (typeof onState !== "function") throw new TypeError("onState must be a function");
if (typeof onState !== "function")
throw new TypeError("onState must be a function");
let unsubscribe = () => {};
let timer;
let stopped = false;
const publishLegacy = (state, attentionCount) => onState({
state,
projectLabel: path.basename(agent.worktreePath) || agent.worktreePath,
attentionCount,
detail: legacyDetail(state),
openCount: 1,
workingCount: state === "streaming" ? 1 : 0,
recoveringCount: state === "recovering" ? 1 : 0,
errorCount: ["error", "failed"].includes(state) ? 1 : 0,
});
const refresh = async () => {
try {
const summary = await client.request("get_workspace_summary");
if (!stopped) onState(summaryState(summary));
} finally {
if (!stopped) timer = setTimeout(() => void refresh(), pollMs);
}
};
return {
async start() {
if (agent) {
if (typeof client.subscribe !== "function")
throw new TypeError("legacy agent relay requires client.subscribe");
const response = await client.request("get_state", { agentId: agent.id });
let state = response?.data?.isStreaming ? "streaming" : "idle";
let attentionCount = 0;
publishLegacy(state, attentionCount);
unsubscribe = await client.subscribe(agent.id, 0, (event) => {
if (event.type === "agent_state" && typeof event.data?.state === "string") state = event.data.state;
if (event.type === "queue") attentionCount = (event.data?.event?.steering?.length ?? 0) + (event.data?.event?.followUp?.length ?? 0);
if (event.type === "extension_ui_request") attentionCount = Math.max(attentionCount, 1);
publishLegacy(state, attentionCount);
});
return;
}
const refresh = async () => {
try {
const summary = await client.request("get_workspace_summary");
if (!stopped) onState(summaryState(summary));
} finally {
if (!stopped) timer = setTimeout(() => void refresh(), pollMs);
}
};
await refresh();
},
start: refresh,
stop() {
stopped = true;
clearTimeout(timer);
unsubscribe();
unsubscribe = () => {};
},
};
}
+6 -4
View File
@@ -11,9 +11,13 @@ const requestOperations = new Map([
["forget_directory", { agent: false, payload: "worktree" }],
["get_workspace", { 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_quick_runtime", { agent: false, payload: "worktree" }],
["promote_quick_runtime", { agent: false, payload: "runtime" }],
["open_session_runtime", { agent: false, payload: "worktreeSession" }],
["close_session_runtime", { agent: false, payload: "runtime" }],
["close_quick_runtime", { agent: false, payload: "runtime" }],
["list_directory_sessions", { agent: false, payload: "worktree" }],
["get_session_runtime_snapshot", { agent: false, payload: "runtime" }],
["subscribe_workspace", { agent: false, payload: "cursor" }],
@@ -32,6 +36,7 @@ const requestOperations = new Map([
["extension_response", { agent: true, payload: "extensionResponse" }],
["retry", { agent: true, payload: "none" }],
["restart", { agent: true, payload: "none" }],
["renew_session", { agent: true, payload: "none" }],
["get_available_models", { agent: true, payload: "none" }],
["get_commands", { agent: true, payload: "none" }],
["set_session_name", { agent: true, payload: "sessionName" }],
@@ -102,10 +107,7 @@ function assertString(value, field, { maxLength = 4096, pattern } = {}) {
);
}
if (pattern && !pattern.test(value))
throw new ProtocolError(
"invalid_message",
`${field} has an invalid format`,
);
throw new ProtocolError("invalid_message", `${field} has an invalid format`);
return value;
}
+84 -2
View File
@@ -78,6 +78,60 @@ test("starts only the home agent and creates other worktree agents on explicit s
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
});
test("model catalog retries unsuccessful responses and falls back to last success", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
await registry.start();
await registry.selectWorktree(worktrees.feature);
const expected = [{ provider: "provider", id: "family/model" }];
const rejected = [{ provider: "wrong", id: "must-not-be-cached" }];
const sends = fixture.calls.map(({ adapter }) => adapter.send.bind(adapter));
let homeCatalogCalls = 0;
let featureCatalogCalls = 0;
fixture.calls[0].adapter.send = async (command, options) => {
if (command.type === "get_available_models") {
homeCatalogCalls += 1;
return {
type: "response",
success: false,
data: { models: rejected },
};
}
return sends[0](command, options);
};
fixture.calls[1].adapter.send = async (command, options) => {
if (command.type === "get_available_models") {
featureCatalogCalls += 1;
return { type: "response", success: true, data: { models: expected } };
}
return sends[1](command, options);
};
assert.deepEqual(await registry.getModelCatalog(), expected);
assert.equal(homeCatalogCalls, 1);
assert.equal(featureCatalogCalls, 1);
fixture.calls[1].adapter.send = async (command, options) => {
if (command.type === "get_available_models") {
featureCatalogCalls += 1;
return {
type: "response",
success: true,
data: { models: "malformed" },
};
}
return sends[1](command, options);
};
assert.deepEqual(await registry.getModelCatalog(), expected);
assert.equal(homeCatalogCalls, 2);
assert.equal(featureCatalogCalls, 2);
await registry.stop();
});
test("sends abort without waiting behind a blocked prompt", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
@@ -108,7 +162,7 @@ test("sends abort without waiting behind a blocked prompt", async () => {
await registry.stop();
});
test("keeps a runtime healthy after a non-terminal adapter diagnostic", async () => {
test("keeps a runtime healthy after an unknown non-terminal Pi notification", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
@@ -119,7 +173,10 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async ()
await registry.start();
fixture.calls[0].options.onError(
{ code: "frame_too_large", message: "Pi RPC frame exceeds 32 bytes" },
{
code: "unsupported_event",
message: "Pi RPC emitted unsupported event: future_pi_notification",
},
{ terminal: false },
);
@@ -130,6 +187,31 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async ()
await registry.stop();
});
test("keeps explicit transport failures terminal", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
await registry.start();
fixture.calls[0].options.onError({
code: "invalid_json",
message: "Pi RPC emitted invalid JSON",
});
const runtime = registry.getWorkspace().directories[0].runtimes[0];
assert.equal(runtime.state, "error");
assert.equal(runtime.attention, true);
assert.deepEqual(runtime.error, {
code: "invalid_json",
message: "Pi RPC emitted invalid JSON",
});
await registry.stop();
});
test("stops agents created during shutdown and rejects new selections", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
-33
View File
@@ -1,33 +0,0 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { startBridgeDaemon } from "../src/bridge/daemon.js";
import { createNoctaliaStateRelay } from "../src/client/noctalia-relay.js";
test("starts a bridge daemon around the local bridge service", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-daemon-"));
const home = join(root, "home");
await mkdir(home);
const daemon = await startBridgeDaemon({ homeWorktree: home, runtimeDir: join(root, "runtime"), sessionRoot: join(root, "sessions"), startAdapter: () => ({ send: async () => ({ type: "response", success: true }), respondToExtension: () => {}, stop: async () => {} }) });
try { assert.match(daemon.socketPath, /bridge\.sock$/); assert.equal(daemon.listAgents()[0].worktreePath, home); }
finally { await daemon.close(); }
});
test("relays aggregate workspace summary as compact Noctalia state", async () => {
const updates = [];
const relay = createNoctaliaStateRelay({
client: { request: async (operation) => { assert.equal(operation, "get_workspace_summary"); return { openCount: 3, workingCount: 1, attentionCount: 2, recoveringCount: 0, errorCount: 1 }; } },
onState: (state) => updates.push(state), pollMs: 250,
});
await relay.start(); relay.stop();
assert.deepEqual(updates, [{ state: "error", projectLabel: "3 sessions", attentionCount: 2, detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1 }]);
});
test("retains agent-scoped relay behavior only when explicitly requested", async () => {
let listener; const updates = [];
const relay = createNoctaliaStateRelay({ client: { request: async () => ({ data: { isStreaming: false } }), subscribe: async (_agentId, _cursor, callback) => { listener = callback; return () => {}; } }, agent: { id: "agent-1", worktreePath: "/worktrees/feature" }, onState: (state) => updates.push(state) });
await relay.start(); listener({ type: "agent_state", data: { state: "streaming" } });
assert.equal(updates.at(-1).state, "streaming"); assert.equal(updates.at(-1).projectLabel, "feature");
});
+149 -23
View File
@@ -177,9 +177,16 @@ test("closing removes desired-open intent, preserves history, and refuses direct
await registry.stop();
});
test("restores durable runtime IDs with two-at-a-time concurrency and preserves command isolation", async () => {
test("restores durable runtime IDs and history labels with two-at-a-time concurrency", async () => {
const paths = await fixture();
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one");
await writeFile(
firstPath,
`${await readFile(firstPath, "utf8")}${JSON.stringify({
type: "message",
message: { role: "user", content: "Hydrate this session label" },
})}\n`,
);
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "two");
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "three");
const firstAdapters = adapterFactory({
@@ -195,6 +202,7 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
if (!initial.sessionPath)
await firstRegistry.closeSessionRuntime(initial.runtimeId);
const one = await firstRegistry.openSessionRuntime(paths.home, firstPath);
assert.equal(one.label, "Hydrate this session label");
const two = await firstRegistry.openSessionRuntime(paths.home, secondPath);
const three = await firstRegistry.openSessionRuntime(paths.home, thirdPath);
await firstRegistry.stop();
@@ -224,12 +232,10 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
startAdapter,
});
const starting = restored.start();
while (gates.length < 2)
await new Promise((resolve) => setImmediate(resolve));
while (gates.length < 2) await new Promise((resolve) => setImmediate(resolve));
assert.equal(gates.length, 2);
gates.splice(0).forEach((resolve) => resolve());
while (gates.length < 1)
await new Promise((resolve) => setImmediate(resolve));
while (gates.length < 1) await new Promise((resolve) => setImmediate(resolve));
assert.equal(gates.length, 1);
gates.splice(0).forEach((resolve) => resolve());
await starting;
@@ -243,6 +249,14 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
new Set([one.runtimeId, two.runtimeId, three.runtimeId]),
);
assert.equal(maxActiveStarts, 2);
assert.equal(
restored
.getWorkspace()
.directories[0].runtimes.find(
(runtime) => runtime.runtimeId === one.runtimeId,
)?.label,
"Hydrate this session label",
);
assert.ok(
restored
.listAgents()
@@ -290,11 +304,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 () => {
const paths = await fixture();
const saved = await sessionFile(
paths.sessionRoot,
paths.home,
"forced-close",
);
const saved = await sessionFile(paths.sessionRoot, paths.home, "forced-close");
let killed;
let releaseStop;
let blockedSavedAdapter = false;
@@ -445,6 +455,68 @@ test("legacy new session releases old identity and makes its history reopenable"
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 () => {
const paths = await fixture();
const createdPath = await sessionFile(
@@ -611,6 +683,28 @@ test("non-default runtime snapshots do not replace the legacy session pointer",
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 () => {
const paths = await fixture();
await mkdir(paths.sessionRoot, { recursive: true });
@@ -622,10 +716,7 @@ test("retains a corrupt workspace manifest and reports its issue", async () => {
startAdapter: adapterFactory().startAdapter,
});
await registry.start();
assert.equal(
registry.getWorkspace().issue.code,
"invalid_workspace_manifest",
);
assert.equal(registry.getWorkspace().issue.code, "invalid_workspace_manifest");
assert.equal(await readFile(manifestPath, "utf8"), "{broken");
await registry.stop();
});
@@ -912,7 +1003,7 @@ test("closing retains a saved-session lease until the child stops", async () =>
await registry.stop();
});
test("first migration retains a missing legacy session as failed dormant intent", async () => {
test("first migration retains missing intent and starts an idle home runtime", async () => {
const paths = await fixture();
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
const missing = join(sessionDir, "missing.jsonl");
@@ -928,14 +1019,18 @@ test("first migration retains a missing legacy session as failed dormant intent"
startAdapter: adapters.startAdapter,
});
await registry.start();
const runtime = registry.getWorkspace().directories[0].runtimes[0];
assert.equal(runtime.state, "failed");
assert.equal(runtime.sessionPath, missing);
assert.equal(adapters.calls.length, 0);
const runtimes = registry.getWorkspace().directories[0].runtimes;
const failed = runtimes.find((runtime) => runtime.state === "failed");
const live = runtimes.find((runtime) => runtime.state === "idle");
assert.equal(failed.sessionPath, missing);
assert.equal(live.worktreePath, paths.home);
assert.equal(adapters.calls.length, 1);
const manifest = await readJson(
join(paths.sessionRoot, "bridge-workspace-v2.json"),
);
assert.equal(manifest.runtimes[0].sessionPath, missing);
assert.ok(
manifest.runtimes.some((runtime) => runtime.sessionPath === missing),
);
await registry.stop();
});
@@ -1025,6 +1120,35 @@ test("manual restart ignores late extension dialogs from the replaced child", as
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 () => {
const paths = await fixture();
const adapters = adapterFactory();
@@ -1094,7 +1218,7 @@ test("snapshot rejects results from an adapter replaced during recovery", async
await registry.stop();
});
test("closing the last runtime persists a zero-runtime restart", async () => {
test("restoration with no live adapter starts one idle home runtime", async () => {
const paths = await fixture();
const firstAdapters = adapterFactory();
const first = createAgentRegistry({
@@ -1112,8 +1236,10 @@ test("closing the last runtime persists a zero-runtime restart", async () => {
startAdapter: restoredAdapters.startAdapter,
});
await restored.start();
assert.equal(restored.getWorkspaceSummary().openCount, 0);
assert.equal(restoredAdapters.calls.length, 0);
assert.equal(restored.getWorkspaceSummary().openCount, 1);
assert.equal(restoredAdapters.calls.length, 1);
assert.equal(restored.listAgents()[0].worktreePath, paths.home);
assert.equal(restored.listAgents()[0].state, "idle");
await restored.stop();
});
+13
View File
@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createNoctaliaStateRelay } from "../src/client/noctalia-relay.js";
test("relays aggregate workspace summary as compact Noctalia state", async () => {
const updates = [];
const relay = createNoctaliaStateRelay({
client: { request: async (operation) => { assert.equal(operation, "get_workspace_summary"); return { openCount: 3, workingCount: 1, attentionCount: 2, recoveringCount: 0, errorCount: 1 }; } },
onState: (state) => updates.push(state), pollMs: 250,
});
await relay.start(); relay.stop();
assert.deepEqual(updates, [{ state: "error", projectLabel: "3 sessions", attentionCount: 2, detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1 }]);
});
+50 -7
View File
@@ -115,10 +115,7 @@ test("allows a per-command timeout override without serializing it to Pi", async
type: "get_state",
id: initialCommand.id,
});
await assert.rejects(
adapter.send({ type: "get_session_stats" }),
/timed out/,
);
await assert.rejects(adapter.send({ type: "get_session_stats" }), /timed out/);
await adapter.stop();
});
@@ -208,6 +205,49 @@ test("forwards extension responses without replacing Pi's request ID", async ()
await adapter.stop();
});
test("normalizes thinking changes and treats every unknown Pi notification as non-terminal", async () => {
const fixture = createFixture();
const events = [];
const errors = [];
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
onEvent: (event) => events.push(event),
onError: (error, metadata) => errors.push({ error, metadata }),
});
const thinkingChange = {
type: "thinking_level_changed",
level: "high",
};
fixture.child.stdout.write(`${JSON.stringify(thinkingChange)}\n`);
fixture.child.stdout.write(
`${JSON.stringify({ type: "future_pi_notification", detail: "new" })}\n`,
);
fixture.child.stdout.write(
`${JSON.stringify({ type: "fatal_pi_notification", terminal: true })}\n`,
);
assert.deepEqual(events, [
{
seq: 1,
type: "transcript",
data: { event: thinkingChange },
},
]);
assert.deepEqual(
errors.map(({ error, metadata }) => ({
code: error.code,
terminal: metadata.terminal,
})),
[
{ code: "unsupported_event", terminal: false },
{ code: "unsupported_event", terminal: false },
],
);
await adapter.stop();
});
test("discards a fragmented oversized frame before processing the next frame", async () => {
const fixture = createFixture();
const errors = [];
@@ -217,7 +257,7 @@ test("discards a fragmented oversized frame before processing the next frame", a
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
maxFrameBytes: 32,
onError: (error) => errors.push(error),
onError: (error, metadata) => errors.push({ error, metadata }),
onEvent: (event) => events.push(event),
});
const oversized = JSON.stringify({
@@ -230,8 +270,11 @@ test("discards a fragmented oversized frame before processing the next frame", a
);
assert.deepEqual(
errors.map((error) => error.code),
["frame_too_large"],
errors.map(({ error, metadata }) => ({
code: error.code,
terminal: metadata?.terminal !== false,
})),
[{ code: "frame_too_large", terminal: true }],
);
assert.deepEqual(events, [
{
+12
View File
@@ -79,6 +79,15 @@ test("accepts bridge-managed directory and session operations", () => {
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(
parseRequestFrame(
request({
@@ -115,8 +124,11 @@ test("accepts additive multi-session runtime operations and numeric workspace cu
{ op: "get_workspace" },
{ op: "get_workspace_summary" },
{ op: "create_session_runtime", payload: { worktreePath } },
{ op: "create_quick_runtime", payload: { worktreePath } },
{ op: "promote_quick_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "close_quick_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "list_directory_sessions", payload: { worktreePath } },
{
op: "get_session_runtime_snapshot",
+409
View File
@@ -0,0 +1,409 @@
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 promotes runtime without a 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, /<ReactMarkdown remarkPlugins=\{\[remarkGfm\]\}>/);
assert.match(source, /className="quick-pane-notice escalation"/);
assert.match(source, /className="quick-pane-assistant-footer"/);
assert.match(source, /Continue in full session/);
const toolbar = source.slice(
source.indexOf('<header className="quick-pane-toolbar">'),
source.indexOf(
"</header>",
source.indexOf('<header className="quick-pane-toolbar">'),
),
);
assert.doesNotMatch(toolbar, /continueFull|Continue/);
assert.match(
source,
/await invoke<\{ runtime: Runtime \}>\("promote_quick_runtime", \{\s+runtimeId: current\.runtimeId,/,
);
assert.doesNotMatch(source, /Quick Pane transcript:/);
assert.doesNotMatch(source, /create_session_runtime/);
assert.doesNotMatch(source, /maximumSeconds|Quick time budget/);
});
test("main Settings uses shared catalog selectors with provider derived from models", async () => {
const [pane, app, catalog, 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/model-catalog.ts", import.meta.url), "utf8"),
readFile(
new URL("../ui/src/workspace/persistence.ts", import.meta.url),
"utf8",
),
]);
assert.doesNotMatch(catalog, /get_model_catalog/);
assert.match(app, /create_quick_runtime/);
assert.match(app, /get_session_runtime_snapshot/);
assert.match(app, /close_quick_runtime/);
assert.match(app, /label="Quick model"/);
assert.match(app, /label="Follow-up model"/);
assert.match(app, /<ModelSelector/);
assert.doesNotMatch(app, /Quick provider\s*<input/);
assert.doesNotMatch(app, /Follow-up provider\s*<input/);
assert.match(app, /provider: identity\.provider/);
assert.match(app, /followUpProvider: identity\.provider/);
assert.match(app, /Save Quick Pane settings/);
assert.match(pane, /snapshot\.models\?\.data\?\.models/);
assert.match(pane, /preparedRuntimeRef/);
assert.doesNotMatch(pane, /get_model_catalog/);
assert.match(pane, /set_thinking_level/);
assert.match(pane, /Loading models…/);
assert.match(pane, /catalogStatus !== "ready"/);
assert.doesNotMatch(pane, /showSettings/);
assert.doesNotMatch(pane, /Save Quick Pane settings/);
assert.match(pane, /!snapshotIsActive\(snapshot\)/);
assert.match(pane, /if \(current !== submitted\.request\) return current;/);
assert.match(pane, /inputSelectionRef\.current = undefined;/);
assert.match(pane, /input\.setSelectionRange\(/);
assert.match(pane, /onSelect=\{\(event\) => recordInputSelection/);
assert.match(pane, /onInput=\{\(event\) => recordInputChangeSelection/);
assert.match(pane, /<ThinkingSelector/);
assert.match(pane, /quick-pane-catalog-overlay/);
assert.match(pane, /readOnly=\{runActive\}/);
assert.match(pane, /window\.requestAnimationFrame\(focusInput\)/);
assert.doesNotMatch(pane, /\.slice\(0, 5\)/);
assert.doesNotMatch(pane, /aria-label="Answer history"/);
assert.match(
pane,
/transcriptMessages\(snapshot, submittedPromptTurnsRef\.current\)/,
);
assert.match(pane, /const QUICK_PANE_ADVISORY_PREFIX =/);
assert.match(pane, /submittedPromptTurns\[nextSubmittedTurn\]/);
assert.match(
pane,
/text\.startsWith\(QUICK_PANE_ADVISORY_PREFIX\) \? "" : text/,
);
assert.match(pane, /submittedPromptTurnsRef\.current\.push\(submittedTurn\)/);
assert.match(pane, /nextSubmittedTurnIdRef\.current/);
assert.match(pane, /submittedPromptTurnsRef\.current = \[\]/);
assert.doesNotMatch(
pane,
/delimiterIndex|text\.indexOf\(USER_REQUEST_DELIMITER\)/,
);
assert.match(
pane,
/message\.role !== "user" && message\.role !== "assistant"/,
);
assert.match(pane, /lastUserQuestionAtRef\.current = submittedAt/);
assert.match(pane, /lastCompletedAnswerAtRef\.current = Date\.now\(\)/);
assert.doesNotMatch(pane, /localStorage|sessionStorage|save_ui_state/);
assert.match(persistence, /quickPane: current\.quickPane/);
});
test("Quick Pane keeps slash-containing model IDs atomic and renews only on pane show", async () => {
const [pane, catalog] = await Promise.all([
readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
),
readFile(new URL("../ui/src/model-catalog.ts", import.meta.url), "utf8"),
]);
assert.match(pane, /useState<ModelIdentity>/);
assert.match(pane, /provider: submittedModel\.provider/);
assert.match(pane, /modelId: submittedModel\.modelId/);
assert.doesNotMatch(pane, /selectedModel\.split/);
assert.match(
pane,
/listen\("quick-pane-shown"[\s\S]*?void renewChatOnShow\(\)/,
);
assert.match(
pane,
/onFocusChanged[\s\S]*?if \(event\.payload\) focusInput\(\);/,
);
assert.match(pane, /const CHAT_RENEWAL_MS = 180_000/);
assert.match(pane, /Date\.now\(\) - lastActivity > CHAT_RENEWAL_MS/);
assert.match(pane, /event\.ctrlKey && event\.key\.toLowerCase\(\) === "n"/);
assert.match(pane, /event\.preventDefault\(\)/);
assert.doesNotMatch(
pane,
/setInterval[^\n]*CHAT_RENEWAL_MS|setTimeout[^\n]*CHAT_RENEWAL_MS/,
);
assert.match(
catalog,
/JSON\.stringify\(\[identity\.provider, identity\.modelId\]\)/,
);
assert.match(catalog, /JSON\.parse\(value\)/);
});
test("Quick Pane dismiss and mutually exclusive visibility use native window state", async () => {
const [pane, nativeSource] = await Promise.all([
readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
),
readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
]);
assert.match(pane, /invoke\("dismiss_quick_pane"\)/);
assert.doesNotMatch(pane, /getCurrentWindow\(\)\.hide\(\)/);
const dismissPane = pane.slice(
pane.indexOf("const dismissPane"),
pane.indexOf("const clearChatState"),
);
assert.doesNotMatch(dismissPane, /closeRuntime|clearRuntime|prepareRuntime/);
assert.match(nativeSource, /fn dismiss_quick_pane\(/);
assert.match(
nativeSource,
/dismiss_quick_pane,[\s\S]*create_session_runtime/,
);
assert.doesNotMatch(nativeSource, /ManagedWindowVisibility|WindowVisibility/);
assert.match(nativeSource, /window\.is_visible\(\)/);
const mainShow = nativeSource.slice(
nativeSource.indexOf("fn apply_main_window_action"),
nativeSource.indexOf("fn get_or_create_quick_pane"),
);
const mainHideQuick = mainShow.indexOf("quick_pane.hide()");
const showMain = mainShow.indexOf("window.show()");
assert.ok(mainShow.indexOf('get_webview_window("quick-pane")') >= 0);
assert.ok(mainHideQuick >= 0 && mainHideQuick < showMain);
const quickShow = nativeSource.slice(
nativeSource.indexOf("fn toggle_quick_pane"),
nativeSource.indexOf("fn requested_new_worktree_argument"),
);
const quickHideMain = quickShow.indexOf("main.hide()");
const showQuick = quickShow.indexOf("window.show()");
assert.ok(quickShow.indexOf('get_webview_window("main")') >= 0);
assert.ok(quickHideMain >= 0 && quickHideMain < showQuick);
});
test("native promotion owns visibility and Main consumes pending runtime after startup refresh", async () => {
const [nativeSource, workspaceSource] = await Promise.all([
readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
readFile(
new URL("../ui/src/workspace/useWorkspace.ts", import.meta.url),
"utf8",
),
]);
const promotion = nativeSource.slice(
nativeSource.indexOf("fn show_main_for_promoted_runtime"),
nativeSource.indexOf("fn toggle_quick_pane"),
);
assert.match(promotion, /get_or_create_main_with_visibility\(app, false\)/);
assert.ok(
promotion.indexOf("quick_pane\n .hide()") <
promotion.indexOf("window\n .show()"),
);
assert.match(promotion, /\*pending = Some\(runtime_id\)/);
assert.match(nativeSource, /fn get_pending_promoted_runtime/);
assert.match(nativeSource, /fn consume_pending_promoted_runtime/);
assert.match(workspaceSource, /await promotedListenerReady/);
assert.match(workspaceSource, /await refresh\(loaded\)/);
assert.match(workspaceSource, /get_pending_promoted_runtime/);
assert.match(workspaceSource, /consume_pending_promoted_runtime/);
});
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/);
});
test("Quick Pane uses native dimensions and request-first compact layout", async () => {
const [source, stylesheet, configSource, nativeSource, capabilitySource] =
await Promise.all([
readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
),
readFile(
new URL("../ui/src/quick-pane/quick-pane.css", import.meta.url),
"utf8",
),
readFile(
new URL("../ui/src-tauri/tauri.conf.json", import.meta.url),
"utf8",
),
readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
readFile(
new URL("../ui/src-tauri/capabilities/default.json", import.meta.url),
"utf8",
),
]);
const config = JSON.parse(configSource);
const capability = JSON.parse(capabilitySource);
const quickPane = config.app.windows.find(
(window) => window.label === "quick-pane",
);
assert.deepEqual(
{
width: quickPane.width,
height: quickPane.height,
minWidth: quickPane.minWidth,
minHeight: quickPane.minHeight,
transparent: quickPane.transparent,
},
{
width: 640,
height: 360,
minWidth: 520,
minHeight: 360,
transparent: true,
},
);
assert.ok(capability.windows.includes("quick-pane"));
assert.match(nativeSource, /WindowEvent::CloseRequested/);
assert.match(nativeSource, /api\.prevent_close\(\)/);
assert.match(
nativeSource,
/WebviewWindowBuilder::from_config\(app, config\)/,
);
assert.match(nativeSource, /get_or_create_quick_pane/);
assert.match(nativeSource, /fn resize_quick_pane/);
assert.match(nativeSource, /height\.clamp\(360\.0, 520\.0\)/);
assert.match(source, /new ResizeObserver\(resizeToContent\)/);
assert.match(source, /invoke\("resize_quick_pane", \{ height \}\)/);
assert.doesNotMatch(source, /onCloseRequested/);
assert.match(source, /className="quick-pane-titlebar"/);
assert.match(source, /startDragging\(\)/);
assert.match(source, /className="quick-pane-request"/);
assert.match(source, /className=\{`quick-pane-exchange/);
assert.match(source, /onOpen=\{handleModelMenuOpen\}/);
assert.match(source, /onClose=\{handleModelMenuClose\}/);
assert.match(source, /requestPaneResize\(QUICK_PANE_MAX_HEIGHT\)/);
assert.match(source, /const QUICK_PANE_MIN_HEIGHT = 360/);
assert.match(source, /const QUICK_PANE_MAX_HEIGHT = 520/);
assert.match(source, /height = clampQuickPaneHeight\(height\)/);
assert.match(source, /if \(modelMenuOpenRef\.current\) return;/);
assert.match(source, /submittedPromptTurnsRef/);
assert.match(source, /function elapsedLabel/);
assert.match(source, /quick-pane-assistant-activity/);
assert.match(source, /transcriptFollowRef/);
assert.match(
source,
/transcript\.scrollHeight\s*-\s*transcript\.clientHeight/,
);
assert.match(source, /className="quick-pane-composer"/);
assert.match(source, /className="quick-pane-composer-actions"/);
assert.match(source, /replaceChat\(false, true\)/);
assert.match(source, /replaceChat\(false, false\)/);
assert.match(source, /className="quick-pane-send"/);
assert.match(source, /ref=\{inputRef\}/);
assert.match(source, /focusInput\(\)/);
assert.doesNotMatch(source, /if \(!event\.payload\)[\s\S]*?hide\(/);
assert.match(
source,
/event\.key === "Enter" &&\s*!event\.shiftKey &&\s*!event\.nativeEvent\.isComposing/,
);
assert.match(source, /event\.preventDefault\(\);\s*void run\(\);/);
assert.match(
stylesheet,
/\.quick-pane-exchange[\s\S]*?flex: 1 1 auto[\s\S]*?overflow: hidden/,
);
assert.match(
stylesheet,
/\.quick-pane-transcript[\s\S]*?flex: 1 1 auto[\s\S]*?overflow-y: auto/,
);
assert.match(
stylesheet,
/\.quick-pane-composer[\s\S]*?height: 120px[\s\S]*?min-height: 110px[\s\S]*?max-height: 130px/,
);
assert.match(
stylesheet,
/\.quick-pane-titlebar[\s\S]*?height:\s*20px[\s\S]*?border-bottom:[\s\S]*?cursor:\s*grab/,
);
assert.match(
stylesheet,
/\.quick-pane-composer-actions[\s\S]*?position: absolute/,
);
assert.match(stylesheet, /\.quick-pane-send[\s\S]*?position: absolute/);
assert.match(
stylesheet,
/\.quick-pane-assistant-elapsed[\s\S]*?font-variant-numeric/,
);
assert.match(stylesheet, /\.quick-pane-assistant-activity[\s\S]*?color:/);
assert.match(stylesheet, /@media \(max-width: 400px\)/);
assert.match(stylesheet, /html,[\s\S]*?#root\s*\{[\s\S]*?overflow: hidden/);
assert.match(stylesheet, /\.quick-pane\s*\{[\s\S]*?overflow: hidden/);
assert.doesNotMatch(
stylesheet,
/\.quick-pane-primary-controls\s*\{[^}]*overflow:\s*(?:hidden|clip)/,
);
assert.doesNotMatch(
stylesheet,
/\.quick-pane-header-actions\s*\{[^}]*overflow:\s*(?:hidden|clip)/,
);
assert.match(
stylesheet,
/\.quick-pane button\.quick-pane-toolbar-control,[\s\S]*?height:\s*27px[\s\S]*?min-width:\s*48px[\s\S]*?padding:\s*0 8px[\s\S]*?border:[\s\S]*?border-radius:[\s\S]*?background:[\s\S]*?font:/,
);
assert.match(
stylesheet,
/\.quick-pane \.quick-pane-toolbar-control:hover:not\(:disabled\)/,
);
assert.match(
stylesheet,
/\.quick-pane \.quick-pane-toolbar-control:focus-visible/,
);
assert.match(
stylesheet,
/\.quick-pane \.quick-pane-toolbar-control:disabled/,
);
assert.match(
stylesheet,
/\.quick-pane-thinking\s*\{[\s\S]*?flex:\s*0 0 143px[\s\S]*?grid-template-columns:\s*auto minmax\(84px, 1fr\)/,
);
assert.match(
stylesheet,
/\.quick-pane-toolbar\s*\{[\s\S]*?flex-wrap:\s*nowrap/,
);
assert.match(source, /triggerClassName="quick-pane-toolbar-control"/);
assert.match(source, /controlClassName="quick-pane-toolbar-control"/);
assert.match(
source,
/className="quick-pane-toolbar-control quick-pane-action"/,
);
assert.match(
source,
/className="quick-pane-toolbar-control quick-pane-close"/,
);
assert.match(stylesheet, /#f0a347/);
assert.doesNotMatch(stylesheet, /#8aa9d6|#46494f|#23252a/);
const [selectorSource, selectorStylesheet] = await Promise.all([
readFile(
new URL("../ui/src/components/ModelSelector.tsx", import.meta.url),
"utf8",
),
readFile(
new URL("../ui/src/components/ModelSelector.css", import.meta.url),
"utf8",
),
]);
assert.match(selectorSource, /import "\.\/ModelSelector\.css";/);
assert.match(selectorSource, /onOpen\?: \(\) => void;/);
assert.match(selectorSource, /onClose\?: \(\) => void;/);
assert.match(selectorSource, /onCloseRef\.current\?\.\(\);/);
assert.match(
selectorStylesheet,
/\.model-trigger\s*>\s*span\s*\{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis/,
);
assert.match(
selectorStylesheet,
/\.model-menu\s*\{[\s\S]*?grid-template-rows:\s*auto auto minmax\(0, 1fr\);[\s\S]*?overflow:\s*hidden/,
);
assert.match(
selectorStylesheet,
/\.model-results\s*\{[\s\S]*?overflow-y:\s*auto/,
);
});
+550
View File
@@ -0,0 +1,550 @@
import assert from "node:assert/strict";
import {
lstat,
mkdir,
mkdtemp,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
createAgentRegistry,
promoteQuickSessionDirectory,
sessionDirectoryPath,
} from "../src/bridge/agent-registry.js";
import { createWorkspaceStore } from "../src/bridge/workspace-store.js";
function adapters() {
const calls = [];
return {
calls,
startAdapter(options) {
const adapter = {
sent: [],
stopped: false,
state: {},
async send(command) {
this.sent.push(command);
return {
type: "response",
command: command.type,
success: true,
data: command.type === "get_state" ? this.state : {},
};
},
respondToExtension() {},
async stop() {
this.stopped = true;
},
};
calls.push({ options, adapter });
return adapter;
},
};
}
test("keeps quick runtimes out of workspace state until promotion", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-isolation-"));
const home = join(root, "home");
const feature = join(root, "feature");
await Promise.all([mkdir(home), mkdir(feature)]);
const fixture = adapters();
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot: join(root, "sessions"),
startAdapter: fixture.startAdapter,
});
await registry.start();
assert.equal(
registry.workspaceEventsAfter(0).events.at(-1).type,
"runtime_opened",
);
const cursor = registry.getWorkspace().latestSeq;
const received = [];
const subscription = registry.subscribeWorkspace(cursor, (event) =>
received.push(event),
);
const quick = await Promise.all(
Array.from({ length: 5 }, () => registry.createQuickRuntime(feature)),
);
fixture.calls.at(-1).options.onEvent({
type: "transcript",
data: { event: { type: "message_end" } },
});
assert.deepEqual(
registry
.getWorkspace()
.directories.map((directory) => directory.worktreePath),
[home],
);
assert.equal(registry.getWorkspace().directories[0].openCount, 1);
assert.deepEqual(registry.getWorkspaceSummary(), {
bridgeInstanceId: registry.bridgeInstanceId,
latestSeq: cursor,
openCount: 1,
workingCount: 0,
attentionCount: 0,
recoveringCount: 0,
errorCount: 0,
directoryCount: 1,
resourceWarning: false,
});
const quickSnapshot = await registry.getSessionRuntimeSnapshot(
quick[0].runtimeId,
);
assert.equal(quickSnapshot.runtime.runtimeId, quick[0].runtimeId);
await registry.closeQuickRuntime(quick.at(-1).runtimeId);
assert.deepEqual(received, []);
assert.deepEqual(registry.workspaceEventsAfter(cursor).events, []);
const promoted = await registry.promoteQuickRuntime(quick[0].runtimeId);
const featureDirectory = registry
.getWorkspace()
.directories.find((directory) => directory.worktreePath === feature);
assert.deepEqual(
featureDirectory?.runtimes.map((runtime) => runtime.runtimeId),
[quick[0].runtimeId],
);
assert.equal(promoted.runtimeId, quick[0].runtimeId);
assert.equal(registry.getWorkspaceSummary().openCount, 2);
assert.deepEqual(
received.map((event) => event.type),
["runtime_promoted"],
);
assert.deepEqual(
registry.workspaceEventsAfter(cursor).events.map((event) => event.type),
["runtime_promoted"],
);
subscription.unsubscribe();
await registry.stop();
});
test("promotes an active quick runtime in place and makes stale quick cleanup harmless", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-"));
const home = join(root, "home");
await mkdir(home);
const fixture = adapters();
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot: join(root, "sessions"),
startAdapter: fixture.startAdapter,
});
await registry.start();
const quick = await registry.createQuickRuntime(home);
const quickAdapter = fixture.calls.at(-1).adapter;
await registry.route(quick.agentId, "prompt", { message: "keep working" });
const events = [];
const subscription = registry.subscribeWorkspace(0, (event) =>
events.push(event),
);
const [promoted, repeated] = await Promise.all([
registry.promoteQuickRuntime(quick.runtimeId),
registry.promoteQuickRuntime(quick.runtimeId),
]);
assert.equal(promoted.runtimeId, quick.runtimeId);
assert.equal(repeated.agentId, quick.agentId);
assert.equal(fixture.calls.at(-1).adapter, quickAdapter);
assert.equal(quickAdapter.stopped, false);
assert.equal(promoted.sessionPath, undefined);
assert.ok(events.some((event) => event.type === "runtime_promoted"));
assert.equal(
registry
.getWorkspace()
.directories[0].runtimes.some(
(runtime) => runtime.runtimeId === quick.runtimeId,
),
true,
);
assert.match(
await readFile(join(root, "sessions", "bridge-workspace-v2.json"), "utf8"),
new RegExp(quick.runtimeId),
);
assert.match(
await readFile(
join(
sessionDirectoryPath(join(root, "sessions"), home),
"bridge-agent.json",
),
"utf8",
),
/"worktreePath"/,
);
await registry.closeQuickRuntime(quick.runtimeId);
assert.equal(quickAdapter.stopped, false);
await registry.route(quick.agentId, "abort");
subscription.unsubscribe();
await registry.stop();
});
test("promotion refreshes active JSONL identity without replacing runtime state", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-identity-"));
const home = join(root, "home");
await mkdir(home);
const fixture = adapters();
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot: join(root, "sessions"),
startAdapter: fixture.startAdapter,
});
await registry.start();
const quick = await registry.createQuickRuntime(home);
const call = fixture.calls.at(-1);
const quickPath = join(call.options.sessionDir, "active.jsonl");
await writeFile(quickPath, '{"type":"session"}\n');
call.adapter.state = { sessionFile: quickPath, sessionId: "session-active" };
call.options.onEvent({
type: "queue",
data: { event: { pendingMessageCount: 2 } },
});
call.options.onEvent({
type: "extension_ui_request",
data: { event: { id: "extension-1", method: "confirm" } },
});
await registry.route(quick.agentId, "set_model", {
provider: "test",
modelId: "model-1",
});
await registry.route(quick.agentId, "set_thinking_level", { level: "high" });
const promoted = await registry.promoteQuickRuntime(quick.runtimeId);
const snapshot = await registry.getSessionRuntimeSnapshot(quick.runtimeId);
assert.equal(promoted.runtimeId, quick.runtimeId);
assert.equal(promoted.agentId, quick.agentId);
assert.equal(promoted.sessionId, "session-active");
assert.match(promoted.sessionPath, /active\.jsonl$/);
assert.equal(snapshot.runtime.queueCount, 2);
assert.equal(snapshot.extensions[0].id, "extension-1");
assert.deepEqual(
call.adapter.sent.filter((command) =>
["set_model", "set_thinking_level"].includes(command.type),
),
[
{ type: "set_model", provider: "test", modelId: "model-1" },
{ type: "set_thinking_level", level: "high" },
],
);
assert.equal(fixture.calls.at(-1).adapter, call.adapter);
await registry.stop();
});
test("session-directory migration rolls back move and symlink failures for retry", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-directory-rollback-"));
const quickDir = join(root, "quick");
const sessionDir = join(root, "session");
await mkdir(quickDir);
await writeFile(join(quickDir, "active.jsonl"), "history");
let renameCalls = 0;
await assert.rejects(
promoteQuickSessionDirectory(quickDir, sessionDir, {
renameFile: async (...args) => {
renameCalls += 1;
if (renameCalls === 2) throw new Error("move failed");
return rename(...args);
},
}),
);
assert.equal(
await readFile(join(quickDir, "active.jsonl"), "utf8"),
"history",
);
await assert.rejects(
promoteQuickSessionDirectory(quickDir, sessionDir, {
makeSymlink: async () => {
throw new Error("symlink failed");
},
}),
);
assert.equal(
await readFile(join(quickDir, "active.jsonl"), "utf8"),
"history",
);
const transaction = await promoteQuickSessionDirectory(quickDir, sessionDir);
await transaction.finalize();
assert.equal(
await readFile(join(sessionDir, "active.jsonl"), "utf8"),
"history",
);
});
test("promotion queues abort and close until identity persistence completes", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-race-"));
const home = join(root, "home");
await mkdir(home);
const fixture = adapters();
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot: join(root, "sessions"),
startAdapter: fixture.startAdapter,
});
await registry.start();
const quick = await registry.createQuickRuntime(home);
const call = fixture.calls.at(-1);
const quickPath = join(call.options.sessionDir, "active.jsonl");
await writeFile(quickPath, "history");
call.adapter.state = { sessionFile: quickPath, sessionId: "session-race" };
const send = call.adapter.send.bind(call.adapter);
let releaseIdentity;
let blockIdentity = true;
call.adapter.send = async (command) => {
if (command.type !== "get_state" || !blockIdentity) return send(command);
return new Promise((resolve) => {
releaseIdentity = () => {
blockIdentity = false;
resolve({
type: "response",
command: "get_state",
success: true,
data: call.adapter.state,
});
};
});
};
const promotion = registry.promoteQuickRuntime(quick.runtimeId);
while (!releaseIdentity) await new Promise((resolve) => setImmediate(resolve));
const abort = registry.route(quick.agentId, "abort");
const close = registry.closeSessionRuntime(quick.runtimeId);
assert.notEqual(call.adapter.sent.at(-1).type, "abort");
releaseIdentity();
await Promise.all([promotion, abort, close]);
assert.ok(call.adapter.sent.some((command) => command.type === "abort"));
assert.equal(call.adapter.stopped, true);
await registry.stop();
});
test("promotion reserves moved JSONL before blocking get_state and releases on rollback", async () => {
const root = await mkdtemp(
join(tmpdir(), "pi-quick-promote-get-state-lease-"),
);
const home = join(root, "home");
const sessionRoot = join(root, "sessions");
await mkdir(home);
const fixture = adapters();
const startAdapter = (options) => {
const adapter = fixture.startAdapter(options);
if (options.sessionPath) adapter.state = { sessionFile: options.sessionPath };
return adapter;
};
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot,
startAdapter,
});
await registry.start();
const quick = await registry.createQuickRuntime(home);
const call = fixture.calls.at(-1);
const quickPath = join(call.options.sessionDir, "active.jsonl");
const promotedPath = join(
sessionDirectoryPath(sessionRoot, home),
"active.jsonl",
);
await writeFile(quickPath, "history");
call.adapter.state = {
sessionFile: quickPath,
sessionId: "session-get-state-lease",
};
call.options.onEvent({ type: "agent_state", data: { state: "idle" } });
while (
!(await registry.getSessionRuntimeSnapshot(quick.runtimeId)).runtime
.sessionPath
)
await new Promise((resolve) => setImmediate(resolve));
const send = call.adapter.send.bind(call.adapter);
let rejectState;
call.adapter.send = async (command) => {
if (command.type !== "get_state") return send(command);
return new Promise((_, reject) => {
rejectState = () => reject(new Error("get_state failed"));
});
};
const promotion = registry.promoteQuickRuntime(quick.runtimeId);
while (!rejectState) await new Promise((resolve) => setImmediate(resolve));
assert.equal((await lstat(call.options.sessionDir)).isSymbolicLink(), true);
assert.equal(await readFile(promotedPath, "utf8"), "history");
const childrenBeforeOpen = fixture.calls.length;
await assert.rejects(
registry.openSessionRuntime(home, promotedPath),
/already open/,
);
assert.equal(fixture.calls.length, childrenBeforeOpen);
rejectState();
await assert.rejects(promotion, /get_state failed/);
await writeFile(promotedPath, "history");
await registry.openSessionRuntime(home, promotedPath);
assert.equal(fixture.calls.length, childrenBeforeOpen + 1);
await registry.stop();
});
test("promotion reserves its canonical JSONL before reference and manifest I/O", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-lease-"));
const home = join(root, "home");
const sessionRoot = join(root, "sessions");
await mkdir(home);
const fixture = adapters();
let quick;
let releasePromotionSave;
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot,
startAdapter: fixture.startAdapter,
workspaceStoreFactory(rootPath) {
const store = createWorkspaceStore(rootPath);
return {
...store,
save(next) {
if (
!next.runtimes.some((runtime) => runtime.runtimeId === quick?.runtimeId)
)
return store.save(next);
return new Promise((resolve, reject) => {
releasePromotionSave = () => store.save(next).then(resolve, reject);
});
},
};
},
});
await registry.start();
quick = await registry.createQuickRuntime(home);
const call = fixture.calls.at(-1);
const quickPath = join(call.options.sessionDir, "active.jsonl");
await writeFile(quickPath, "history");
call.adapter.state = { sessionFile: quickPath, sessionId: "session-lease" };
const promotedPath = join(
sessionDirectoryPath(sessionRoot, home),
"active.jsonl",
);
const promotion = registry.promoteQuickRuntime(quick.runtimeId);
while (!releasePromotionSave)
await new Promise((resolve) => setImmediate(resolve));
await assert.rejects(
registry.openSessionRuntime(home, promotedPath),
/already open/,
);
releasePromotionSave();
await promotion;
await registry.stop();
});
test("concurrent manifest persistence retains promoted runtime for restart", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-persist-"));
const home = join(root, "home");
const sessionRoot = join(root, "sessions");
await mkdir(home);
const fixture = adapters();
let releasePromotionSave;
let holdPromotionSave = true;
let quick;
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot,
startAdapter: fixture.startAdapter,
workspaceStoreFactory(rootPath) {
const store = createWorkspaceStore(rootPath);
return {
...store,
save(next) {
if (
!holdPromotionSave ||
!next.runtimes.some((runtime) => runtime.runtimeId === quick?.runtimeId)
)
return store.save(next);
holdPromotionSave = false;
return new Promise((resolve, reject) => {
releasePromotionSave = () => store.save(next).then(resolve, reject);
});
},
};
},
});
await registry.start();
quick = await registry.createQuickRuntime(home);
const call = fixture.calls.at(-1);
const quickPath = join(call.options.sessionDir, "active.jsonl");
await writeFile(quickPath, "history");
call.adapter.state = { sessionFile: quickPath, sessionId: "session-persist" };
const promotion = registry.promoteQuickRuntime(quick.runtimeId);
while (!releasePromotionSave)
await new Promise((resolve) => setImmediate(resolve));
const concurrentOpen = registry.createSessionRuntime(home);
releasePromotionSave();
await Promise.all([promotion, concurrentOpen]);
const manifest = JSON.parse(
await readFile(join(sessionRoot, "bridge-workspace-v2.json"), "utf8"),
);
assert.ok(
manifest.runtimes.some((runtime) => runtime.runtimeId === quick.runtimeId),
);
await registry.stop();
const restored = adapters();
const originalStart = restored.startAdapter;
restored.startAdapter = (options) => {
const adapter = originalStart(options);
if (options.sessionPath) adapter.state = { sessionFile: options.sessionPath };
return adapter;
};
const restarted = createAgentRegistry({
homeWorktree: home,
sessionRoot,
startAdapter: restored.startAdapter,
});
await restarted.start();
assert.ok(
restarted
.getWorkspace()
.directories.flatMap((directory) => directory.runtimes)
.some(
(runtime) =>
runtime.runtimeId === quick.runtimeId && runtime.state !== "failed",
),
);
await restarted.stop();
});
test("promotion reference and manifest failures roll back quick runtime for retry", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-quick-persist-rollback-"));
const home = join(root, "home");
await mkdir(home);
const fixture = adapters();
const sessionRoot = join(root, "sessions");
const registry = createAgentRegistry({
homeWorktree: home,
sessionRoot,
startAdapter: fixture.startAdapter,
});
await registry.start();
const quick = await registry.createQuickRuntime(home);
const quickDir = fixture.calls.at(-1).options.sessionDir;
await writeFile(join(quickDir, "active.jsonl"), "history");
const sessionDir = sessionDirectoryPath(sessionRoot, home);
const referencePath = join(sessionDir, "bridge-agent.json");
await mkdir(referencePath);
await assert.rejects(registry.promoteQuickRuntime(quick.runtimeId));
assert.equal(
await readFile(join(quickDir, "active.jsonl"), "utf8"),
"history",
);
assert.equal((await lstat(quickDir)).isDirectory(), true);
await rm(referencePath, { recursive: true });
const workspacePath = join(sessionRoot, "bridge-workspace-v2.json");
await rm(workspacePath);
await mkdir(workspacePath);
await assert.rejects(registry.promoteQuickRuntime(quick.runtimeId));
assert.equal(
await readFile(join(quickDir, "active.jsonl"), "utf8"),
"history",
);
assert.equal((await lstat(quickDir)).isDirectory(), true);
await rm(workspacePath, { recursive: true });
assert.equal(
(await registry.promoteQuickRuntime(quick.runtimeId)).runtimeId,
quick.runtimeId,
);
await registry.stop();
});
+9 -3
View File
@@ -18,11 +18,12 @@ test("wires the desktop to stable runtime workspace APIs", async () => {
"close_session_runtime",
"list_directory_sessions",
"get_session_runtime_snapshot",
"renew_session",
"subscribe_workspace",
])
assert.match(`${app}\n${hook}`, new RegExp(operation));
assert.doesNotMatch(app, /switch_session/);
assert.doesNotMatch(app, /new_session/);
assert.doesNotMatch(app, /["']new_session["']/);
assert.match(reducer, /WorkspaceState\["runtimesById"\]/);
assert.match(reducer, /requestGeneration/);
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(css, /prefers-reduced-motion/);
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 () => {
@@ -68,8 +73,9 @@ test("preserves native session naming, compaction, composer, and extension respo
assert.match(app, /respond_to_extension/);
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
assert.match(conversation, /className="todos-pane"/);
assert.match(extension, /role="dialog"/);
assert.match(extension, /aria-modal="true"/);
assert.match(extension, /<dialog/);
assert.match(extension, /showModal\(\)/);
assert.match(extension, /onCancel=/);
});
test("configures native window behavior and folder dialog capabilities", async () => {
+1 -2
View File
@@ -2,9 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
<title>Pi Status UI</title>
</head>
<body>
+1461 -17
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -21,9 +21,10 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
-6
View File
@@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

+3 -2
View File
@@ -1,9 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"description": "Capability for Pi Status windows",
"windows": [
"main"
"main",
"quick-pane"
],
"permissions": [
"core:default",
+41 -152
View File
@@ -1,7 +1,6 @@
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::{json, Value};
use std::env;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@@ -203,6 +202,9 @@ pub async fn get_workspace(socket_path: &str) -> Result<Workspace, String> {
pub async fn get_workspace_summary(socket_path: &str) -> Result<WorkspaceSummary, String> {
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(
socket_path: &str,
worktree_path: &str,
@@ -214,6 +216,28 @@ pub async fn create_session_runtime(
)
.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 promote_quick_runtime(
socket_path: &str,
runtime_id: &str,
) -> Result<RuntimeResult, String> {
typed_request(
socket_path,
"promote_quick_runtime",
Some(json!({ "runtimeId": runtime_id })),
)
.await
}
pub async fn open_session_runtime(
socket_path: &str,
worktree_path: &str,
@@ -237,6 +261,17 @@ pub async fn close_session_runtime(
)
.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(
socket_path: &str,
worktree_path: &str,
@@ -310,72 +345,11 @@ pub async fn new_session(socket_path: &str, agent_id: &str) -> Result<Value, Str
request(socket_path, "new_session", Some(agent_id), None).await
}
fn fallback_transcript(state: &Value) -> Value {
let Some(path) = state.pointer("/data/sessionFile").and_then(Value::as_str) else {
return json!({ "data": { "messages": [] } });
};
let path = Path::new(path);
if !path.is_absolute()
|| path.extension().and_then(|extension| extension.to_str()) != Some("jsonl")
{
return json!({ "data": { "messages": [] } });
}
let messages = std::fs::read_to_string(path)
.ok()
.map(|content| {
content
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("message"))
.filter_map(|entry| entry.get("message").cloned())
.collect::<Vec<_>>()
})
.unwrap_or_default();
json!({ "data": { "messages": messages } })
}
fn compact_commands(value: Value) -> Value {
let commands = value
.pointer("/data/commands")
.and_then(Value::as_array)
.map(|commands| {
commands
.iter()
.map(|command| {
let mut compact = serde_json::Map::new();
for field in ["name", "description"] {
if let Some(value) = command.get(field) {
compact.insert(field.to_owned(), value.clone());
}
}
Value::Object(compact)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
json!({ "data": { "commands": commands } })
}
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, String> {
// Older bridge daemons can still serve the conversation while they await a restart.
let stats = request(socket_path, "get_session_stats", Some(agent_id), None)
.await
.unwrap_or_else(|_| json!({ "data": {} }));
let (state, commands, models) = tokio::try_join!(
request(socket_path, "get_state", Some(agent_id), None),
request(socket_path, "get_commands", Some(agent_id), None),
request(socket_path, "get_available_models", Some(agent_id), None),
)?;
Ok(json!({
"state": state,
"stats": stats,
"transcript": fallback_transcript(&state),
"commands": compact_commands(commands),
"models": models
}))
}
pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) -> Result<Value, String> {
pub async fn submit_prompt(
socket_path: &str,
agent_id: &str,
message: &str,
) -> Result<Value, String> {
request(
socket_path,
"submit_prompt",
@@ -860,91 +834,6 @@ mod tests {
std::fs::remove_file(path).expect("socket cleanup");
}
#[test]
fn reads_messages_from_the_saved_session_file_when_transcript_rpc_is_unavailable() {
let path = format!(
"{}/pi-status-ui-history-{}.jsonl",
env::temp_dir().display(),
std::process::id()
);
std::fs::write(
&path,
concat!(
"{\"type\":\"session\",\"id\":\"session-1\"}\n",
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}\n",
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}\n"
),
)
.expect("session fixture");
let transcript = fallback_transcript(&json!({
"data": { "sessionFile": path }
}));
assert_eq!(
transcript["data"]["messages"],
json!([
{ "role": "user", "content": "Hello" },
{ "role": "assistant", "content": "Hi" }
])
);
std::fs::remove_file(path).expect("session cleanup");
}
#[tokio::test]
async fn loads_state_history_commands_and_models_as_one_snapshot() {
let path = format!(
"{}/pi-status-ui-load-{}.sock",
env::temp_dir().display(),
std::process::id()
);
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).expect("listener");
let server = tokio::spawn(async move {
for _ in 0..4 {
let (stream, _) = listener.accept().await.expect("connection");
tokio::spawn(async move {
let mut line = String::new();
let mut reader = BufReader::new(stream);
reader.read_line(&mut line).await.expect("request");
let request: Value = serde_json::from_str(&line).expect("JSON request");
assert_eq!(request["agentId"], "agent-1");
let result = match request["op"].as_str() {
Some("get_state") => json!({ "data": { "isStreaming": false } }),
Some("get_session_stats") => json!({
"data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } }
}),
Some("get_commands") => json!({ "data": { "commands": [{
"name": "resume",
"description": "Resume a saved session",
"sourceInfo": { "path": "/very/large/extension/metadata" }
}] } }),
Some("get_available_models") => json!({ "data": { "models": [] } }),
other => panic!("unexpected operation: {other:?}"),
};
let mut response = serde_json::to_vec(&json!({
"id": "tauri-ui", "ok": true, "result": result
}))
.expect("response JSON");
response.push(b'\n');
reader
.get_mut()
.write_all(&response)
.await
.expect("response");
});
}
});
let snapshot = load_agent(&path, "agent-1").await.expect("snapshot");
assert_eq!(snapshot["state"]["data"]["isStreaming"], false);
assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000);
assert_eq!(snapshot["transcript"]["data"]["messages"], json!([]));
assert_eq!(
snapshot["commands"]["data"]["commands"],
json!([{ "name": "resume", "description": "Resume a saved session" }])
);
server.await.expect("server succeeds");
std::fs::remove_file(path).expect("socket cleanup");
}
fn runtime(id: &str) -> Value {
json!({
"runtimeId": id,
+425 -44
View File
@@ -2,10 +2,19 @@ mod bridge;
mod ui_state;
use serde_json::{json, Value};
use std::{env, path::Path, sync::{Arc, Mutex}, time::Duration};
use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
use std::{
env,
path::Path,
sync::{Arc, Mutex},
time::Duration,
};
use tauri::{
async_runtime::JoinHandle, AppHandle, Emitter, LogicalSize, Manager, State, WebviewWindow,
WebviewWindowBuilder, WindowEvent,
};
struct LegacySubscription(Mutex<Option<JoinHandle<()>>>);
struct PendingPromotedRuntime(Mutex<Option<String>>);
struct WorkspaceSubscription {
generation: Arc<bridge::SubscriptionGeneration>,
task: Mutex<Option<JoinHandle<()>>>,
@@ -45,6 +54,148 @@ fn socket_path() -> Result<String, String> {
bridge::default_socket_path()
}
fn requested_quick_pane(args: &[String]) -> bool {
args.iter().any(|arg| arg == "--quick")
}
fn quick_pane_action(is_visible: bool) -> WindowAction {
if is_visible {
WindowAction::Hide
} else {
WindowAction::ShowAndFocus
}
}
fn should_hide_close_request(label: &str) -> bool {
label == "main" || label == "quick-pane"
}
fn get_or_create_main_with_visibility(
app: &AppHandle,
visible_when_created: bool,
) -> Result<(WebviewWindow, bool), String> {
if let Some(window) = app.get_webview_window("main") {
return Ok((window, false));
}
let config = app
.config()
.app
.windows
.iter()
.find(|window| window.label == "main")
.ok_or_else(|| "Main window configuration is missing".to_owned())?;
let window = WebviewWindowBuilder::from_config(app, config)
.map_err(|error| error.to_string())?
.visible(visible_when_created)
.build()
.map_err(|error| error.to_string())?;
Ok((window, true))
}
fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
get_or_create_main_with_visibility(app, true)
}
fn apply_main_window_action(app: &AppHandle, args: &[String]) {
let (window, created) = match get_or_create_main(app) {
Ok(result) => result,
Err(error) => {
eprintln!("Could not create main window: {error}");
return;
}
};
let visible = !created && window.is_visible().unwrap_or(false);
match window_action(args, visible) {
WindowAction::Hide => {
let _ = window.hide();
}
WindowAction::ShowAndFocus => {
if let Some(quick_pane) = app.get_webview_window("quick-pane") {
if let Err(error) = quick_pane.hide() {
eprintln!("Could not hide Quick Pane before showing main window: {error}");
return;
}
}
if window.show().is_ok() {
let _ = window.unminimize();
let _ = window.set_focus();
}
}
}
}
fn get_or_create_quick_pane(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
if let Some(window) = app.get_webview_window("quick-pane") {
return Ok((window, false));
}
let config = app
.config()
.app
.windows
.iter()
.find(|window| window.label == "quick-pane")
.ok_or_else(|| "Quick Pane window configuration is missing".to_owned())?;
let window = WebviewWindowBuilder::from_config(app, config)
.map_err(|error| error.to_string())?
.build()
.map_err(|error| error.to_string())?;
Ok((window, true))
}
fn show_main_for_promoted_runtime(app: &AppHandle, runtime_id: String) -> Result<(), String> {
// A recreated Main starts hidden, so Quick is never visible with it.
let (window, _) = get_or_create_main_with_visibility(app, false)?;
let pending = app
.try_state::<PendingPromotedRuntime>()
.ok_or_else(|| "Promoted runtime state is unavailable".to_owned())?;
let mut pending = pending
.0
.lock()
.map_err(|_| "Could not queue promoted runtime".to_owned())?;
if let Some(quick_pane) = app.get_webview_window("quick-pane") {
quick_pane
.hide()
.map_err(|error| format!("Could not hide Quick Pane before promotion: {error}"))?;
}
// Retain this on show failure; a later Main startup can consume it.
*pending = Some(runtime_id);
window
.show()
.map_err(|error| format!("Could not show Main after hiding Quick Pane: {error}"))?;
let _ = window.unminimize();
let _ = window.set_focus();
Ok(())
}
fn toggle_quick_pane(app: &AppHandle) {
let (window, created) = match get_or_create_quick_pane(app) {
Ok(result) => result,
Err(error) => {
eprintln!("Could not create Quick Pane window: {error}");
return;
}
};
let visible = !created && window.is_visible().unwrap_or(false);
match quick_pane_action(visible) {
WindowAction::Hide => {
let _ = window.hide();
}
WindowAction::ShowAndFocus => {
if let Some(main) = app.get_webview_window("main") {
if let Err(error) = main.hide() {
eprintln!("Could not hide main window before showing Quick Pane: {error}");
return;
}
}
if window.show().is_ok() {
let _ = window.unminimize();
let _ = window.set_focus();
let _ = app.emit("quick-pane-shown", ());
}
}
}
}
fn requested_new_worktree_argument(args: &[String]) -> Option<String> {
args.iter()
.position(|arg| arg == "--worktree")
@@ -69,30 +220,52 @@ fn requested_new_worktree(app: &AppHandle, args: &[String]) -> Option<String> {
}
fn launch_new_session(app: AppHandle, args: &[String]) {
let Some(worktree_path) = requested_new_worktree(&app, args) else { return };
let Some(worktree_path) = requested_new_worktree(&app, args) else {
return;
};
tauri::async_runtime::spawn(async move {
// Give a newly-created webview time to register its frontend listeners.
tokio::time::sleep(Duration::from_millis(350)).await;
let _ = app.emit("workspace-new-session", json!({
"phase": "starting",
"detail": "Starting a new Pi session…"
}));
let _ = app.emit(
"workspace-new-session",
json!({
"phase": "starting",
"detail": "Starting a new Pi session…"
}),
);
match socket_path().map(|socket| (socket, worktree_path)) {
Ok((socket, worktree_path)) => match bridge::create_session_runtime(&socket, &worktree_path).await {
Ok(result) => { let _ = app.emit("workspace-new-session", json!({
"phase": "ready",
"runtimeId": result.runtime.runtime_id,
"detail": "New session ready"
})); }
Err(error) => { let _ = app.emit("workspace-new-session", json!({
"phase": "error",
"detail": format!("Could not start session: {error}")
})); }
},
Err(error) => { let _ = app.emit("workspace-new-session", json!({
"phase": "error",
"detail": error
})); }
Ok((socket, worktree_path)) => {
match bridge::create_session_runtime(&socket, &worktree_path).await {
Ok(result) => {
let _ = app.emit(
"workspace-new-session",
json!({
"phase": "ready",
"runtimeId": result.runtime.runtime_id,
"detail": "New session ready"
}),
);
}
Err(error) => {
let _ = app.emit(
"workspace-new-session",
json!({
"phase": "error",
"detail": format!("Could not start session: {error}")
}),
);
}
}
}
Err(error) => {
let _ = app.emit(
"workspace-new-session",
json!({
"phase": "error",
"detail": error
}),
);
}
}
});
}
@@ -107,11 +280,96 @@ async fn get_workspace_summary() -> Result<bridge::WorkspaceSummary, String> {
bridge::get_workspace_summary(&socket_path()?).await
}
#[tauri::command]
async fn get_model_catalog() -> Result<Value, String> {
bridge::get_model_catalog(&socket_path()?).await
}
fn quick_pane_height(height: f64) -> Result<f64, String> {
if !height.is_finite() {
return Err("Quick Pane height must be finite".to_owned());
}
Ok(height.clamp(360.0, 520.0))
}
#[tauri::command]
fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> {
let height = quick_pane_height(height)?;
let quick_pane = app
.get_webview_window("quick-pane")
.ok_or_else(|| "Quick Pane window is unavailable".to_owned())?;
let scale_factor = quick_pane
.scale_factor()
.map_err(|error| error.to_string())?;
let current_size = quick_pane.inner_size().map_err(|error| error.to_string())?;
quick_pane
.set_size(LogicalSize::new(
f64::from(current_size.width) / scale_factor,
height,
))
.map_err(|error| error.to_string())
}
#[tauri::command]
fn dismiss_quick_pane(app: AppHandle) -> Result<(), String> {
app.get_webview_window("quick-pane")
.ok_or_else(|| "Quick Pane window is unavailable".to_owned())?
.hide()
.map_err(|error| error.to_string())
}
#[tauri::command]
async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
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]
async fn promote_quick_runtime(
app: AppHandle,
runtime_id: String,
) -> Result<bridge::RuntimeResult, String> {
let result = bridge::promote_quick_runtime(&socket_path()?, &runtime_id).await?;
show_main_for_promoted_runtime(&app, result.runtime.runtime_id.clone())?;
let _ = app.emit(
"workspace-promoted-runtime",
json!({ "runtimeId": result.runtime.runtime_id }),
);
Ok(result)
}
#[tauri::command]
fn get_pending_promoted_runtime(
pending: State<'_, PendingPromotedRuntime>,
) -> Result<Option<String>, String> {
pending
.0
.lock()
.map(|pending| pending.clone())
.map_err(|_| "Could not read promoted runtime".to_owned())
}
#[tauri::command]
fn consume_pending_promoted_runtime(
runtime_id: String,
pending: State<'_, PendingPromotedRuntime>,
) -> Result<bool, String> {
let mut pending = pending
.0
.lock()
.map_err(|_| "Could not consume promoted runtime".to_owned())?;
if pending.as_deref() == Some(runtime_id.as_str()) {
*pending = None;
Ok(true)
} else {
Ok(false)
}
}
#[tauri::command]
async fn open_session_runtime(
worktree_path: String,
@@ -125,6 +383,11 @@ async fn close_session_runtime(runtime_id: String) -> Result<bridge::CloseRuntim
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]
async fn list_directory_sessions(
worktree_path: String,
@@ -177,11 +440,6 @@ async fn forget_directory(worktree_path: String) -> Result<Value, String> {
bridge::forget_directory(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn load_agent(agent_id: String) -> Result<Value, String> {
bridge::load_agent(&socket_path()?, &agent_id).await
}
#[tauri::command]
async fn list_sessions(agent_id: String) -> Result<Value, String> {
bridge::list_sessions(&socket_path()?, &agent_id).await
@@ -217,6 +475,11 @@ async fn restart(agent_id: String) -> Result<(), String> {
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]
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
@@ -239,7 +502,8 @@ async fn pi_rpc_command(agent_id: String, command: String, input: Value) -> Resu
"pi_rpc_command",
Some(&agent_id),
Some(serde_json::json!({ "command": command, "input": input })),
).await
)
.await
}
#[tauri::command]
@@ -347,34 +611,49 @@ pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(LegacySubscription(Mutex::new(None)))
.manage(PendingPromotedRuntime(Mutex::new(None)))
.manage(WorkspaceSubscription {
generation: Arc::new(bridge::SubscriptionGeneration::default()),
task: Mutex::new(None),
})
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if requested_quick_pane(&args) {
toggle_quick_pane(app);
return;
}
launch_new_session(app.clone(), &args);
if let Some(window) = app.get_webview_window("main") {
match window_action(&args, window.is_visible().unwrap_or(false)) {
WindowAction::Hide => {
let _ = window.hide();
}
WindowAction::ShowAndFocus => {
let _ = window.show();
let _ = window.set_focus();
}
apply_main_window_action(app, &args);
}))
.on_window_event(|window, event| {
if should_hide_close_request(window.label()) {
if let WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
}
}))
})
.setup(move |app| {
launch_new_session(app.handle().clone(), &launch_args);
if requested_quick_pane(&launch_args) {
toggle_quick_pane(app.handle());
} else {
launch_new_session(app.handle().clone(), &launch_args);
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_workspace,
get_workspace_summary,
get_model_catalog,
resize_quick_pane,
dismiss_quick_pane,
create_session_runtime,
create_quick_runtime,
promote_quick_runtime,
get_pending_promoted_runtime,
consume_pending_promoted_runtime,
open_session_runtime,
close_session_runtime,
close_quick_runtime,
list_directory_sessions,
get_session_runtime_snapshot,
load_ui_state,
@@ -385,7 +664,6 @@ pub fn run() {
list_directories,
select_worktree,
forget_directory,
load_agent,
list_sessions,
switch_session,
new_session,
@@ -393,6 +671,7 @@ pub fn run() {
abort,
retry,
restart,
renew_session,
set_model,
set_thinking_level,
set_session_name,
@@ -428,10 +707,112 @@ mod tests {
#[test]
fn new_session_uses_an_explicit_absolute_worktree() {
let args = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "/workspace".into()];
assert_eq!(requested_new_worktree_argument(&args), Some("/workspace".into()));
let relative = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "workspace".into()];
assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None);
let args = vec![
"pi-status-ui".into(),
"--new".into(),
"--worktree".into(),
"/workspace".into(),
];
assert_eq!(
requested_new_worktree_argument(&args),
Some("/workspace".into())
);
let relative = vec![
"pi-status-ui".into(),
"--new".into(),
"--worktree".into(),
"workspace".into(),
];
assert_eq!(
requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()),
None
);
}
#[test]
fn quick_flag_is_detected_and_toggles_its_own_window() {
assert!(requested_quick_pane(&["--quick".to_owned()]));
assert!(!requested_quick_pane(&["--toggle".to_owned()]));
assert_eq!(quick_pane_action(false), WindowAction::ShowAndFocus);
assert_eq!(quick_pane_action(true), WindowAction::Hide);
}
#[test]
fn native_close_requests_hide_managed_windows() {
assert!(should_hide_close_request("main"));
assert!(should_hide_close_request("quick-pane"));
assert!(!should_hide_close_request("other"));
}
#[test]
fn promoted_runtime_pending_id_clears_only_after_matching_acknowledgement() {
let pending = PendingPromotedRuntime(Mutex::new(Some("runtime-a".to_owned())));
let mut value = pending.0.lock().unwrap();
assert_ne!(value.as_deref(), Some("runtime-b"));
assert_eq!(value.as_deref(), Some("runtime-a"));
*value = None;
assert_eq!(value.as_deref(), None);
}
#[test]
fn main_recreation_uses_the_complete_configured_window() {
let context: tauri::Context<tauri::Wry> = tauri::generate_context!();
let main = context
.config()
.app
.windows
.iter()
.find(|window| window.label == "main")
.unwrap();
assert_eq!(main.width, 1100.0);
assert_eq!(main.height, 840.0);
assert_eq!(main.url.to_string(), "index.html");
assert!(main.visible);
assert!(main.transparent);
assert!(main.always_on_top);
assert!(main.skip_taskbar);
}
#[test]
fn quick_pane_recreation_uses_the_complete_configured_window() {
let context: tauri::Context<tauri::Wry> = tauri::generate_context!();
let quick_pane = context
.config()
.app
.windows
.iter()
.find(|window| window.label == "quick-pane")
.unwrap();
assert_eq!(quick_pane.width, 640.0);
assert_eq!(quick_pane.height, 360.0);
assert_eq!(quick_pane.min_width, Some(520.0));
assert_eq!(quick_pane.min_height, Some(360.0));
assert_eq!(quick_pane.url.to_string(), "index.html");
assert!(!quick_pane.visible);
assert!(quick_pane.transparent);
assert!(quick_pane.always_on_top);
assert!(quick_pane.skip_taskbar);
}
#[test]
fn quick_pane_height_stays_within_content_limit() {
assert_eq!(quick_pane_height(200.0), Ok(360.0));
assert_eq!(quick_pane_height(420.0), Ok(420.0));
assert_eq!(quick_pane_height(900.0), Ok(520.0));
}
#[test]
fn quick_pane_resize_rejects_non_finite_height() {
assert_eq!(
quick_pane_height(f64::NAN),
Err("Quick Pane height must be finite".to_owned())
);
assert_eq!(
quick_pane_height(f64::INFINITY),
Err("Quick Pane height must be finite".to_owned())
);
}
#[test]
+126 -1
View File
@@ -12,6 +12,7 @@ const MAX_DRAFT_BYTES: usize = 128 * 1024;
const MAX_TOTAL_DRAFT_BYTES: usize = 1024 * 1024;
const MAX_TOTAL_STRING_BYTES: usize = 1536 * 1024;
const MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
const DEFAULT_THINKING_LEVEL: &str = "medium";
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -37,6 +38,39 @@ pub struct LastSeenState {
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_tool_calls: u32,
#[serde(default = "default_thinking_level")]
pub default_thinking_level: String,
}
fn default_thinking_level() -> String {
DEFAULT_THINKING_LEVEL.to_owned()
}
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 complex work.".to_owned(),
maximum_tool_calls: 6,
default_thinking_level: default_thinking_level(),
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UiStateV1 {
@@ -61,6 +95,8 @@ pub struct UiStateV1 {
pub default_new_session_worktree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dismissed_collision_warning: Option<bool>,
#[serde(default)]
pub quick_pane: QuickPaneSettings,
}
impl Default for UiStateV1 {
@@ -77,6 +113,7 @@ impl Default for UiStateV1 {
interface_scale: None,
default_new_session_worktree: None,
dismissed_collision_warning: None,
quick_pane: QuickPaneSettings::default(),
}
}
}
@@ -182,6 +219,44 @@ pub fn validate(state: &UiStateV1) -> Result<(), String> {
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
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..=64).contains(&quick.maximum_tool_calls) {
return Err("Quick-pane tool-call limit is outside the supported range".to_owned());
}
if !matches!(
quick.default_thinking_level.as_str(),
"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
) {
return Err("Quick-pane default thinking level is invalid".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 !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
return Err("Interface scale is outside the supported range".to_owned());
@@ -217,7 +292,16 @@ pub fn load_from(app_data_dir: &Path) -> Result<UiStateV1, String> {
if content.len() > MAX_FILE_BYTES {
return Err("UI state file exceeds the size limit".to_owned());
}
let state: UiStateV1 = serde_json::from_slice(&content)
let mut value: serde_json::Value = serde_json::from_slice(&content)
.map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?;
// Read legacy persisted cap once, but never retain or serialize it again.
if let Some(quick_pane) = value
.get_mut("quickPane")
.and_then(serde_json::Value::as_object_mut)
{
quick_pane.remove("maximumSeconds");
}
let state: UiStateV1 = serde_json::from_value(value)
.map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?;
validate(&state)?;
Ok(state)
@@ -368,6 +452,47 @@ mod tests {
.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.default_thinking_level = "high".into();
save_to(&root, &state).unwrap();
assert_eq!(load_from(&root).unwrap().quick_pane.default_thinking_level, "high");
state.quick_pane.default_thinking_level = "unsupported".into();
assert!(validate(&state).is_err());
state.quick_pane.default_thinking_level = default_thinking_level();
state.quick_pane.maximum_tool_calls = 0;
assert!(validate(&state).is_err());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn legacy_maximum_seconds_loads_but_is_not_serialized() {
let root = temp_dir("quick-pane-legacy-seconds");
fs::create_dir_all(&root).unwrap();
let path = state_path(&root);
fs::write(
&path,
r#"{"version":1,"quickPane":{"workspace":"","provider":"","model":"","followUpProvider":"","followUpModel":"","instructions":"valid","maximumSeconds":60,"maximumToolCalls":6}}"#,
)
.unwrap();
let state = load_from(&root).unwrap();
assert_eq!(state.quick_pane.default_thinking_level, DEFAULT_THINKING_LEVEL);
save_to(&root, &state).unwrap();
let saved = fs::read_to_string(&path).unwrap();
assert!(!saved.contains("maximumSeconds"));
assert!(saved.contains("\"defaultThinkingLevel\": \"medium\""));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
let root = temp_dir("replace");
+16
View File
@@ -12,6 +12,7 @@
"app": {
"windows": [
{
"label": "main",
"title": "Pi Status UI",
"width": 1100,
"height": 840,
@@ -23,6 +24,21 @@
"alwaysOnTop": true,
"skipTaskbar": true,
"shadow": true
},
{
"label": "quick-pane",
"title": "Pi Quick Pane",
"width": 640,
"height": 360,
"minWidth": 520,
"minHeight": 360,
"center": true,
"visible": false,
"decorations": false,
"transparent": true,
"alwaysOnTop": true,
"skipTaskbar": true,
"shadow": true
}
],
"security": {
+520 -164
View File
@@ -66,16 +66,17 @@ select:focus-visible {
}
.workflow-header {
display: flex;
min-height: 24px;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: grab;
}
.workspace-heading,
.status-row {
.workspace-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
gap: 7px;
}
.brand {
color: #f0a347;
@@ -84,39 +85,17 @@ select:focus-visible {
letter-spacing: 0.16em;
}
.workflow-header h1 {
overflow: hidden;
margin: 0;
font-size: 16px;
font-size: 14px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-row {
margin-top: 1px;
color: #aaa6a0;
.workflow-header > button {
padding: 4px 8px;
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 {
width: 7px;
height: 7px;
@@ -126,17 +105,6 @@ select:focus-visible {
box-shadow: 0 0 0 0 rgba(240, 163, 71, 0.7);
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 {
padding: 6px 8px;
border: 1px solid #8c713c;
@@ -158,6 +126,18 @@ select:focus-visible {
flex: 0 0 auto;
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 {
flex: 1;
min-height: 0;
@@ -244,107 +224,242 @@ select:focus-visible {
gap: 5px;
}
.session-strip {
display: block;
min-width: 0;
}
.session-tab-bar {
display: flex;
align-items: end;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 28px auto;
align-items: center;
gap: 4px;
min-width: 0;
border-bottom: 1px solid #55585d;
padding: 4px;
border: 1px solid #484b50;
border-radius: 9px;
background: #242528;
box-shadow: inset 0 1px rgba(255, 255, 255, 0.025);
}
.session-scroll-button {
display: grid;
place-items: center;
width: 28px;
height: 44px;
padding: 0;
border: 1px solid #4b4e53;
border-radius: 7px;
background: #303237;
color: #c8ccd3;
font-size: 21px;
line-height: 1;
}
.session-scroll-button:hover:not(:disabled) {
border-color: #8a6339;
background: #3d372f;
color: #ffc170;
}
.session-scroll-button:disabled {
opacity: 0.25;
}
.session-tabs {
min-width: 0;
flex: 1;
display: flex;
min-width: 0;
gap: 5px;
overflow-x: auto;
gap: 2px;
padding-left: 4px;
overflow-y: hidden;
padding: 2px;
scroll-behavior: smooth;
scroll-padding-inline: 2px;
scroll-snap-type: x proximity;
scrollbar-color: #696057 transparent;
scrollbar-width: thin;
}
.session-tabs::-webkit-scrollbar {
height: 5px;
}
.session-tabs::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #696057;
}
.session-tab {
display: flex;
flex: 0 0 auto;
display: grid;
grid-template-columns: minmax(0, 1fr) 27px 27px;
width: 188px;
height: 44px;
flex: 0 0 188px;
overflow: hidden;
scroll-snap-align: start;
border: 1px solid #4d5055;
border-radius: 7px;
background: #303237;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22);
}
.session-tab:hover,
.session-tab:focus-within {
border-color: #696d73;
background: #35373c;
}
.session-tab.selected {
border-color: #d88735;
background: #3c362f;
box-shadow:
inset 3px 0 #f0a347,
0 0 0 1px rgba(240, 163, 71, 0.12);
}
.session-tab > button[role="tab"] {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 124px;
max-width: 190px;
padding: 6px 8px 5px;
border: 1px solid #555;
border-bottom: 0;
border-radius: 8px 8px 0 0;
background: #303136;
min-width: 0;
align-content: center;
gap: 3px;
padding: 5px 7px 5px 10px;
border: 0;
border-radius: 0;
background: transparent;
color: #d5d7dc;
text-align: left;
}
.session-tab.selected > button[role="tab"] {
border-color: #d88735;
background: #3b3935;
color: #fff5e7;
box-shadow: inset 0 2px #f0a347;
.session-tab > button[role="tab"]:hover {
background: rgba(255, 255, 255, 0.025);
}
.session-tab > button span {
.session-tab.selected > button[role="tab"] {
color: #fff5e7;
}
.session-tab-label {
overflow: hidden;
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-tab small {
grid-row: 2;
color: #788077;
font-size: 10px;
.session-tab-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 5px;
}
.session-tab small.streaming,
.session-tab small.recovering {
color: #ffc170;
.session-tab-meta small {
overflow: hidden;
color: #9299a2;
font-size: 9px;
letter-spacing: 0.035em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.session-state-dot {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: #737a75;
}
.session-state-dot.streaming,
.session-state-dot.working,
.session-state-dot.recovering {
background: #f0a347;
box-shadow: 0 0 6px rgba(240, 163, 71, 0.7);
}
.session-state-dot.error,
.session-state-dot.failed {
background: #e47682;
}
.renew-session,
.close-session {
display: grid;
place-items: center;
width: 27px;
min-width: 0;
border: 0;
border-left: 1px solid #45484d;
border-radius: 0;
padding: 0;
background: rgba(18, 19, 21, 0.2);
color: #aeb3bb;
}
.renew-session {
color: #9bcfbe;
}
.renew-session-icon {
width: 14px;
height: 14px;
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 {
align-self: stretch;
border: 1px solid #505257;
border-bottom: 0;
border-left: 0;
border-radius: 0 8px 0 0;
padding: 4px 8px;
background: #303136;
color: #b8bbc1;
font-size: 16px;
}
.session-tab.selected .renew-session,
.session-tab.selected .close-session {
border-left-color: #594d40;
color: #f3deca;
}
.renew-session:hover:not(:disabled) {
background: #294139;
color: #c8ffef;
}
.close-session:hover:not(:disabled) {
background: #4a3131;
color: #ffd7db;
}
.unread-dot {
width: 7px;
height: 7px;
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: #f0a347;
}
.attention-dot {
color: #ffcb6b;
font-size: 10px;
}
.session-tab-actions {
display: flex;
gap: 3px;
padding: 0 0 2px 5px;
align-self: stretch;
gap: 4px;
padding-left: 2px;
border-left: 1px solid #424449;
}
.session-tab-actions button {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 44px;
white-space: nowrap;
border: 1px solid #55585d;
border-bottom: 0;
border-radius: 6px 6px 0 0;
padding: 5px 8px;
background: #383a40;
color: #e2e4e8;
border: 1px solid #51545a;
border-radius: 7px;
padding: 0 9px;
background: #303237;
color: #dde0e5;
}
.session-tab-actions button:hover {
background: #4a4540;
.session-tab-actions button:hover:not(:disabled) {
border-color: #8a6339;
background: #3d372f;
color: #ffc170;
}
.session-tab-actions .new-session {
border-color: #d88735;
background: #4a3827;
.session-tab-actions .new-session-tab {
border-color: #9a6938;
background: #443526;
color: #ffc170;
}
.session-tab-actions span {
margin-right: 5px;
font-size: 15px;
line-height: 0;
.session-tab-actions button > span:first-child {
font-size: 16px;
line-height: 1;
}
.session-action-label {
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.directory-add {
display: flex;
@@ -356,18 +471,68 @@ select:focus-visible {
padding: 7px;
}
.workflow {
position: relative;
flex: 1;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
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 {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 190px;
gap: 5px;
}
.transcript-container {
position: relative;
min-width: 0;
min-height: 0;
}
.transcript {
height: 100%;
}
.transcript,
.todos-pane,
.settings,
@@ -385,6 +550,33 @@ select:focus-visible {
inset 3px 0 #f0a347,
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 {
display: flex;
align-items: center;
@@ -489,30 +681,62 @@ select:focus-visible {
font-size: 13px;
line-height: 1.32;
}
.tool-result summary {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
list-style: none;
.tool-result-visible > strong,
.tool-result-label {
color: #a9f2df;
font-size: 10px;
font-weight: 700;
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 {
display: none;
}
.tool-result summary::after {
grid-column: 2;
grid-row: 1;
content: "▾";
color: #a9f2df;
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 {
content: "▴";
}
.tool-result[open] summary {
margin-bottom: 6px;
}
.tool-result[open] .tool-result-preview,
.tool-result[open] .tool-result-hidden-hint {
display: none;
}
.pending-message {
border-style: dashed;
}
@@ -566,66 +790,111 @@ select:focus-visible {
border-radius: 5px;
background: #303030;
}
.pi-controls label {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px;
align-items: center;
font-size: 10px;
}
.pi-controls select {
.session-status-strip {
display: flex;
min-width: 0;
appearance: none;
color-scheme: dark;
padding: 5px 25px 5px 6px;
border: 1px solid #70522f;
border-radius: 4px;
background: #2c2a27
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
no-repeat right 7px center;
color: #f2ece4;
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;
}
.pi-controls select:hover {
border-color: #d88735;
background-color: #383129;
}
.pi-controls select:focus {
border-color: #f0a347;
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
}
.pi-controls select option {
background: #2c2a27;
color: #f2ece4;
}
.pi-controls select option:checked {
background: #4b3420;
color: #fff2df;
}
.composer-progress {
.session-status-primary,
.session-status-meta,
.status-chip {
display: flex;
align-items: center;
}
.session-status-primary {
min-width: 0;
flex: 1;
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 {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
background: #687168;
}
.composer-progress.working {
padding: 4px 6px;
border-left: 3px solid #f0a347;
background: #3b3025;
color: #ffd39a;
font-weight: 700;
.session-status-strip.working {
border-color: #60492f;
border-left-color: #f0a347;
background: #332d26;
}
.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;
box-shadow: 0 0 8px #f0a347;
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 {
display: flex;
gap: 7px;
@@ -654,6 +923,25 @@ select:focus-visible {
display: flex;
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 {
display: grid;
gap: 4px;
@@ -694,6 +982,56 @@ select:focus-visible {
color: #b9afa4;
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,
.quick-pane-model-setting {
display: grid;
gap: 4px;
color: #ddd8d0;
font-size: 12px;
font-weight: 700;
}
.quick-pane-model-setting .model-control {
grid-template-columns: 120px minmax(0, 1fr);
font-size: 12px;
}
.quick-pane-model-setting .model-menu {
width: min(400px, calc(100vw - 80px));
}
.quick-pane-model-setting small {
color: #b9afa4;
font-weight: 400;
}
.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 {
padding: 5px 8px;
border-left: 3px solid #f0a347;
@@ -742,17 +1080,14 @@ select:focus-visible {
flex: 0 0 auto;
transform: scale(1.35);
}
.modal-backdrop,
.extension-backdrop {
position: absolute;
z-index: 10;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
dialog.session-picker::backdrop,
dialog.extension::backdrop {
background: rgba(0, 0, 0, 0.62);
}
dialog.session-picker:not([open]),
dialog.extension:not([open]) {
display: none;
}
.session-picker,
.extension {
width: min(680px, 90vw);
@@ -760,10 +1095,12 @@ select:focus-visible {
overflow: auto;
display: grid;
gap: 8px;
margin: auto;
padding: 15px;
border: 1px solid #b76f26;
border-radius: 10px;
background: #2d2e31;
color: #f1f3f4;
}
.session-picker {
overflow: hidden;
@@ -820,6 +1157,9 @@ select:focus-visible {
min-width: 0;
gap: 3px;
}
.session-row-content > strong {
color: #f1f3f4;
}
.session-preview,
.session-metadata {
overflow: hidden;
@@ -883,6 +1223,11 @@ select:focus-visible {
.notification {
padding: 6px;
}
@keyframes tab-action-spin {
to {
transform: rotate(360deg);
}
}
@keyframes work-pulse {
70% {
box-shadow: 0 0 0 7px rgba(240, 163, 71, 0);
@@ -915,11 +1260,22 @@ select:focus-visible {
.workflow-main {
grid-template-columns: minmax(0, 1fr) 165px;
}
.session-strip-actions {
flex-direction: column;
.session-tab-bar {
grid-template-columns: 26px minmax(0, 1fr) 26px auto;
}
.session-tab > button[role="tab"] {
min-width: 125px;
.session-scroll-button {
width: 26px;
}
.session-tab {
width: 164px;
flex-basis: 164px;
}
.session-tab-actions button {
width: 34px;
padding: 0;
}
.session-action-label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
+1297
View File
File diff suppressed because it is too large Load Diff
+850 -141
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

+106 -114
View File
@@ -1,17 +1,18 @@
import { useEffect, useRef, useState } from "react";
import type { RpcCommand, RpcField } from "../commands/rpc";
const controls = (container: HTMLElement | null) =>
Array.from(
container?.querySelectorAll<HTMLElement>(
"button, input, select, textarea",
) ?? [],
).filter((node) => !node.hasAttribute("disabled"));
type RpcInputValue =
| string
| boolean
| null
| RpcInputValue[]
| { [key: string]: RpcInputValue }
| undefined;
function valueFor(field: RpcField, value: string | boolean): unknown {
function valueFor(field: RpcField, value: string | boolean): RpcInputValue {
if (field.kind === "boolean") return value === true;
if (field.kind === "json")
return value ? JSON.parse(value as string) : undefined;
return value ? (JSON.parse(value as string) as RpcInputValue) : undefined;
return value || undefined;
}
@@ -19,31 +20,32 @@ export function CommandFormDialog({
command,
onSubmit,
onCancel,
pending = false,
commandError,
}: {
command?: RpcCommand;
onSubmit: (input: Record<string, unknown>) => void;
onCancel: () => void;
pending?: boolean;
commandError?: string;
}) {
const ref = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null);
const ref = useRef<HTMLDialogElement>(null);
const [values, setValues] = useState<Record<string, string | boolean>>({});
const [error, setError] = useState<string>();
const [validationError, setValidationError] = useState<string>();
useEffect(() => {
if (!command) return;
opener.current = document.activeElement as HTMLElement | null;
setValues({});
setError(undefined);
const frame = requestAnimationFrame(() =>
controls(ref.current)[0]?.focus(),
);
setValidationError(undefined);
const dialog = ref.current;
dialog?.showModal();
return () => {
cancelAnimationFrame(frame);
opener.current?.focus();
if (dialog?.open) dialog.close();
};
}, [command]);
if (!command) return null;
const fields = command.input ?? [];
const submit = () => {
if (pending) return;
try {
const input: Record<string, unknown> = {};
for (const field of fields) {
@@ -57,104 +59,94 @@ export function CommandFormDialog({
}
onSubmit(input);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Invalid input");
setValidationError(cause instanceof Error ? cause.message : "Invalid input");
}
};
return (
<div className="extension-backdrop">
<section
ref={ref}
className="extension"
role="dialog"
aria-modal="true"
aria-labelledby="command-form-title"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
if (event.key === "Tab") {
const items = controls(ref.current),
first = items[0],
last = items[items.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}}
>
<p className="extension-eyebrow">Pi RPC command</p>
<h2 id="command-form-title">/{command.command}</h2>
<p>{command.description}</p>
{fields.map((field) => (
<label key={field.name}>
{field.label}
{field.kind === "boolean" ? (
<input
aria-label={field.label}
type="checkbox"
checked={values[field.name] === true}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.checked,
})
}
/>
) : field.kind === "enum" ? (
<select
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
>
<option value="">Select…</option>
{field.options?.map((option) => (
<option key={option}>{option}</option>
))}
</select>
) : field.kind === "json" ? (
<textarea
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
/>
) : (
<input
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
/>
)}
</label>
))}
{error && <p role="alert">{error}</p>}
<div className="extension-options">
<button onClick={submit}>Run command</button>
<button className="quiet" onClick={onCancel}>
Cancel
</button>
</div>
</section>
</div>
<dialog
ref={ref}
className="extension"
aria-labelledby="command-form-title"
aria-busy={pending}
onCancel={(event) => {
event.preventDefault();
onCancel();
}}
>
<p className="extension-eyebrow">Pi RPC command</p>
<h2 id="command-form-title">/{command.command}</h2>
<p>{command.description}</p>
{fields.map((field) => (
<label key={field.name}>
{field.label}
{field.kind === "boolean" ? (
<input
aria-label={field.label}
type="checkbox"
checked={values[field.name] === true}
disabled={pending}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.checked,
})
}
/>
) : field.kind === "enum" ? (
<select
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
disabled={pending}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
>
<option value="">Select…</option>
{field.options?.map((option) => (
<option key={option}>{option}</option>
))}
</select>
) : field.kind === "json" ? (
<textarea
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
disabled={pending}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
/>
) : (
<input
aria-label={field.label}
value={(values[field.name] as string) ?? ""}
disabled={pending}
onChange={(event) =>
setValues({
...values,
[field.name]: event.currentTarget.value,
})
}
/>
)}
</label>
))}
{(validationError || commandError) && (
<p role="alert">{validationError || commandError}</p>
)}
<div className="extension-options">
<button disabled={pending} onClick={submit}>
{pending ? "Running…" : "Run command"}
</button>
<button className="quiet" disabled={pending} onClick={onCancel}>
Cancel
</button>
</div>
</dialog>
);
}
+305 -103
View File
@@ -2,6 +2,16 @@ import { useEffect, useMemo, useRef } from "react";
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
import { CommandPalette } from "./CommandPalette";
import { ModelSelector } from "./ModelSelector";
import { ThinkingSelector } from "./ThinkingSelector";
type SessionStatus = {
label: string;
detail: string;
phase: RuntimeView["progress"]["phase"];
bridge: string;
extensions: string[];
};
export function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content;
@@ -15,21 +25,77 @@ export function messageText(message: RuntimeView["messages"][number]) {
.filter(Boolean)
.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({
message,
}: {
message: RuntimeView["messages"][number];
}) {
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 (
<article className={`message ${role}`}>
<details className="tool-result">
<summary>Tool result · {message.toolName ?? "tool"}</summary>
<pre>{messageText(message)}</pre>
<summary>
<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>
</article>
);
}
return (
<article className={`message ${role}`}>
<strong>{message.role ?? "message"}</strong>
@@ -75,8 +141,13 @@ export function ConversationWorkspace({
onScroll,
onModel,
onThinking,
focusRequest = 0,
isSubmitting = false,
isAborting = false,
isModelUpdating = false,
locked = false,
lockReason = "renewing",
status,
onCommand,
}: {
runtime: RuntimeView;
@@ -84,90 +155,163 @@ export function ConversationWorkspace({
onSubmit: () => void;
onAbort: () => void;
onScroll: (atBottom: boolean, scrollTop: number) => void;
onModel: (model: Model) => void;
onThinking: (level: string) => void;
onModel: (model: Model) => Promise<boolean>;
onThinking: (level: string) => Promise<boolean>;
focusRequest?: number;
isSubmitting?: boolean;
isAborting?: boolean;
isModelUpdating?: boolean;
locked?: boolean;
lockReason?: "renewing" | "restarting";
status?: SessionStatus;
onCommand: (command: RpcCommand) => void;
}) {
const ref = useRef<HTMLElement>(null);
const promptRef = useRef<HTMLTextAreaElement>(null);
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
const runtimeIsWorking =
runtime.summary.state === "streaming" ||
runtime.progress.phase === "working";
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
const isWorking =
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 selectedThinkingModel = runtime.models.find(
(model) =>
model.provider === runtime.state.model?.provider &&
model.id === runtime.state.model?.id,
);
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(() => {
if (runtime.scroll.atBottom && ref.current)
ref.current.scrollTop = ref.current.scrollHeight;
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
}, [
runtime.messages,
runtime.pendingSubmissions,
runtime.scroll.atBottom,
isWorking,
runtime.progress.detail,
runtime.progress.tool,
]);
useEffect(() => {
if (focusRequest > 0 && !isSubmitting && !locked) promptRef.current?.focus();
}, [focusRequest, isSubmitting, locked]);
const scrollToLatest = () => {
const transcript = ref.current;
if (!transcript) return;
transcript.scrollTop = transcript.scrollHeight;
onScroll(true, transcript.scrollTop);
transcript.focus();
};
return (
<div
className="workflow"
id="session-workspace"
role="tabpanel"
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
aria-busy={locked}
>
<div className="workflow-main">
<section
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
aria-label="Conversation"
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
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">
<i />
<i />
<i />
</span>
<div>
<strong>Pi is working</strong>
<span>
{isSubmitting
? "Sending your prompt…"
: runtime.progress.detail}
<div className="workflow-main" inert={locked ? true : undefined}>
<div className="transcript-container">
<section
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
aria-label="Conversation"
ref={ref}
tabIndex={-1}
onScroll={(event) => {
const node = event.currentTarget;
onScroll(
node.scrollHeight - node.scrollTop - node.clientHeight <= 4,
node.scrollTop,
);
}}
>
{runtime.loading && !runtime.messages.length && (
<p className="muted">Loading session…</p>
)}
{!runtime.loading && !runtime.messages.length && (
<p className="muted">No messages yet.</p>
)}
{runtime.messages.map((message, index) => (
<TranscriptMessage key={index} message={message} />
))}
{runtime.pendingSubmissions.map((pending) => (
<article
className={`message user pending-message ${pending.phase}`}
key={pending.id}
>
<strong>
You <span>{pendingStatus(pending)}</span>
</strong>
<pre>{pending.text}</pre>
</article>
))}
{isWorking && (
<div className="agent-working" role="status" aria-live="polite">
<span className="working-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
{runtime.progress.tool && (
<small>Using {runtime.progress.tool}</small>
)}
<div>
<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>
)}
{runtime.loading && !runtime.messages.length && (
<p className="muted">Loading session…</p>
)}
{!runtime.loading && !runtime.messages.length && (
<p className="muted">No messages yet.</p>
)}
{runtime.messages.map((message, index) => (
<TranscriptMessage key={index} message={message} />
))}
{runtime.pendingSubmissions.map((pending) => (
<article
className={`message user pending-message ${pending.phase}`}
key={pending.id}
)}
</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>
You <span>{pendingStatus(pending)}</span>
</strong>
<pre>{pending.text}</pre>
</article>
))}
</section>
<span aria-hidden="true">↓</span>
</button>
)}
</div>
<aside className="todos-pane" aria-label="Current todos">
<div className="todos-heading">
<strong>Todos</strong>
<span>
{tasks.filter((task) => task.status !== "completed").length}
</span>
<span>{tasks.filter((task) => task.status !== "completed").length}</span>
</div>
{tasks.length ? (
<ol className="todo-list">
@@ -186,7 +330,7 @@ export function ConversationWorkspace({
)}
</aside>
</div>
<footer className="workflow-footer">
<footer className="workflow-footer" inert={locked ? true : undefined}>
{Object.values(runtime.extensionWidgets)
.flatMap((widget) => widget.lines)
.map((line, index) => (
@@ -197,51 +341,85 @@ export function ConversationWorkspace({
<section
className="pi-controls"
aria-label="Model and thinking controls"
aria-busy={isModelUpdating || locked}
>
<label>
Model
<select
value={`${runtime.state.model?.provider ?? ""}/${runtime.state.model?.id ?? ""}`}
onChange={(event) => {
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>
Thinking
<select
value={runtime.state.thinkingLevel ?? "medium"}
onChange={(event) => onThinking(event.currentTarget.value)}
>
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
(level) => (
<option key={level}>{level}</option>
),
)}
</select>
</label>
<ModelSelector
models={runtime.models}
current={runtime.state.model}
onSelect={onModel}
pending={isModelUpdating || locked}
/>
<ThinkingSelector
model={selectedThinkingModel ?? runtime.state.model}
value={runtime.state.thinkingLevel ?? "medium"}
disabled={isModelUpdating || locked}
onChange={(level) => void onThinking(level)}
/>
</section>
<div
className={`composer-progress ${isSubmitting ? "working" : runtime.progress.phase}`}
className={`session-status-strip ${presentedStatus.phase}`}
role="status"
aria-label="Session status"
aria-live="polite"
>
<span className="work-dot" />
<span>
{isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail}
<span className="session-status-primary">
<span className="work-dot" aria-hidden="true" />
<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>
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
</div>
<div className="composer">
<CommandPalette
@@ -251,8 +429,9 @@ export function ConversationWorkspace({
/>
<textarea
aria-label="Prompt"
ref={promptRef}
value={runtime.draft}
disabled={isSubmitting}
disabled={isSubmitting || locked}
onChange={(event) => onDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
@@ -267,7 +446,7 @@ export function ConversationWorkspace({
<button
type="button"
className="quiet"
disabled={isAborting}
disabled={isAborting || locked}
onClick={onAbort}
>
{isAborting ? "Aborting…" : "Abort"}
@@ -276,6 +455,7 @@ export function ConversationWorkspace({
<button
type="button"
disabled={
locked ||
isSubmitting ||
!runtime.summary.agentId ||
!runtime.draft.trim()
@@ -287,6 +467,28 @@ export function ConversationWorkspace({
</div>
</div>
</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>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
const fs = await import(["node", "fs"].join(":"));
const modelCss = fs.readFileSync("src/components/ModelSelector.css", "utf8");
const quickPaneCss = fs.readFileSync("src/quick-pane/quick-pane.css", "utf8");
describe("dropdown CSS", () => {
it("keeps regular model menu capped with scrolling results", () => {
expect(modelCss).toMatch(
/\.model-menu\s*\{[\s\S]*max-height:\s*min\(420px, calc\(100vh - 80px\)\)/,
);
expect(modelCss).toMatch(/\.model-results\s*\{[\s\S]*overflow-y:\s*auto/);
expect(modelCss).toMatch(/\.model-option\[aria-selected="true"\]/);
});
it("caps compact menus to Quick Pane container below titlebar and toolbar", () => {
expect(quickPaneCss).toMatch(
/\.quick-pane\s*\{[\s\S]*container-type:\s*size/,
);
expect(quickPaneCss).toMatch(
/\.quick-pane \.model-menu\s*\{[\s\S]*max-height:\s*min\(350px, calc\(100cqh - 74px\)\)/,
);
expect(quickPaneCss).toMatch(
/\.quick-pane \.model-menu\s*\{[\s\S]*width:\s*min\(400px, calc\(100cqw - 24px\)\)/,
);
});
});
+90 -128
View File
@@ -1,137 +1,99 @@
import { useEffect, useRef, useState } from "react";
import type { Extension } from "../workspace/types";
function controls(container: HTMLElement | null) {
return [
...(container?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
) ?? []),
];
}
export function ExtensionDialog({
extension,
onRespond,
extension,
onRespond,
}: {
extension?: Extension;
onRespond: (response: Record<string, unknown>) => void;
extension?: Extension;
onRespond: (response: Record<string, unknown>) => void;
}) {
const ref = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null);
const respond = useRef(onRespond);
const [value, setValue] = useState("");
respond.current = onRespond;
const ref = useRef<HTMLDialogElement>(null);
const [value, setValue] = useState("");
useEffect(() => {
if (!extension) return;
opener.current = document.activeElement as HTMLElement | null;
setValue(
extension.prefill ?? extension.initialValue ?? extension.text ?? "",
);
const frame = requestAnimationFrame(() =>
controls(ref.current)[0]?.focus(),
);
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
respond.current({ cancelled: true });
return;
}
if (event.key !== "Tab") return;
const focusable = controls(ref.current);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", keydown);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("keydown", keydown);
opener.current?.focus();
};
}, [extension]);
useEffect(() => {
if (!extension) return;
setValue(
extension.prefill ?? extension.initialValue ?? extension.text ?? "",
);
const dialog = ref.current;
dialog?.showModal();
return () => {
if (dialog?.open) dialog.close();
};
}, [extension]);
if (!extension) return null;
return (
<div className="extension-backdrop">
<section
ref={ref}
className="extension"
role="dialog"
aria-modal="true"
aria-labelledby="extension-title"
>
<p className="extension-eyebrow">Pi agent request</p>
<h2 id="extension-title">Review request</h2>
<div className="extension-request">
<p className="extension-request-label">Requested action</p>
<p className="extension-request-action">
{extension.title ?? "Pi needs input"}
</p>
{extension.message && (
<>
<p className="extension-request-label">Request details</p>
<p className="extension-request-details">{extension.message}</p>
</>
)}
</div>
{extension.method === "select" && (
<div className="extension-options">
{extension.options?.map((option) => (
<button key={option} onClick={() => onRespond({ value: option })}>
{option}
</button>
))}
</div>
)}
{extension.method === "confirm" && (
<div className="extension-options">
<button onClick={() => onRespond({ confirmed: true })}>
Allow request
</button>
<button
className="quiet"
onClick={() => onRespond({ confirmed: false })}
>
Decline request
</button>
</div>
)}
{extension.method === "input" && (
<>
<input
aria-label="Extension value"
value={value}
placeholder={extension.placeholder}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<button onClick={() => onRespond({ value })}>Submit</button>
</>
)}
{extension.method === "editor" && (
<>
<textarea
aria-label="Extension value"
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<button onClick={() => onRespond({ value })}>Submit</button>
</>
)}
<button
className="quiet"
onClick={() => onRespond({ cancelled: true })}
>
Cancel
</button>
</section>
</div>
);
if (!extension) return null;
return (
<dialog
ref={ref}
className="extension"
aria-labelledby="extension-title"
onCancel={(event) => {
event.preventDefault();
onRespond({ cancelled: true });
}}
>
<p className="extension-eyebrow">Pi agent request</p>
<h2 id="extension-title">Review request</h2>
<div className="extension-request">
<p className="extension-request-label">Requested action</p>
<p className="extension-request-action">
{extension.title ?? "Pi needs input"}
</p>
{extension.message && (
<>
<p className="extension-request-label">Request details</p>
<p className="extension-request-details">{extension.message}</p>
</>
)}
</div>
{extension.method === "select" && (
<div className="extension-options">
{extension.options?.map((option) => (
<button key={option} onClick={() => onRespond({ value: option })}>
{option}
</button>
))}
</div>
)}
{extension.method === "confirm" && (
<div className="extension-options">
<button onClick={() => onRespond({ confirmed: true })}>
Allow request
</button>
<button
className="quiet"
onClick={() => onRespond({ confirmed: false })}
>
Decline request
</button>
</div>
)}
{extension.method === "input" && (
<>
<input
aria-label="Extension value"
value={value}
placeholder={extension.placeholder}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<button onClick={() => onRespond({ value })}>Submit</button>
</>
)}
{extension.method === "editor" && (
<>
<textarea
aria-label="Extension value"
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
/>
<button onClick={() => onRespond({ value })}>Submit</button>
</>
)}
<button className="quiet" onClick={() => onRespond({ cancelled: true })}>
Cancel
</button>
</dialog>
);
}
+132
View File
@@ -0,0 +1,132 @@
.model-control {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px;
align-items: center;
min-width: 0;
font-size: 10px;
}
.model-selector {
position: relative;
min-width: 0;
}
.model-trigger {
box-sizing: border-box;
width: 100%;
min-width: 0;
overflow: hidden;
appearance: none;
padding: 5px 25px 5px 6px;
border: 1px solid #70522f;
border-radius: 4px;
background: #2c2a27
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
no-repeat right 7px center;
color: #f2ece4;
font: inherit;
font-weight: 650;
text-align: left;
}
.model-trigger:hover {
border-color: #d88735;
background-color: #383129;
}
.model-trigger:focus {
border-color: #f0a347;
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
}
.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;
grid-template-rows: auto auto minmax(0, 1fr);
box-sizing: border-box;
width: min(400px, calc(100vw - 40px));
max-height: min(420px, calc(100vh - 80px));
overflow: hidden;
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-control-down .model-menu {
top: calc(100% + 6px);
bottom: auto;
}
.model-menu input {
box-sizing: border-box;
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-x: hidden;
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;
font: inherit;
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;
}
+145
View File
@@ -0,0 +1,145 @@
import { fireEvent, 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("notifies menu open and every close path", async () => {
const user = userEvent.setup();
const onOpen = vi.fn();
const onClose = vi.fn();
const { unmount } = render(
<ModelSelector
models={models}
onSelect={vi.fn().mockResolvedValue(true)}
onOpen={onOpen}
onClose={onClose}
/>,
);
const trigger = screen.getByRole("button", { name: /Current model/i });
await user.click(trigger);
expect(onOpen).toHaveBeenCalledTimes(1);
fireEvent.pointerDown(document.body);
expect(onClose).toHaveBeenCalledTimes(1);
await user.click(trigger);
await user.keyboard("{Escape}");
expect(onOpen).toHaveBeenCalledTimes(2);
expect(onClose).toHaveBeenCalledTimes(2);
await user.click(trigger);
await user.click(screen.getByRole("option", { name: /Claude Sonnet/ }));
expect(onOpen).toHaveBeenCalledTimes(3);
expect(onClose).toHaveBeenCalledTimes(3);
await user.click(trigger);
unmount();
expect(onOpen).toHaveBeenCalledTimes(4);
expect(onClose).toHaveBeenCalledTimes(4);
});
it("supports labeled downward placement for toolbar and Settings reuse", () => {
const { container } = render(
<ModelSelector
models={models}
current={{ provider: "openai", id: "organization/gpt-5" }}
label="Quick model"
placement="down"
onSelect={vi.fn().mockResolvedValue(true)}
/>,
);
expect(
screen.getByRole("button", { name: /Quick model openai · GPT 5/i }),
).toBeInTheDocument();
expect(container.querySelector(".model-control-down")).toBeInTheDocument();
});
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();
});
});
+278
View File
@@ -0,0 +1,278 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { AgentState, Model } from "../workspace/types";
import "./ModelSelector.css";
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,
disabled = false,
label = "Model",
placement = "up",
className = "",
triggerClassName = "",
onOpen,
onClose,
}: {
models: Model[];
current?: AgentState["model"];
onSelect: (model: Model) => Promise<boolean>;
pending?: boolean;
disabled?: boolean;
label?: string;
placement?: "up" | "down";
className?: string;
triggerClassName?: string;
onOpen?: () => void;
onClose?: () => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [selecting, setSelecting] = useState(false);
const openRef = useRef(false);
const onOpenRef = useRef(onOpen);
const onCloseRef = useRef(onClose);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
onOpenRef.current = onOpen;
onCloseRef.current = onClose;
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;
const openMenu = () => {
if (openRef.current) return;
openRef.current = true;
setQuery("");
setOpen(true);
onOpenRef.current?.();
};
const closeMenu = (restoreFocus = false) => {
if (!openRef.current) return;
openRef.current = false;
setOpen(false);
onCloseRef.current?.();
if (restoreFocus)
window.requestAnimationFrame(() => triggerRef.current?.focus());
};
useEffect(() => {
return () => {
if (!openRef.current) return;
openRef.current = false;
onCloseRef.current?.();
};
}, []);
useEffect(() => {
if (!open) return;
const focusFrame = window.requestAnimationFrame(() =>
searchRef.current?.focus(),
);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeMenu(true);
};
const closeOutside = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) closeMenu();
};
window.addEventListener("keydown", closeOnEscape);
window.addEventListener("pointerdown", closeOutside);
return () => {
window.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;
closeMenu(true);
} finally {
setSelecting(false);
}
};
return (
<div
className={`model-control model-control-${placement} ${className}`.trim()}
aria-busy={busy}
>
<span id={labelId}>{label}</span>
<div className="model-selector" ref={rootRef}>
<button
ref={triggerRef}
type="button"
className={["model-trigger", triggerClassName].filter(Boolean).join(" ")}
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={open ? menuId : undefined}
aria-labelledby={`${labelId} ${valueId}`}
disabled={availableModels.length === 0 || busy || disabled}
onClick={() => {
if (openRef.current) closeMenu();
else openMenu();
}}
>
<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 || disabled}
/>
))}
</div>
) : (
<p className="model-empty muted">No models match “{query.trim()}”.</p>
)}
</div>
)}
</div>
</div>
);
}
+46 -91
View File
@@ -1,9 +1,6 @@
import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { DirectorySession } from "../workspace/types";
const focusableSelector =
'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])';
function sessionTitle(session: DirectorySession) {
return session.name ?? session.firstMessage ?? "Untitled session";
}
@@ -26,9 +23,7 @@ function SessionRow({
onOpen: (session: DirectorySession) => void;
}) {
const showPreview = Boolean(
session.name &&
session.firstMessage &&
session.firstMessage !== session.name,
session.name && session.firstMessage && session.firstMessage !== session.name,
);
return (
<li>
@@ -71,47 +66,6 @@ function SessionResults({
);
}
function useDialogFocus(
dialogRef: RefObject<HTMLElement | null>,
searchRef: RefObject<HTMLInputElement | null>,
onClose: () => void,
) {
const openerRef = useRef<HTMLElement | null>(
document.activeElement as HTMLElement | null,
);
useEffect(() => {
const dialog = dialogRef.current;
const controls = () => [
...(dialog?.querySelectorAll<HTMLElement>(focusableSelector) ?? []),
];
searchRef.current?.focus();
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (event.key !== "Tab") return;
const focusable = controls();
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", keydown);
return () => {
window.removeEventListener("keydown", keydown);
openerRef.current?.focus();
};
}, [dialogRef, onClose, searchRef]);
}
export function SessionPicker({
sessions,
onOpen,
@@ -122,55 +76,56 @@ export function SessionPicker({
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const dialogRef = useRef<HTMLElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const normalizedQuery = query.trim().toLocaleLowerCase();
const filteredSessions = useMemo(
() => sessions.filter((session) => matchesQuery(session, normalizedQuery)),
[normalizedQuery, sessions],
);
useDialogFocus(dialogRef, searchRef, onClose);
useEffect(() => {
const dialog = dialogRef.current;
dialog?.showModal();
return () => {
if (dialog?.open) dialog.close();
};
}, []);
const resultLabel = `${filteredSessions.length} saved session${filteredSessions.length === 1 ? "" : "s"}`;
return (
<div className="modal-backdrop">
<section
ref={dialogRef}
className="session-picker"
role="dialog"
aria-modal="true"
aria-labelledby="session-picker-title"
>
<div className="picker-heading">
<h2 id="session-picker-title">Saved sessions</h2>
<button onClick={onClose}>Close</button>
</div>
<div className="session-search">
<label className="sr-only" htmlFor="session-search">
Search saved sessions
</label>
<input
ref={searchRef}
id="session-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by name, message, or ID"
aria-controls="session-results"
/>
</div>
<p className="session-result-count" role="status" aria-live="polite">
{sessions.length ? resultLabel : "No saved sessions"}
</p>
{sessions.length === 0 ? (
<p className="muted">No saved sessions for this directory.</p>
) : (
<SessionResults
sessions={filteredSessions}
query={query}
onOpen={onOpen}
/>
)}
</section>
</div>
<dialog
ref={dialogRef}
className="session-picker"
aria-labelledby="session-picker-title"
onCancel={(event) => {
event.preventDefault();
onClose();
}}
>
<div className="picker-heading">
<h2 id="session-picker-title">Saved sessions</h2>
<button onClick={onClose}>Close</button>
</div>
<div className="session-search">
<label className="sr-only" htmlFor="session-search">
Search saved sessions
</label>
<input
autoFocus
id="session-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by name, message, or ID"
aria-controls="session-results"
/>
</div>
<p className="session-result-count" role="status" aria-live="polite">
{sessions.length ? resultLabel : "No saved sessions"}
</p>
{sessions.length === 0 ? (
<p className="muted">No saved sessions for this directory.</p>
) : (
<SessionResults sessions={filteredSessions} query={query} onOpen={onOpen} />
)}
</dialog>
);
}
+170 -32
View File
@@ -1,4 +1,10 @@
import { useEffect, useRef, type KeyboardEvent } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent,
} from "react";
import type { RuntimeView } from "../workspace/types";
type Props = {
@@ -6,32 +12,88 @@ type Props = {
selectedId?: string;
onSelect: (id: string) => void;
onClose: (runtime: RuntimeView) => void;
onRenew: (runtime: RuntimeView) => void;
onNew: () => void;
onResume: () => void;
busy?: boolean;
closingRuntimeId?: string;
closingRuntimeIds?: ReadonlySet<string>;
renewingRuntimeIds?: ReadonlySet<string>;
restoreFocus?: boolean;
onFocusRestored?: () => void;
};
export function SessionTabs({
runtimes,
selectedId,
onSelect,
onClose,
onRenew,
onNew,
onResume,
busy,
closingRuntimeId,
closingRuntimeIds = new Set(),
renewingRuntimeIds = new Set(),
restoreFocus,
onFocusRestored,
}: Props) {
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
const tabsRef = useRef<HTMLDivElement>(null);
const newSessionRef = useRef<HTMLButtonElement>(null);
const [scrollPosition, setScrollPosition] = useState({
left: false,
right: false,
});
const syncScrollPosition = useCallback(() => {
const tabs = tabsRef.current;
if (!tabs) return;
const next = {
left: tabs.scrollLeft > 1,
right: tabs.scrollLeft + tabs.clientWidth < tabs.scrollWidth - 1,
};
setScrollPosition((current) =>
current.left === next.left && current.right === next.right ? current : next,
);
}, []);
const reveal = useCallback((runtimeId: string) => {
refs.current[runtimeId]?.scrollIntoView?.({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
}, []);
useEffect(() => {
if (selectedId) reveal(selectedId);
}, [selectedId, reveal]);
useEffect(() => {
const tabs = tabsRef.current;
if (!tabs) return;
syncScrollPosition();
const observer =
typeof ResizeObserver === "undefined"
? undefined
: new ResizeObserver(syncScrollPosition);
observer?.observe(tabs);
window.addEventListener("resize", syncScrollPosition);
return () => {
observer?.disconnect();
window.removeEventListener("resize", syncScrollPosition);
};
}, [runtimes.length, syncScrollPosition]);
useEffect(() => {
if (!restoreFocus) return;
(selectedId ? refs.current[selectedId] : newSessionRef.current)?.focus();
const target = selectedId ? refs.current[selectedId] : newSessionRef.current;
target?.focus();
if (selectedId) reveal(selectedId);
onFocusRestored?.();
}, [restoreFocus, selectedId, onFocusRestored]);
}, [restoreFocus, selectedId, onFocusRestored, reveal]);
function scrollTabs(direction: -1 | 1) {
const tabs = tabsRef.current;
if (!tabs) return;
tabs.scrollBy({
behavior: "smooth",
left: direction * Math.max(tabs.clientWidth * 0.75, 180),
});
}
function keydown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
let next: number | undefined;
if (!runtimes.length) return;
@@ -42,20 +104,55 @@ export function SessionTabs({
else if (event.key === "End") next = runtimes.length - 1;
if (next === undefined) return;
event.preventDefault();
refs.current[runtimes[next].summary.runtimeId]?.focus();
onSelect(runtimes[next].summary.runtimeId);
const runtimeId = runtimes[next].summary.runtimeId;
refs.current[runtimeId]?.focus();
reveal(runtimeId);
onSelect(runtimeId);
}
return (
<div className="session-strip">
<div className="session-tab-bar" aria-label="Sessions">
<div className="session-tabs" role="tablist" aria-label="Open sessions">
<button
type="button"
className="session-scroll-button"
aria-label="Scroll sessions left"
title="Previous sessions"
disabled={!scrollPosition.left}
onClick={() => scrollTabs(-1)}
>
<span aria-hidden="true">‹</span>
</button>
<div
className="session-tabs"
role="tablist"
aria-label="Open sessions"
ref={tabsRef}
onScroll={syncScrollPosition}
onWheel={(event) => {
const tabs = event.currentTarget;
if (
Math.abs(event.deltaY) <= Math.abs(event.deltaX) ||
tabs.scrollWidth <= tabs.clientWidth
)
return;
const atStart = tabs.scrollLeft <= 0;
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 1;
if ((event.deltaY < 0 && atStart) || (event.deltaY > 0 && atEnd)) return;
event.preventDefault();
tabs.scrollLeft += event.deltaY;
syncScrollPosition();
}}
>
{runtimes.map((runtime, index) => {
const summary = runtime.summary;
const selected = summary.runtimeId === selectedId;
const renewing = renewingRuntimeIds.has(summary.runtimeId);
const closing = closingRuntimeIds.has(summary.runtimeId);
return (
<div
className={`session-tab ${selected ? "selected" : ""}`}
key={summary.runtimeId}
role="presentation"
>
<button
role="tab"
@@ -66,50 +163,91 @@ export function SessionTabs({
ref={(element) => {
refs.current[summary.runtimeId] = element;
}}
onFocus={() => reveal(summary.runtimeId)}
onKeyDown={(event) => keydown(event, index)}
onClick={() => onSelect(summary.runtimeId)}
>
<span>{summary.label}</span>
<small className={summary.state}>
{summary.state}
{summary.queueCount
? ` · ${summary.queueCount} queued`
: ""}
</small>
{runtime.unread && (
<i className="unread-dot" aria-label="Unread activity" />
)}
{summary.attention && (
<b className="attention-dot" aria-label="Needs attention">
!
</b>
<span className="session-tab-label">{summary.label}</span>
<span className="session-tab-meta">
<i
className={`session-state-dot ${summary.state}`}
aria-hidden="true"
/>
<small>
{summary.state}
{summary.queueCount ? ` · ${summary.queueCount} queued` : ""}
</small>
{runtime.unread && (
<i className="unread-dot" aria-label="Unread activity" />
)}
{summary.attention && (
<b className="attention-dot" aria-label="Needs attention">
!
</b>
)}
</span>
</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
className="close-session"
aria-label={`Close ${summary.label}`}
disabled={closingRuntimeId === summary.runtimeId}
aria-label={
closing ? `Closing ${summary.label}` : `Close ${summary.label}`
}
aria-busy={closing}
disabled={renewing || closing}
onClick={() => onClose(runtime)}
>
{closingRuntimeId === summary.runtimeId ? "…" : "×"}
{closing ? "…" : "×"}
</button>
</div>
);
})}
</div>
<button
type="button"
className="session-scroll-button"
aria-label="Scroll sessions right"
title="More sessions"
disabled={!scrollPosition.right}
onClick={() => scrollTabs(1)}
>
<span aria-hidden="true">›</span>
</button>
<div className="session-tab-actions" aria-label="Session actions">
<button className="load-session" disabled={busy} onClick={onResume}>
<span aria-hidden="true">⌄</span>
{busy ? "Working…" : "Load"}
</button>
<button
className="new-session"
className="new-session-tab"
ref={newSessionRef}
aria-label={busy ? "Starting new session" : "New session"}
disabled={busy}
onClick={onNew}
>
<span aria-hidden="true">+</span>
{busy ? "Starting…" : "New"}
<span aria-hidden="true">{busy ? "…" : "+"}</span>
<span className="session-action-label">{busy ? "Starting" : "New"}</span>
</button>
<button className="load-session" disabled={busy} onClick={onResume}>
<span aria-hidden="true">⌄</span>
<span className="session-action-label">{busy ? "Working…" : "Load"}</span>
</button>
</div>
</div>
+18
View File
@@ -0,0 +1,18 @@
.thinking-selector {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px;
align-items: center;
min-width: 0;
color: #bbb7b0;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.thinking-trigger {
appearance: auto;
padding-right: 6px;
background-image: none;
}
@@ -0,0 +1,71 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ThinkingSelector } from "./ThinkingSelector";
const model = {
reasoning: true,
thinkingLevelMap: {
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: "xhigh",
},
};
const fullModel = {
reasoning: true,
thinkingLevelMap: {
minimal: "minimal",
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
},
};
describe("ThinkingSelector", () => {
it("uses reconciled model options", () => {
render(
<ThinkingSelector model={model} value="medium" onChange={vi.fn()} />,
);
const select = screen.getByRole("combobox", { name: "Thinking" });
expect(select).toHaveValue("off");
expect(
screen.getAllByRole("option").map((option) => option.textContent),
).toEqual(["Off", "High", "XHigh"]);
});
it("reports native selection changes", () => {
const onChange = vi.fn();
render(
<ThinkingSelector model={fullModel} value="off" onChange={onChange} />,
);
fireEvent.change(screen.getByRole("combobox", { name: "Thinking" }), {
target: { value: "high" },
});
expect(onChange).toHaveBeenCalledWith("high");
});
it("supports labels, disabled state, and control classes", () => {
render(
<ThinkingSelector
label="Default thinking"
model={model}
value="off"
disabled
className="compact"
controlClassName="toolbar-control"
onChange={vi.fn()}
/>,
);
const select = screen.getByRole("combobox", { name: "Default thinking" });
expect(select).toBeDisabled();
expect(select).toHaveClass("toolbar-control");
expect(select.closest("label")).toHaveClass("compact");
});
});
+53
View File
@@ -0,0 +1,53 @@
import { reconcileThinkingLevel, thinkingLevelOptions } from "../model-catalog";
import type { Model } from "../workspace/types";
import "./ModelSelector.css";
import "./ThinkingSelector.css";
type ThinkingSelectorProps = {
model?: Model;
label?: string;
value: string;
disabled?: boolean;
className?: string;
controlClassName?: string;
onChange: (level: string) => void;
};
export function ThinkingSelector({
model,
label = "Thinking",
value,
disabled = false,
className,
controlClassName,
onChange,
}: ThinkingSelectorProps) {
const options = thinkingLevelOptions(model);
return (
<label
className={["thinking-selector", className]
.filter(Boolean)
.join(" ")}
>
<span>{label}</span>
<select
className={[
"model-trigger",
"thinking-trigger",
controlClassName,
]
.filter(Boolean)
.join(" ")}
value={reconcileThinkingLevel(model, value)}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value)}
>
{options.map((option) => (
<option value={option.value} key={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
+309 -37
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DirectorySidebar } from "./DirectorySidebar";
@@ -42,6 +42,8 @@ describe("multi-session navigation", () => {
it("renders directory aggregates and accessible keyboard session tabs", async () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
const onSelect = vi.fn();
const onNew = vi.fn();
const onRenew = vi.fn();
const { rerender } = render(
<>
<DirectorySidebar
@@ -58,7 +60,8 @@ describe("multi-session navigation", () => {
selectedId="a"
onSelect={onSelect}
onClose={vi.fn()}
onNew={vi.fn()}
onRenew={onRenew}
onNew={onNew}
onResume={vi.fn()}
/>
</>,
@@ -66,6 +69,21 @@ describe("multi-session navigation", () => {
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
const tabs = screen.getAllByRole("tab");
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.previousElementSibling).toHaveAccessibleName(
"Scroll sessions left",
);
expect(tablist.nextElementSibling).toHaveAccessibleName(
"Scroll sessions right",
);
expect(newSession.closest(".session-tab-actions")).not.toBeNull();
await userEvent.click(newSession);
expect(onNew).toHaveBeenCalledOnce();
await userEvent.click(tabs[0]);
await userEvent.keyboard("{ArrowRight}");
expect(onSelect).toHaveBeenCalledWith("b");
@@ -75,12 +93,86 @@ describe("multi-session navigation", () => {
selectedId="b"
onSelect={onSelect}
onClose={vi.fn()}
onRenew={vi.fn()}
onNew={vi.fn()}
onResume={vi.fn()}
/>,
);
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
});
it("scrolls overflowing sessions and reveals the selected tab", async () => {
const user = userEvent.setup();
const runtimes = ["a", "b", "c", "d", "e"].map((id) =>
runtimeView(summary(id)),
);
const props = {
runtimes,
onSelect: vi.fn(),
onClose: vi.fn(),
onRenew: vi.fn(),
onNew: vi.fn(),
onResume: vi.fn(),
};
const { rerender } = render(<SessionTabs {...props} selectedId="a" />);
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
const scrollBy = vi.fn();
Object.defineProperties(tablist, {
scrollWidth: { configurable: true, value: 800 },
clientWidth: { configurable: true, value: 240 },
scrollLeft: { configurable: true, value: 0, writable: true },
scrollBy: { configurable: true, value: scrollBy },
});
fireEvent.scroll(tablist);
const previous = screen.getByRole("button", {
name: "Scroll sessions left",
});
const next = screen.getByRole("button", { name: "Scroll sessions right" });
expect(previous).toBeDisabled();
expect(next).toBeEnabled();
await user.click(next);
expect(scrollBy).toHaveBeenCalledWith({ behavior: "smooth", left: 180 });
(tablist as HTMLDivElement).scrollLeft = 300;
fireEvent.wheel(tablist, { deltaX: 0, deltaY: -80 });
expect((tablist as HTMLDivElement).scrollLeft).toBe(220);
(tablist as HTMLDivElement).scrollLeft = 560;
fireEvent.scroll(tablist);
expect(previous).toBeEnabled();
expect(next).toBeDisabled();
const finalTab = screen.getByRole("tab", { name: /^e idle$/ });
const scrollIntoView = vi.fn();
Object.defineProperty(finalTab, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});
rerender(<SessionTabs {...props} selectedId="e" />);
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
});
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", () => {
const onFocusRestored = vi.fn();
render(
@@ -89,6 +181,7 @@ describe("multi-session navigation", () => {
selectedId="b"
onSelect={vi.fn()}
onClose={vi.fn()}
onRenew={vi.fn()}
onNew={vi.fn()}
onResume={vi.fn()}
restoreFocus
@@ -98,13 +191,64 @@ describe("multi-session navigation", () => {
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
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 runtime = runtimeView(summary("tool-result"));
runtime.messages = [
{ role: "toolResult", toolName: "read", content: "Detailed tool output" },
{ role: "toolResult", toolName: "read", content: "first\nsecond\n" },
];
const { container } = render(
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("uses omitted-reasoning state model metadata when catalog lacks current model", async () => {
const runtime = runtimeView(summary("thinking"));
runtime.state = {
model: {
provider: "provider",
id: "current",
thinkingLevelMap: { high: null, xhigh: "xhigh" },
},
};
render(
<ConversationWorkspace
runtime={runtime}
onDraft={vi.fn()}
@@ -116,10 +260,146 @@ describe("multi-session navigation", () => {
onCommand={vi.fn()}
/>,
);
const details = container.querySelector("details");
expect(details).not.toHaveAttribute("open");
await user.click(screen.getByText("Tool result · read"));
expect(details).toHaveAttribute("open");
const thinking = screen.getByRole("combobox", { name: "Thinking" });
expect(
within(thinking)
.getAllByRole("option")
.map((option) => option.getAttribute("value")),
).toEqual(["off", "minimal", "low", "medium", "xhigh"]);
});
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(
<ConversationWorkspace
runtime={runtime}
onDraft={vi.fn()}
onSubmit={vi.fn()}
onAbort={vi.fn()}
onScroll={vi.fn()}
onModel={vi.fn()}
onThinking={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 modelControls = screen.getByRole("region", {
name: "Model and thinking controls",
});
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("keeps working activity at the transcript tail and follows it from the bottom", () => {
const idle = runtimeView(summary("activity"));
idle.messages = [{ role: "assistant", content: "Partial response" }];
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 { rerender } = render(
<ConversationWorkspace runtime={idle} {...props} />,
);
const transcript = screen.getByRole("region", { name: "Conversation" });
Object.defineProperty(transcript, "scrollHeight", {
configurable: true,
value: 600,
});
transcript.scrollTop = 125;
rerender(
<ConversationWorkspace
runtime={{
...idle,
summary: { ...idle.summary, state: "streaming" },
progress: {
phase: "working",
detail: "Thinking and preparing a response",
toolCount: 0,
},
}}
{...props}
/>,
);
const activity = screen.getByText("Pi is working").closest(".agent-working");
expect(transcript.lastElementChild).toBe(activity);
expect(transcript.scrollTop).toBe(600);
});
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", () => {
const runtime = runtimeView(summary("working", "streaming"));
@@ -191,9 +471,7 @@ describe("multi-session navigation", () => {
await user.type(search, "release");
expect(screen.getByRole("status")).toHaveTextContent("1 saved session");
expect(
screen.getByRole("button", { name: /Planning/ }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Planning/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Investigate/ })).toBeNull();
await user.clear(search);
await user.type(search, "INCIDENT-42");
@@ -203,29 +481,16 @@ describe("multi-session navigation", () => {
await user.type(search, "missing");
expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument();
});
it("focuses search, traps focus, and restores the opener", async () => {
const user = userEvent.setup();
const opener = document.createElement("button");
document.body.append(opener);
opener.focus();
it("opens a native modal and handles cancellation", () => {
const onClose = vi.fn();
const { unmount } = render(
<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />,
);
const search = screen.getByRole("searchbox", {
name: "Search saved sessions",
});
const close = screen.getByRole("button", { name: "Close" });
expect(search).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(close).toHaveFocus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(search).toHaveFocus();
await user.keyboard("{Escape}");
render(<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />);
const dialog = screen.getByRole("dialog", { name: "Saved sessions" });
expect(dialog).toHaveAttribute("open");
expect(
screen.getByRole("searchbox", { name: "Search saved sessions" }),
).toHaveFocus();
fireEvent(dialog, new Event("cancel", { cancelable: true }));
expect(onClose).toHaveBeenCalled();
unmount();
expect(opener).toHaveFocus();
document.body.removeChild(opener);
});
it("labels permission requests with the action and exact details", async () => {
const user = userEvent.setup();
@@ -235,8 +500,7 @@ describe("multi-session navigation", () => {
extension={{
method: "confirm",
title: "Run a shell command",
message:
"rm -rf ./build\nThis removes the generated build directory.",
message: "rm -rf ./build\nThis removes the generated build directory.",
}}
onRespond={onRespond}
/>,
@@ -264,9 +528,10 @@ describe("multi-session navigation", () => {
"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(),
onResume = vi.fn(),
onRenew = vi.fn(),
onClose = vi.fn();
render(
<SessionTabs
@@ -274,15 +539,22 @@ describe("multi-session navigation", () => {
selectedId="a"
onSelect={vi.fn()}
onClose={onClose}
onRenew={onRenew}
onNew={onNew}
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.getByLabelText("Renew a"));
await userEvent.click(screen.getByLabelText("Close a"));
expect(onNew).toHaveBeenCalled();
expect(onResume).toHaveBeenCalled();
expect(onRenew).toHaveBeenCalledWith(
expect.objectContaining({
summary: expect.objectContaining({ runtimeId: "a" }),
}),
);
expect(onClose).toHaveBeenCalled();
});
});
+3 -1
View File
@@ -1,9 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import App from "./App";
import QuickPane from "./quick-pane/QuickPane";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
{getCurrentWindow().label === "quick-pane" ? <QuickPane /> : <App />}
</React.StrictMode>,
);
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
catalogModels,
reconcileThinkingLevel,
thinkingLevelOptions,
} from "./model-catalog";
describe("thinking model catalog metadata", () => {
it("matches Pi reasoning defaults, null exclusions, and explicit max", () => {
expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]);
expect(thinkingLevelOptions({ reasoning: false })).toEqual([
{ value: "off", label: "Off" },
]);
expect(thinkingLevelOptions({})).toEqual([
{ value: "off", label: "Off" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
]);
const mapped = {
thinkingLevelMap: {
off: null,
low: null,
xhigh: "xhigh",
max: "max",
},
};
expect(thinkingLevelOptions(mapped)).toEqual([
{ value: "minimal", label: "Minimal" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "XHigh" },
{ value: "max", label: "Max" },
]);
expect(reconcileThinkingLevel(mapped, "off")).toBe("minimal");
expect(reconcileThinkingLevel(mapped, "xhigh")).toBe("xhigh");
const unavailable = {
thinkingLevelMap: {
off: null,
minimal: null,
low: null,
medium: null,
high: null,
xhigh: null,
},
};
expect(reconcileThinkingLevel(unavailable, "high")).toBe("off");
});
it("treats dynamically configured models with omitted reasoning as capable", () => {
const [dynamic] = catalogModels(
[],
[{ provider: "localai", modelId: "local/unknown" }],
);
expect(dynamic.thinkingLevelMap).toBeUndefined();
expect(thinkingLevelOptions(dynamic)).toEqual([
{ value: "off", label: "Off" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
]);
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { Model } from "./workspace/types";
export type ModelIdentity = { provider: string; modelId: string };
export type ThinkingLevelOption = { value: string; label: string };
const DEFAULT_THINKING_LEVELS = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
] as const;
const OFF_THINKING_LEVEL: ThinkingLevelOption = { value: "off", label: "Off" };
function thinkingLevelLabel(level: string) {
if (level === "xhigh") return "XHigh";
return level[0].toUpperCase() + level.slice(1);
}
export function modelIdentity(model: Model): ModelIdentity | undefined {
if (!model.provider || !model.id) return undefined;
return { provider: model.provider, modelId: model.id };
}
export function modelIdentityValue(identity: ModelIdentity) {
return JSON.stringify([identity.provider, identity.modelId]);
}
export function modelIdentityFromValue(
value: string,
): ModelIdentity | undefined {
try {
const parsed: unknown = JSON.parse(value);
if (
!Array.isArray(parsed) ||
parsed.length !== 2 ||
!parsed.every((part) => typeof part === "string")
)
return undefined;
return { provider: parsed[0], modelId: parsed[1] };
} catch {
return undefined;
}
}
export function reconcileModelIdentity(
models: Model[],
preferred: ModelIdentity,
): ModelIdentity | undefined {
const identities = models
.map(modelIdentity)
.filter((identity): identity is ModelIdentity => !!identity);
const preferredValue = modelIdentityValue(preferred);
return (
identities.find(
(identity) => modelIdentityValue(identity) === preferredValue,
) ??
identities.find((identity) => identity.provider === preferred.provider) ??
identities[0]
);
}
export function thinkingLevelOptions(model?: Model): ThinkingLevelOption[] {
if (!model || model.reasoning === false) return [OFF_THINKING_LEVEL];
const map = model.thinkingLevelMap;
const levels = [
...DEFAULT_THINKING_LEVELS.filter((level) => {
if (level === "xhigh") return map?.xhigh !== undefined && map.xhigh !== null;
return map?.[level] !== null;
}),
...(map?.max == null ? [] : ["max"]),
];
return levels.length
? levels.map((value) => ({ value, label: thinkingLevelLabel(value) }))
: [OFF_THINKING_LEVEL];
}
export function reconcileThinkingLevel(
model: Model | undefined,
level: string,
) {
const options = thinkingLevelOptions(model);
return options.some((option) => option.value === level)
? level
: (options.find((option) => option.value === "off")?.value ??
options[0].value);
}
export function catalogModels(
models: Model[],
configured: ModelIdentity[],
): Model[] {
const available = models.filter((model) => model.provider && model.id);
const keys = new Set(
available
.map(modelIdentity)
.filter((identity): identity is ModelIdentity => !!identity)
.map(modelIdentityValue),
);
for (const identity of configured) {
if (!identity.provider || !identity.modelId) continue;
const key = modelIdentityValue(identity);
if (keys.has(key)) continue;
available.unshift({
provider: identity.provider,
id: identity.modelId,
});
keys.add(key);
}
return available;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+648
View File
@@ -0,0 +1,648 @@
:root {
color-scheme: dark;
}
html,
body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
min-width: 0;
min-height: 0;
margin: 0;
background: transparent;
}
.quick-pane {
position: relative;
container-type: size;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 9px;
height: 100vh;
overflow: hidden;
padding: 10px;
color: #e6e3df;
font:
14px / 1.4 Inter,
ui-sans-serif,
system-ui,
sans-serif;
background: rgba(34, 34, 34, 0.98);
border: 1px solid #4b4b4b;
border-radius: 8px;
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.55);
}
.quick-pane-toolbar,
.quick-pane-primary-controls,
.quick-pane-header-actions,
.quick-pane-field {
display: flex;
min-width: 0;
align-items: center;
}
.quick-pane-titlebar {
display: flex;
height: 20px;
flex: 0 0 20px;
align-items: center;
padding: 0 8px;
border-bottom: 1px solid #4b4b4b;
color: #aaa39a;
cursor: grab;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.08em;
text-transform: uppercase;
user-select: none;
}
.quick-pane-titlebar:active {
cursor: grabbing;
}
.quick-pane-toolbar {
flex: 0 0 auto;
flex-wrap: nowrap;
justify-content: space-between;
gap: 8px;
}
.quick-pane-primary-controls,
.quick-pane-header-actions {
gap: 6px;
}
.quick-pane-primary-controls {
flex: 1 1 0;
min-width: 0;
}
.quick-pane-header-actions {
flex: 0 0 auto;
justify-content: end;
}
.quick-pane-field {
gap: 4px;
color: #bbb7b0;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.quick-pane-model-selector {
flex: 1 1 0;
min-width: 0;
color: #bbb7b0;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.quick-pane-catalog-overlay {
position: absolute;
z-index: 10;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
color: #f1e4d1;
background: rgba(34, 34, 34, 0.98);
border-radius: inherit;
font-weight: 750;
}
.quick-pane-catalog-overlay p {
max-width: 440px;
margin: 0;
text-align: center;
}
.quick-pane-catalog-spinner {
width: 22px;
height: 22px;
border: 3px solid rgba(240, 163, 71, 0.28);
border-top-color: #f0a347;
border-radius: 50%;
animation: quick-pane-catalog-spin 0.8s linear infinite;
}
@keyframes quick-pane-catalog-spin {
to {
transform: rotate(360deg);
}
}
.quick-pane .model-menu {
width: min(400px, calc(100cqw - 24px));
max-height: min(350px, calc(100cqh - 74px));
color: #e6e3df;
font-size: 11px;
font-weight: 500;
letter-spacing: normal;
text-transform: none;
}
.quick-pane .model-option {
display: grid;
padding: 6px 7px;
border: 1px solid transparent;
color: #f2ece4;
background: #35322e;
font-size: 11px;
font-weight: 500;
}
.quick-pane .model-option:hover {
border-color: #8c6338;
background: #44382d;
}
.quick-pane .model-option[aria-selected="true"] {
border-color: #f0a347;
background: #4b3420;
}
.quick-pane-thinking {
display: grid;
flex: 0 0 143px;
grid-template-columns: auto minmax(84px, 1fr);
gap: 4px;
}
.quick-pane .compact-label {
display: none;
}
.quick-pane button {
box-sizing: border-box;
min-width: 0;
border: 0;
border-radius: 5px;
padding: 5px 8px;
font: inherit;
}
.quick-pane button {
flex: 0 0 auto;
color: #25180b;
background: #f0a347;
font-weight: 750;
cursor: pointer;
}
.quick-pane button:hover {
background: #ffc170;
}
.quick-pane button:disabled {
opacity: 0.58;
cursor: wait;
}
.quick-pane button:focus-visible {
outline: 3px solid #f0a347;
outline-offset: 2px;
}
.quick-pane button.quick-pane-toolbar-control,
.quick-pane .model-trigger.quick-pane-toolbar-control,
.quick-pane .thinking-trigger.quick-pane-toolbar-control {
box-sizing: border-box;
height: 27px;
min-width: 48px;
padding: 0 8px;
border: 1px solid #70522f;
border-radius: 5px;
color: #e6e3df;
background: #353535;
font:
650 11px / 1 Inter,
ui-sans-serif,
system-ui,
sans-serif;
cursor: pointer;
}
.quick-pane .model-trigger.quick-pane-toolbar-control,
.quick-pane .thinking-trigger.quick-pane-toolbar-control {
appearance: auto;
}
.quick-pane .quick-pane-toolbar-control:hover:not(:disabled) {
border-color: #d88735;
background: #44382d;
}
.quick-pane .quick-pane-toolbar-control:focus-visible {
outline: 3px solid #f0a347;
outline-offset: 2px;
}
.quick-pane .quick-pane-toolbar-control:disabled {
opacity: 0.58;
cursor: not-allowed;
}
.quick-pane-request:focus {
outline: 3px solid #f0a347;
outline-offset: 2px;
}
.quick-pane-exchange {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 9px;
overflow: hidden;
padding: 8px;
border: 1px solid #4b4b4b;
border-radius: 7px;
background: #242424;
scroll-behavior: smooth;
}
.quick-pane-exchange.empty {
display: none;
}
.quick-pane-transcript {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 9px;
overflow-x: hidden;
overflow-y: auto;
}
.quick-pane-user-row {
display: flex;
flex: 0 0 auto;
justify-content: end;
}
.quick-pane-user-message,
.quick-pane-assistant-message {
box-sizing: border-box;
overflow-wrap: anywhere;
}
.quick-pane-user-message {
width: fit-content;
max-width: 78%;
padding: 7px 10px;
border: 1px solid #66513a;
border-radius: 12px 12px 3px 12px;
background: #3a3026;
}
.quick-pane-user-message p,
.quick-pane-notice p,
.quick-pane-system-notice {
margin: 0;
white-space: pre-wrap;
}
.message-author {
display: block;
margin-bottom: 3px;
color: #bdb7ae;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.quick-pane-assistant-message {
width: 100%;
flex: 0 0 auto;
padding: 10px 12px;
border: 1px solid #484848;
border-radius: 7px;
background: #292929;
}
.quick-pane-assistant-message > :last-child {
margin-bottom: 0;
}
.quick-pane-assistant-elapsed,
.quick-pane-assistant-activity {
display: block;
margin-top: 6px;
color: #96918b;
font-size: 10px;
}
.quick-pane-assistant-elapsed {
font-variant-numeric: tabular-nums;
}
.quick-pane-assistant-activity {
color: #c5beb5;
}
.quick-pane-assistant-footer {
margin-top: 8px;
border-top: 1px solid #484848;
padding-top: 6px;
}
.quick-pane-assistant-footer button {
padding: 0;
color: #ffc170;
background: transparent;
font-size: 11px;
font-weight: 650;
}
.quick-pane-assistant-footer button:hover {
color: #fff0d8;
background: transparent;
}
.quick-pane-assistant-message p,
.quick-pane-assistant-message ul,
.quick-pane-assistant-message ol,
.quick-pane-assistant-message blockquote,
.quick-pane-assistant-message pre,
.quick-pane-assistant-message table {
margin-block: 0 9px;
}
.quick-pane-assistant-message ul,
.quick-pane-assistant-message ol {
padding-left: 22px;
}
.quick-pane-assistant-message a {
color: #ffc170;
}
.quick-pane-assistant-message code {
border-radius: 4px;
padding: 1px 4px;
color: #f3ded0;
background: #1d1d1d;
font:
0.9em / 1.45 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
}
.quick-pane-assistant-message pre {
max-width: 100%;
overflow: auto;
border: 1px solid #414141;
border-radius: 6px;
padding: 9px;
background: #1d1d1d;
}
.quick-pane-assistant-message pre code {
padding: 0;
background: transparent;
}
.quick-pane-assistant-message blockquote {
border-left: 3px solid #80613e;
padding-left: 9px;
color: #c6c0b8;
}
.quick-pane-assistant-message table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.quick-pane-assistant-message th,
.quick-pane-assistant-message td {
border: 1px solid #4b4b4b;
padding: 4px 7px;
}
.quick-pane-activity {
display: flex;
min-height: 22px;
flex: 0 0 auto;
align-items: center;
gap: 8px;
padding-inline: 3px;
color: #c5beb5;
font-size: 12px;
}
.typing-dots {
display: inline-flex;
align-items: center;
gap: 3px;
}
.typing-dots i {
width: 5px;
height: 5px;
border-radius: 50%;
background: #f0a347;
animation: quick-pane-dot 1.15s infinite ease-in-out;
}
.typing-dots i:nth-child(2) {
animation-delay: 0.14s;
}
.typing-dots i:nth-child(3) {
animation-delay: 0.28s;
}
@keyframes quick-pane-dot {
0%,
60%,
100% {
opacity: 0.35;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-2px);
}
}
@media (prefers-reduced-motion: reduce) {
.typing-dots i {
animation: none;
}
}
.quick-pane-notice {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 1px solid #65533e;
border-radius: 7px;
padding: 8px 10px;
color: #f1e4d1;
background: #3b3228;
}
.quick-pane-notice strong {
display: block;
margin-bottom: 2px;
}
.quick-pane-notice.error {
border-color: #70424a;
color: #ffd7db;
background: #3d252b;
}
.quick-pane-notice button {
align-self: center;
}
.quick-pane-system-notice {
flex: 0 0 auto;
padding: 6px 8px;
color: #c5beb5;
font-size: 12px;
}
.quick-pane-exchange .extension {
width: min(400px, calc(100cqw - 24px));
max-height: calc(100cqh - 48px);
padding: 7px 9px;
border: 1px solid #65533e;
border-radius: 7px;
background: #302b25;
}
.quick-pane-exchange .extension h2,
.quick-pane-exchange .extension p {
margin: 0 0 5px;
font-size: 12px;
}
.quick-pane-composer {
position: relative;
box-sizing: border-box;
height: 120px;
min-height: 110px;
max-height: 130px;
flex: 0 0 120px;
}
.quick-pane-request {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 11px 150px 29px 11px;
resize: none;
border: 1px solid #55514c;
border-radius: 8px;
color: #e6e3df;
background: #292929;
font: inherit;
font-size: 15px;
}
.quick-pane-request[readonly] {
color: #d1cbc4;
background: #272727;
}
.quick-pane-composer-actions {
position: absolute;
right: 50px;
bottom: 9px;
display: flex;
gap: 4px;
}
.quick-pane-send {
position: absolute;
right: 8px;
bottom: 9px;
width: 34px;
height: 30px;
padding: 0 !important;
border-radius: 7px !important;
font-size: 20px !important;
line-height: 1 !important;
}
.quick-pane-composer-hint {
position: absolute;
left: 11px;
bottom: 8px;
color: #96918b;
font-size: 10px;
pointer-events: none;
}
@media (max-width: 400px) {
.quick-pane {
padding: 8px;
}
.quick-pane .full-label {
display: none;
}
.quick-pane .compact-label {
display: inline;
flex: 0 0 auto;
}
.quick-pane-toolbar,
.quick-pane-primary-controls,
.quick-pane-header-actions {
gap: 4px;
}
.quick-pane-model {
flex-basis: 130px;
}
.quick-pane-thinking {
flex-basis: 74px;
}
.quick-pane-field {
gap: 2px;
}
.quick-pane .thinking-trigger {
padding-inline: 5px;
font-size: 10px;
}
.quick-pane-user-message {
max-width: 88%;
}
}
+10
View File
@@ -1,4 +1,14 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
if (!HTMLDialogElement.prototype.showModal) {
HTMLDialogElement.prototype.showModal = function showModal() {
this.open = true;
};
HTMLDialogElement.prototype.close = function close() {
this.open = false;
};
}
afterEach(cleanup);
+138
View File
@@ -0,0 +1,138 @@
import { render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationWorkspace } from "./components/ConversationWorkspace";
import QuickPane from "./quick-pane/QuickPane";
import { runtimeView } from "./workspace/reducer";
import type { Model, RuntimeSnapshot, UiState } from "./workspace/types";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
listen: vi.fn(),
onFocusChanged: vi.fn(),
startDragging: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({
onFocusChanged: mocks.onFocusChanged,
startDragging: mocks.startDragging,
}),
}));
const settings: UiState = {
version: 1,
directoryOrder: [],
runtimeOrderByDirectory: {},
drafts: {},
scroll: {},
lastSeen: {},
quickPane: {
workspace: "/repo",
provider: "provider",
model: "model",
followUpProvider: "provider",
followUpModel: "model",
instructions: "Stay read-only.",
maximumToolCalls: 6,
defaultThinkingLevel: "medium",
},
};
function options(selector: HTMLElement) {
return within(selector)
.getAllByRole("option")
.map((option) => option.getAttribute("value"));
}
function renderSurfaces(model: Model) {
const runtime = runtimeView({
runtimeId: "runtime",
worktreePath: "/repo",
state: "idle",
label: "Session",
attention: false,
queueCount: 0,
lastActivity: "now",
openedAt: "now",
});
runtime.models = [model];
runtime.state = { model, thinkingLevel: "high" };
const snapshot: RuntimeSnapshot = {
bridgeInstanceId: "bridge",
latestSeq: 1,
runtime: runtime.summary,
state: { data: runtime.state },
models: { data: { models: runtime.models } },
extensions: [],
};
mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(settings);
if (command === "create_quick_runtime")
return Promise.resolve({
runtime: { agentId: "agent", runtimeId: "quick" },
});
if (command === "get_session_runtime_snapshot")
return Promise.resolve(snapshot);
return Promise.resolve(undefined);
});
render(
<>
<ConversationWorkspace
runtime={runtime}
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()}
/>
<QuickPane />
</>,
);
}
describe("thinking selector parity", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.listen.mockResolvedValue(vi.fn());
mocks.onFocusChanged.mockResolvedValue(vi.fn());
mocks.startDragging.mockResolvedValue(undefined);
});
it.each([
[
"omitted reasoning and map",
{},
["off", "minimal", "low", "medium", "high"],
],
[
"null exclusions",
{ thinkingLevelMap: { minimal: null, high: null } },
["off", "low", "medium"],
],
[
"explicit xhigh and max",
{ thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
["off", "minimal", "low", "medium", "high", "xhigh", "max"],
],
[
"non-reasoning",
{ reasoning: false, thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
["off"],
],
] as const)(
"matches Pi %s semantics in Conversation Workspace and Quick Pane",
async (_name, metadata, expected) => {
renderSurfaces({ provider: "provider", id: "model", ...metadata });
const selectors = await screen.findAllByRole("combobox", {
name: "Thinking",
});
expect(selectors).toHaveLength(2);
for (const selector of selectors)
expect(options(selector)).toEqual(expected);
},
);
});
+4 -1
View File
@@ -42,7 +42,10 @@ export async function loadPresentation() {
return invoke<UiState>("load_ui_state");
}
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">) {
const path = storage.getItem(selectedPathKey) ?? undefined;
+210 -5
View File
@@ -74,6 +74,176 @@ describe("workspace reducer", () => {
expect(state.runtimesById.b.messages[0].content).toBe("b");
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("preserves reasoning model metadata from runtime snapshots", () => {
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: {
data: {
model: {
provider: "provider",
id: "reasoning",
reasoning: true,
thinkingLevelMap: { high: null, xhigh: "xhigh" },
},
},
},
models: {
data: {
models: [
{
provider: "provider",
id: "reasoning",
reasoning: true,
thinkingLevelMap: { high: null, xhigh: "xhigh" },
},
],
},
},
extensions: [],
},
});
expect(state.runtimesById.a.state.model).toEqual({
provider: "provider",
id: "reasoning",
reasoning: true,
thinkingLevelMap: { high: null, xhigh: "xhigh" },
});
expect(state.runtimesById.a.models[0]).toEqual(
state.runtimesById.a.state.model,
);
});
it("removes the optimistic submission when Pi returns its structured user message", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
@@ -259,8 +429,8 @@ describe("workspace reducer", () => {
],
};
expect(
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed
.summary.agentId,
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed.summary
.agentId,
).toBeUndefined();
});
it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => {
@@ -318,9 +488,7 @@ describe("workspace reducer", () => {
it("clears stale extension and transcript state for every runtime after an epoch change", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById.b.messages = [{ role: "assistant", content: "old" }];
state.runtimesById.b.extensions = [
{ id: "dead-request", method: "confirm" },
];
state.runtimesById.b.extensions = [{ id: "dead-request", method: "confirm" }];
state.runtimesById.b.unread = true;
const reset = workspaceReducer(state, {
type: "hostEvent",
@@ -340,6 +508,43 @@ describe("workspace reducer", () => {
expect(restored.runtimesById.b.extensions).toEqual([]);
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", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
const next = applyWorkspaceEvent(state, {
+68 -19
View File
@@ -16,6 +16,28 @@ const idleProgress = {
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]) {
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
@@ -57,6 +79,7 @@ export function runtimeView(
messages: previous?.messages ?? [],
commands: previous?.commands ?? [],
models: previous?.models ?? [],
modelError: previous?.modelError,
extensions: previous?.extensions ?? [],
extensionStatuses: previous?.extensionStatuses ?? {},
extensionWidgets: previous?.extensionWidgets ?? {},
@@ -67,6 +90,7 @@ export function runtimeView(
scroll: previous?.scroll ?? { atBottom: true },
unread: previous?.unread ?? false,
loading: previous?.loading ?? false,
snapshotRetry: previous?.snapshotRetry ?? false,
error: previous?.error,
requestGeneration: previous?.requestGeneration ?? 0,
loadedThroughSeq: previous?.loadedThroughSeq ?? 0,
@@ -84,6 +108,7 @@ function resetRuntimeView(
messages: [],
commands: [],
models: [],
modelError: undefined,
extensions: [],
extensionStatuses: {},
extensionWidgets: {},
@@ -92,6 +117,7 @@ function resetRuntimeView(
unread: false,
progress: idleProgress,
loading: false,
snapshotRetry: false,
error: undefined,
requestGeneration: 0,
loadedThroughSeq: 0,
@@ -147,9 +173,7 @@ export function reconcileWorkspace(
ids,
);
}
const paths = workspace.directories.map(
(directory) => directory.worktreePath,
);
const paths = workspace.directories.map((directory) => directory.worktreePath);
const directoryOrder = ordered(
persisted?.directoryOrder ?? state.directoryOrder,
paths,
@@ -158,9 +182,7 @@ export function reconcileWorkspace(
const selectedRuntimeId =
preferred && runtimesById[preferred]
? preferred
: directoryOrder.flatMap(
(path) => runtimeOrderByDirectory[path] ?? [],
)[0];
: directoryOrder.flatMap((path) => runtimeOrderByDirectory[path] ?? [])[0];
const selectedDirectoryPath = selectedRuntimeId
? runtimesById[selectedRuntimeId]?.summary.worktreePath
: directoryOrder[0];
@@ -261,6 +283,26 @@ export function applyWorkspaceEvent(
},
};
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 (
["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes(
event.type ?? "",
@@ -456,8 +498,7 @@ export function workspaceReducer(
return {
...state,
selectedDirectoryPath: action.worktreePath,
selectedRuntimeId:
state.runtimeOrderByDirectory[action.worktreePath]?.[0],
selectedRuntimeId: state.runtimeOrderByDirectory[action.worktreePath]?.[0],
};
if (action.type === "selectRuntime")
return {
@@ -483,20 +524,29 @@ export function workspaceReducer(
const view = { ...state.runtimesById[runtimeId] };
if (action.type === "snapshotRequested") {
view.loading = true;
view.snapshotRetry = false;
view.error = undefined;
view.requestGeneration = action.generation;
} else if (action.type === "snapshotFailed") {
if (view.requestGeneration !== action.generation) return state;
view.loading = false;
view.snapshotRetry = false;
view.error = action.error;
} else if (action.type === "snapshotLoaded") {
if (view.requestGeneration !== action.generation) return state;
if (
view.requestGeneration !== action.generation ||
action.snapshot.bridgeInstanceId !== state.bridge.instanceId ||
action.snapshot.latestSeq < view.loadedThroughSeq
)
return state;
) {
view.loading = false;
view.snapshotRetry = true;
return {
...state,
runtimesById: { ...state.runtimesById, [runtimeId]: view },
};
}
view.loading = false;
view.snapshotRetry = false;
view.summary = action.snapshot.runtime;
view.state = action.snapshot.state?.data ?? {};
view.stats = action.snapshot.stats?.data ?? {};
@@ -506,16 +556,15 @@ export function workspaceReducer(
view.messages,
);
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.loadedThroughSeq = action.snapshot.latestSeq;
view.progress = view.state.isStreaming
? {
phase: "working",
detail: "Thinking and preparing a response",
toolCount: 0,
}
: idleProgress;
view.progress = snapshotProgress(action.snapshot.runtime);
} else if (action.type === "draftChanged") view.draft = action.draft;
else if (action.type === "scrollChanged")
view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop };
+33 -9
View File
@@ -60,19 +60,23 @@ export type Message = {
| string
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
};
type ThinkingLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "max";
export type AgentState = {
isStreaming?: boolean;
thinkingLevel?: string;
thinkingLevel?: ThinkingLevel;
sessionName?: string;
sessionId?: string;
messageCount?: number;
pendingMessageCount?: number;
model?: {
provider?: string;
id?: string;
name?: string;
contextWindow?: number;
};
model?: Model & { contextWindow?: number };
};
export type SessionStats = {
tokens?: {
@@ -89,7 +93,13 @@ export type SessionStats = {
};
};
export type Command = { name?: string; description?: string };
export type Model = { provider?: string; id?: string; name?: string };
export type Model = {
provider?: string;
id?: string;
name?: string;
reasoning?: boolean;
thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
};
export type Extension = {
id?: string;
method?: string;
@@ -129,7 +139,7 @@ export type RuntimeSnapshot = {
stats?: { data?: SessionStats };
transcript?: { data?: { messages?: Message[] } };
commands?: { data?: { commands?: Command[] } };
models?: { data?: { models?: Model[] } };
models?: { data?: { models?: Model[] }; error?: string };
extensions: Extension[];
};
export type DirectorySession = {
@@ -180,6 +190,7 @@ export type RuntimeView = {
messages: Message[];
commands: Command[];
models: Model[];
modelError?: string;
extensions: Extension[];
extensionStatuses: Record<string, string>;
extensionWidgets: Record<
@@ -193,6 +204,7 @@ export type RuntimeView = {
scroll: { atBottom: boolean; scrollTop?: number };
unread: boolean;
loading: boolean;
snapshotRetry: boolean;
error?: string;
requestGeneration: number;
loadedThroughSeq: number;
@@ -219,6 +231,17 @@ export type WorkspaceState = {
interfaceScale: number;
};
export type QuickPaneSettings = {
workspace: string;
provider: string;
model: string;
followUpProvider: string;
followUpModel: string;
instructions: string;
maximumToolCalls: number;
defaultThinkingLevel: string;
};
export type UiState = {
version: 1;
directoryOrder: string[];
@@ -234,4 +257,5 @@ export type UiState = {
interfaceScale?: number;
defaultNewSessionWorktree?: string;
dismissedCollisionWarning?: boolean;
quickPane?: QuickPaneSettings;
};
+158
View File
@@ -0,0 +1,158 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeSnapshot, Workspace, WorkspaceSummary } from "./types";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
listeners: {} as Record<string, (message: { payload: any }) => void>,
listen: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
vi.mock("./persistence", () => ({
legacyPreferences: () => ({}),
loadPresentation: async () => ({
version: 1,
directoryOrder: [],
runtimeOrderByDirectory: {},
drafts: {},
scroll: {},
lastSeen: {},
}),
presentationState: vi.fn(),
removeLegacyPreferences: vi.fn(),
savePresentation: vi.fn().mockResolvedValue(undefined),
}));
import { useWorkspace } from "./useWorkspace";
const normalRuntime = {
runtimeId: "normal",
worktreePath: "/repo",
state: "idle",
label: "Normal",
attention: false,
queueCount: 0,
lastActivity: "now",
openedAt: "now",
};
const quickRuntime = { ...normalRuntime, runtimeId: "quick", label: "Quick" };
function workspace(runtimes: (typeof normalRuntime)[]): Workspace {
return {
bridgeInstanceId: "bridge",
latestSeq: 1,
directories: [
{
worktreePath: "/repo",
isHome: true,
openCount: runtimes.length,
workingCount: 0,
attentionCount: 0,
recoveringCount: 0,
errorCount: 0,
runtimes,
},
],
};
}
const summary: WorkspaceSummary = {
bridgeInstanceId: "bridge",
latestSeq: 1,
openCount: 1,
workingCount: 0,
attentionCount: 0,
recoveringCount: 0,
errorCount: 0,
directoryCount: 1,
resourceWarning: false,
};
function snapshot(runtime: typeof normalRuntime): RuntimeSnapshot {
return {
bridgeInstanceId: "bridge",
latestSeq: 1,
runtime,
extensions: [],
};
}
describe("useWorkspace Quick runtime isolation", () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(mocks.listeners)) delete mocks.listeners[key];
mocks.listen.mockImplementation((event, listener) => {
mocks.listeners[event] = listener;
return Promise.resolve(() => delete mocks.listeners[event]);
});
});
it("ignores unknown Quick events and exposes promoted runtime once", async () => {
let promoted = false;
mocks.invoke.mockImplementation(
(command: string, payload?: { runtimeId?: string }) => {
if (command === "get_workspace")
return Promise.resolve(
workspace(promoted ? [normalRuntime, quickRuntime] : [normalRuntime]),
);
if (command === "get_workspace_summary") return Promise.resolve(summary);
if (command === "get_session_runtime_snapshot")
return Promise.resolve(
snapshot(payload?.runtimeId === "quick" ? quickRuntime : normalRuntime),
);
if (command === "get_pending_promoted_runtime")
return Promise.resolve(null);
return Promise.resolve(undefined);
},
);
const { result } = renderHook(() => useWorkspace());
await waitFor(() =>
expect(result.current.state.runtimesById.normal).toBeDefined(),
);
await waitFor(() =>
expect(mocks.listeners["workspace-bridge"]).toBeDefined(),
);
const workspaceCalls = () =>
mocks.invoke.mock.calls.filter(([command]) => command === "get_workspace");
await act(async () => {
mocks.listeners["workspace-bridge"]({
payload: {
kind: "event",
event: {
bridgeInstanceId: "bridge",
seq: 2,
type: "runtime_event",
data: {
runtimeId: "quick",
runtime: quickRuntime,
eventType: "transcript",
eventData: { event: { type: "message_end" } },
},
},
},
});
});
expect(result.current.state.runtimesById.quick).toBeUndefined();
expect(workspaceCalls()).toHaveLength(1);
promoted = true;
await act(async () => {
mocks.listeners["workspace-promoted-runtime"]({
payload: { runtimeId: "quick" },
});
});
await waitFor(() =>
expect(result.current.state.runtimesById.quick).toBeDefined(),
);
expect(result.current.state.runtimeOrderByDirectory["/repo"]).toEqual([
"normal",
"quick",
]);
expect(result.current.state.selectedRuntimeId).toBe("quick");
expect(workspaceCalls()).toHaveLength(2);
});
});
+102 -27
View File
@@ -1,6 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useReducer, useRef } from "react";
import { initialWorkspaceState, workspaceReducer } from "./reducer";
import {
@@ -28,29 +27,50 @@ export function useWorkspace() {
const legacyMigrationPending = useRef(false);
const disposed = useRef(true);
const refreshGeneration = useRef(0);
const workspaceReady = useRef(false);
const pendingPromotedRuntime = useRef<string | undefined>(undefined);
const consumingPromotion = useRef(false);
const loadSnapshot = useCallback(async (runtimeId: string) => {
if (disposed.current) return;
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
loadGeneration.current[runtimeId] = generation;
dispatch({ type: "snapshotRequested", runtimeId, generation });
try {
const snapshot = await invoke<RuntimeSnapshot>(
"get_session_runtime_snapshot",
{ runtimeId },
);
if (!disposed.current)
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
} catch (error) {
if (!disposed.current)
dispatch({
type: "snapshotFailed",
runtimeId,
generation,
error: String(error),
});
}
}, []);
const loadSnapshot = useCallback(
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
if (disposed.current) return false;
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
loadGeneration.current[runtimeId] = generation;
dispatch({ type: "snapshotRequested", runtimeId, generation });
try {
const snapshot = await invoke<RuntimeSnapshot>(
"get_session_runtime_snapshot",
{ runtimeId },
);
if (!disposed.current)
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
return true;
} catch (error) {
if (!disposed.current)
dispatch({
type: "snapshotFailed",
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(
(runtimeId: string) => {
@@ -132,10 +152,44 @@ export function useWorkspace() {
[loadSnapshot],
);
const consumePromotedRuntime = useCallback(() => {
if (!workspaceReady.current || consumingPromotion.current) return;
consumingPromotion.current = true;
void (async () => {
while (pendingPromotedRuntime.current && !disposed.current) {
const runtimeId = pendingPromotedRuntime.current;
pendingPromotedRuntime.current = undefined;
await refresh();
if (disposed.current) return;
dispatch({ type: "selectRuntime", runtimeId });
await loadSnapshot(runtimeId);
if (disposed.current) return;
await invoke("consume_pending_promoted_runtime", { runtimeId });
}
})()
.catch(() => {})
.finally(() => {
consumingPromotion.current = false;
if (pendingPromotedRuntime.current) consumePromotedRuntime();
});
}, [loadSnapshot, refresh]);
useEffect(() => {
disposed.current = false;
workspaceReady.current = false;
let stop: (() => void) | undefined;
let stopNewRuntime: (() => void) | undefined;
let stopPromotedRuntime: (() => void) | undefined;
const promotedListenerReady = listen<{ runtimeId: string }>(
"workspace-promoted-runtime",
(message) => {
pendingPromotedRuntime.current = message.payload.runtimeId;
consumePromotedRuntime();
},
).then((unlisten) => {
if (disposed.current) unlisten();
else stopPromotedRuntime = unlisten;
});
void listen<{
phase: "starting" | "ready" | "error";
runtimeId?: string;
@@ -166,10 +220,22 @@ export function useWorkspace() {
void listen<HostEvent>("workspace-bridge", (message) => {
if (disposed.current) return;
const host = message.payload;
const runtimeId =
host.kind === "event" ? host.event.data?.runtimeId : undefined;
if (
runtimeId &&
!stateRef.current.runtimesById[runtimeId] &&
[
"runtime_event",
"runtime_diagnostic",
"runtime_recovery",
"runtime_closing",
].includes(host.kind === "event" ? (host.event.type ?? "") : "")
)
return;
dispatch({ type: "hostEvent", host });
if (host.kind === "resetRequired") void refresh(undefined, true);
else if (host.kind === "event") {
const runtimeId = host.event.data?.runtimeId;
const eventType = host.event.type;
const piType = String(host.event.data?.eventData?.event?.type ?? "");
if (
@@ -177,7 +243,9 @@ export function useWorkspace() {
"runtime_opened",
"runtime_closed",
"runtime_recovered",
"runtime_renewed",
"runtime_restore_failed",
"runtime_promoted",
].includes(eventType ?? "")
) {
void refresh();
@@ -196,10 +264,17 @@ export function useWorkspace() {
});
void (async () => {
try {
await promotedListenerReady;
const loaded = await loadPresentation();
if (disposed.current) return;
await refresh(loaded);
if (disposed.current) return;
workspaceReady.current = true;
const runtimeId = await invoke<string | null>(
"get_pending_promoted_runtime",
).catch(() => null);
if (runtimeId) pendingPromotedRuntime.current = runtimeId;
consumePromotedRuntime();
initialized.current = true;
} catch (error) {
if (!disposed.current)
@@ -213,7 +288,6 @@ export function useWorkspace() {
});
}
})();
const appWindow = getCurrentWindow();
const visibility = () =>
dispatch({
type: "setWindowActive",
@@ -224,17 +298,18 @@ export function useWorkspace() {
document.addEventListener("visibilitychange", visibility);
return () => {
disposed.current = true;
workspaceReady.current = false;
refreshGeneration.current += 1;
stop?.();
stopNewRuntime?.();
stopPromotedRuntime?.();
void invoke("unsubscribe_workspace").catch(() => {});
window.removeEventListener("focus", visibility);
window.removeEventListener("blur", visibility);
document.removeEventListener("visibilitychange", visibility);
Object.values(timers.current).forEach(clearTimeout);
void appWindow;
};
}, [refresh, scheduleSnapshot]);
}, [consumePromotedRuntime, refresh, scheduleSnapshot]);
useEffect(() => {
if (!initialized.current) return;