Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 636ef5dcc2 | |||
| 56fe4e734d | |||
| 35719e0961 | |||
| 6385c20f91 | |||
| e181b2ab12 | |||
| 0949c6f0d7 | |||
| a4ee3eb7a8 | |||
| 087c177e8e | |||
| d448152e7f | |||
| 9345b67c9f | |||
| 35d5698f1a | |||
| ce8e2e1332 | |||
| a180ac9a2c | |||
| 5642d9be91 | |||
| 1ce03ef986 | |||
| 0eb163b6c4 | |||
| b44b1be4b7 | |||
| 0a318f5ab6 | |||
| 5e9074605d | |||
| e869a0bfcb | |||
| aa37a2f60e | |||
| 0da45b468e | |||
| 880cddf3cf | |||
| 60fb91f375 | |||
| 05874a0be1 | |||
| ad66067a31 | |||
| 4ec951bc05 | |||
| d34099771c | |||
| 0ec7d224a2 | |||
| 4fc9c60ade | |||
| 1b0b34fad8 | |||
| ab482d3daa | |||
| a204eea7da | |||
| bab754714e | |||
| 67063464e7 | |||
| dd23898769 | |||
| 612b146fd6 | |||
| 4f57114f19 | |||
| 166b065ad4 |
@@ -77,7 +77,7 @@ The request payload is the local protocol JSON. See [`DESIGN.md`](DESIGN.md) for
|
|||||||
node src/client/noctalia-relay-cli.js --socket "$PI_STATUS_BRIDGE_SOCKET"
|
node src/client/noctalia-relay-cli.js --socket "$PI_STATUS_BRIDGE_SOCKET"
|
||||||
```
|
```
|
||||||
|
|
||||||
The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests. `--agent <agent-id>` retains the former agent-scoped relay only for external compatibility callers.
|
The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests.
|
||||||
|
|
||||||
## Keep the bridge running in the background (recommended)
|
## Keep the bridge running in the background (recommended)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+408
-94
@@ -5,8 +5,11 @@ import {
|
|||||||
readdir,
|
readdir,
|
||||||
readFile,
|
readFile,
|
||||||
realpath,
|
realpath,
|
||||||
|
rename,
|
||||||
rm,
|
rm,
|
||||||
stat,
|
stat,
|
||||||
|
symlink,
|
||||||
|
unlink,
|
||||||
writeFile,
|
writeFile,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -51,8 +54,7 @@ async function readSessionReference(sessionDir) {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
sessionPath:
|
sessionPath:
|
||||||
typeof value?.sessionPath === "string" &&
|
typeof value?.sessionPath === "string" && path.isAbsolute(value.sessionPath)
|
||||||
path.isAbsolute(value.sessionPath)
|
|
||||||
? value.sessionPath
|
? value.sessionPath
|
||||||
: undefined,
|
: undefined,
|
||||||
worktreePath:
|
worktreePath:
|
||||||
@@ -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) {
|
function sessionPreview(content) {
|
||||||
if (typeof content === "string") return content;
|
if (typeof content === "string") return content;
|
||||||
if (!Array.isArray(content)) return "";
|
if (!Array.isArray(content)) return "";
|
||||||
@@ -110,11 +175,7 @@ async function readSessionSummary(sessionPath, currentPath) {
|
|||||||
firstMessage = sessionPreview(entry.message.content);
|
firstMessage = sessionPreview(entry.message.content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (!header || typeof header.id !== "string" || typeof header.cwd !== "string")
|
||||||
!header ||
|
|
||||||
typeof header.id !== "string" ||
|
|
||||||
typeof header.cwd !== "string"
|
|
||||||
)
|
|
||||||
return undefined;
|
return undefined;
|
||||||
return {
|
return {
|
||||||
path: sessionPath,
|
path: sessionPath,
|
||||||
@@ -250,6 +311,7 @@ export function createAgentRegistry({
|
|||||||
homeWorktree,
|
homeWorktree,
|
||||||
sessionRoot,
|
sessionRoot,
|
||||||
startAdapter = startPiRpcAdapter,
|
startAdapter = startPiRpcAdapter,
|
||||||
|
workspaceStoreFactory = createWorkspaceStore,
|
||||||
eventLimit = DEFAULT_EVENT_LIMIT,
|
eventLimit = DEFAULT_EVENT_LIMIT,
|
||||||
maxRecoveryAttempts,
|
maxRecoveryAttempts,
|
||||||
recoveryDelayForAttempt,
|
recoveryDelayForAttempt,
|
||||||
@@ -262,9 +324,11 @@ export function createAgentRegistry({
|
|||||||
throw new TypeError("sessionRoot must be an absolute path");
|
throw new TypeError("sessionRoot must be an absolute path");
|
||||||
if (typeof startAdapter !== "function")
|
if (typeof startAdapter !== "function")
|
||||||
throw new TypeError("startAdapter must be a 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 bridgeInstanceId = randomUUID();
|
||||||
const workspaceStore = createWorkspaceStore(sessionRoot);
|
const workspaceStore = workspaceStoreFactory(sessionRoot);
|
||||||
const runtimesByRuntimeId = new Map();
|
const runtimesByRuntimeId = new Map();
|
||||||
const runtimesByAgentId = new Map();
|
const runtimesByAgentId = new Map();
|
||||||
const runtimesByWorktreePath = new Map();
|
const runtimesByWorktreePath = new Map();
|
||||||
@@ -277,10 +341,13 @@ export function createAgentRegistry({
|
|||||||
const workspaceEvents = [];
|
const workspaceEvents = [];
|
||||||
let workspaceSequence = 0;
|
let workspaceSequence = 0;
|
||||||
let manifestIssue;
|
let manifestIssue;
|
||||||
|
let manifestWriteQueue = Promise.resolve();
|
||||||
let canonicalHomeWorktree;
|
let canonicalHomeWorktree;
|
||||||
|
let lastSuccessfulModelCatalog;
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
|
|
||||||
function publishWorkspace(type, runtime, data = {}) {
|
function publishWorkspace(type, runtime, data = {}) {
|
||||||
|
if (runtime?.ephemeral) return undefined;
|
||||||
const event = {
|
const event = {
|
||||||
version: "v1",
|
version: "v1",
|
||||||
bridgeInstanceId,
|
bridgeInstanceId,
|
||||||
@@ -309,7 +376,9 @@ export function createAgentRegistry({
|
|||||||
version: 2,
|
version: 2,
|
||||||
migrated: true,
|
migrated: true,
|
||||||
directories: [...managedWorktrees].sort(),
|
directories: [...managedWorktrees].sort(),
|
||||||
runtimes: [...runtimesByRuntimeId.values()].map((runtime) => ({
|
runtimes: [...runtimesByRuntimeId.values()]
|
||||||
|
.filter((runtime) => !runtime.ephemeral)
|
||||||
|
.map((runtime) => ({
|
||||||
runtimeId: runtime.runtimeId,
|
runtimeId: runtime.runtimeId,
|
||||||
worktreePath: runtime.worktreePath,
|
worktreePath: runtime.worktreePath,
|
||||||
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
||||||
@@ -320,13 +389,19 @@ export function createAgentRegistry({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistWorkspace() {
|
function persistWorkspace() {
|
||||||
|
const operation = manifestWriteQueue
|
||||||
|
.catch(() => {})
|
||||||
|
.then(async () => {
|
||||||
await workspaceStore.save(manifest());
|
await workspaceStore.save(manifest());
|
||||||
manifestIssue = undefined;
|
manifestIssue = undefined;
|
||||||
|
});
|
||||||
|
manifestWriteQueue = operation.catch(() => {});
|
||||||
|
return operation;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addWorktreeRuntime(runtime) {
|
function addWorktreeRuntime(runtime) {
|
||||||
managedWorktrees.add(runtime.worktreePath);
|
if (!runtime.ephemeral) managedWorktrees.add(runtime.worktreePath);
|
||||||
let runtimes = runtimesByWorktreePath.get(runtime.worktreePath);
|
let runtimes = runtimesByWorktreePath.get(runtime.worktreePath);
|
||||||
if (!runtimes) {
|
if (!runtimes) {
|
||||||
runtimes = new Set();
|
runtimes = new Set();
|
||||||
@@ -380,8 +455,7 @@ export function createAgentRegistry({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enqueue(runtime, operation) {
|
function enqueue(runtime, operation) {
|
||||||
if (stopping)
|
if (stopping) return Promise.reject(new Error("agent registry is stopping"));
|
||||||
return Promise.reject(new Error("agent registry is stopping"));
|
|
||||||
if (
|
if (
|
||||||
runtime.state === "closing" ||
|
runtime.state === "closing" ||
|
||||||
runtime.state === "stopped" ||
|
runtime.state === "stopped" ||
|
||||||
@@ -420,10 +494,7 @@ export function createAgentRegistry({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code !== "ENOENT" || !allowMissing) throw error;
|
if (error?.code !== "ENOENT" || !allowMissing) throw error;
|
||||||
const resolvedParent = await realpath(path.dirname(sessionPath));
|
const resolvedParent = await realpath(path.dirname(sessionPath));
|
||||||
resolvedSessionPath = path.join(
|
resolvedSessionPath = path.join(resolvedParent, path.basename(sessionPath));
|
||||||
resolvedParent,
|
|
||||||
path.basename(sessionPath),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const relative = path.relative(resolvedSessionDir, resolvedSessionPath);
|
const relative = path.relative(resolvedSessionDir, resolvedSessionPath);
|
||||||
if (
|
if (
|
||||||
@@ -436,10 +507,23 @@ export function createAgentRegistry({
|
|||||||
return resolvedSessionPath;
|
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(
|
async function updateRuntimeIdentity(
|
||||||
runtime,
|
runtime,
|
||||||
stateResponse,
|
stateResponse,
|
||||||
{ expectedSessionPath, adapter = runtime.adapter } = {},
|
{
|
||||||
|
expectedSessionPath,
|
||||||
|
adapter = runtime.adapter,
|
||||||
|
persist = true,
|
||||||
|
indexSession = true,
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
runtime.stopped ||
|
runtime.stopped ||
|
||||||
@@ -461,9 +545,7 @@ export function createAgentRegistry({
|
|||||||
resolved = candidate;
|
resolved = candidate;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code !== "ENOENT")
|
if (error?.code !== "ENOENT")
|
||||||
throw new Error(
|
throw new Error("Pi reported a session outside its managed directory");
|
||||||
"Pi reported a session outside its managed directory",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (expectedSessionPath && resolved !== expectedSessionPath)
|
if (expectedSessionPath && resolved !== expectedSessionPath)
|
||||||
@@ -483,18 +565,26 @@ export function createAgentRegistry({
|
|||||||
throw new Error("session runtime is closing");
|
throw new Error("session runtime is closing");
|
||||||
if (typeof state.sessionName === "string")
|
if (typeof state.sessionName === "string")
|
||||||
runtime.sessionName = state.sessionName;
|
runtime.sessionName = state.sessionName;
|
||||||
if (typeof state.sessionId === "string")
|
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
|
||||||
runtime.sessionId = state.sessionId;
|
if (resolved && !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 (resolved) {
|
||||||
|
if (indexSession) {
|
||||||
if (
|
if (
|
||||||
runtime.sessionPath &&
|
runtime.sessionPath &&
|
||||||
runtime.sessionPath !== resolved &&
|
runtime.sessionPath !== resolved &&
|
||||||
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
||||||
)
|
)
|
||||||
runtimesBySessionPath.delete(runtime.sessionPath);
|
runtimesBySessionPath.delete(runtime.sessionPath);
|
||||||
runtime.sessionPath = resolved;
|
|
||||||
runtimesBySessionPath.set(resolved, runtime);
|
runtimesBySessionPath.set(resolved, runtime);
|
||||||
releaseSessionReservation(resolved, runtime);
|
releaseSessionReservation(resolved, runtime);
|
||||||
|
}
|
||||||
|
runtime.sessionPath = resolved;
|
||||||
if (runtime.legacyDefault)
|
if (runtime.legacyDefault)
|
||||||
await persistSessionReference(
|
await persistSessionReference(
|
||||||
runtime.sessionDir,
|
runtime.sessionDir,
|
||||||
@@ -502,7 +592,16 @@ export function createAgentRegistry({
|
|||||||
runtime.worktreePath,
|
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) {
|
function publishAgent(runtime, type, data) {
|
||||||
@@ -532,12 +631,8 @@ export function createAgentRegistry({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (type === "queue") {
|
if (type === "queue") {
|
||||||
const followUp = Array.isArray(event.followUp)
|
const followUp = Array.isArray(event.followUp) ? event.followUp.length : 0;
|
||||||
? event.followUp.length
|
const steering = Array.isArray(event.steering) ? event.steering.length : 0;
|
||||||
: 0;
|
|
||||||
const steering = Array.isArray(event.steering)
|
|
||||||
? event.steering.length
|
|
||||||
: 0;
|
|
||||||
runtime.queueCount =
|
runtime.queueCount =
|
||||||
typeof event.pendingMessageCount === "number"
|
typeof event.pendingMessageCount === "number"
|
||||||
? event.pendingMessageCount
|
? event.pendingMessageCount
|
||||||
@@ -553,8 +648,7 @@ export function createAgentRegistry({
|
|||||||
const method = event.method;
|
const method = event.method;
|
||||||
if (["select", "confirm", "input", "editor"].includes(method)) {
|
if (["select", "confirm", "input", "editor"].includes(method)) {
|
||||||
runtime.attention = true;
|
runtime.attention = true;
|
||||||
if (typeof event.id === "string")
|
if (typeof event.id === "string") runtime.extensions.set(event.id, event);
|
||||||
runtime.extensions.set(event.id, event);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const message = event.message;
|
const message = event.message;
|
||||||
@@ -573,6 +667,7 @@ export function createAgentRegistry({
|
|||||||
sessionPath,
|
sessionPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
legacyDefault = false,
|
legacyDefault = false,
|
||||||
|
ephemeral = false,
|
||||||
openedAt = new Date().toISOString(),
|
openedAt = new Date().toISOString(),
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
@@ -583,6 +678,7 @@ export function createAgentRegistry({
|
|||||||
sessionPath,
|
sessionPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
legacyDefault,
|
legacyDefault,
|
||||||
|
ephemeral,
|
||||||
openedAt,
|
openedAt,
|
||||||
state: "starting",
|
state: "starting",
|
||||||
stateVersion: 0,
|
stateVersion: 0,
|
||||||
@@ -599,6 +695,8 @@ export function createAgentRegistry({
|
|||||||
adapter: undefined,
|
adapter: undefined,
|
||||||
supervisor: undefined,
|
supervisor: undefined,
|
||||||
stopped: false,
|
stopped: false,
|
||||||
|
promoting: false,
|
||||||
|
promotionPromise: undefined,
|
||||||
recoveryPromise: undefined,
|
recoveryPromise: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -624,8 +722,7 @@ export function createAgentRegistry({
|
|||||||
const state = await adapter.send({ type: "get_state" });
|
const state = await adapter.send({ type: "get_state" });
|
||||||
await updateRuntimeIdentity(runtime, state, { adapter });
|
await updateRuntimeIdentity(runtime, state, { adapter });
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (runtime.stopped || runtime.state === "closing" || stopping)
|
if (runtime.stopped || runtime.state === "closing" || stopping) return;
|
||||||
return;
|
|
||||||
runtime.error = {
|
runtime.error = {
|
||||||
code: "identity_refresh",
|
code: "identity_refresh",
|
||||||
message: error.message,
|
message: error.message,
|
||||||
@@ -719,13 +816,16 @@ export function createAgentRegistry({
|
|||||||
sessionPath,
|
sessionPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
legacyDefault = false,
|
legacyDefault = false,
|
||||||
|
ephemeral = false,
|
||||||
openedAt,
|
openedAt,
|
||||||
},
|
},
|
||||||
{ restore = false } = {},
|
{ restore = false } = {},
|
||||||
) {
|
) {
|
||||||
if (stopping) throw new Error("agent registry is stopping");
|
if (stopping) throw new Error("agent registry is stopping");
|
||||||
const canonicalPath = await realpath(worktreePath);
|
const canonicalPath = await realpath(worktreePath);
|
||||||
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath);
|
const sessionDir = ephemeral
|
||||||
|
? path.join(sessionRoot, "quick", runtimeId)
|
||||||
|
: sessionDirectoryFor(sessionRoot, canonicalPath);
|
||||||
await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE });
|
await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE });
|
||||||
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
|
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
|
||||||
let ownedPath;
|
let ownedPath;
|
||||||
@@ -757,6 +857,7 @@ export function createAgentRegistry({
|
|||||||
sessionPath: ownedPath,
|
sessionPath: ownedPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
legacyDefault,
|
legacyDefault,
|
||||||
|
ephemeral,
|
||||||
openedAt,
|
openedAt,
|
||||||
});
|
});
|
||||||
if (stopping) throw new Error("agent registry is stopping");
|
if (stopping) throw new Error("agent registry is stopping");
|
||||||
@@ -778,7 +879,7 @@ export function createAgentRegistry({
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await launch(runtime);
|
await launch(runtime);
|
||||||
await persistWorkspace();
|
if (!runtime.ephemeral) await persistWorkspace();
|
||||||
publishWorkspace("runtime_opened", runtime, {
|
publishWorkspace("runtime_opened", runtime, {
|
||||||
runtime: publicRuntime(runtime),
|
runtime: publicRuntime(runtime),
|
||||||
});
|
});
|
||||||
@@ -829,8 +930,7 @@ export function createAgentRegistry({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openRuntime(record, options) {
|
function openRuntime(record, options) {
|
||||||
if (stopping)
|
if (stopping) return Promise.reject(new Error("agent registry is stopping"));
|
||||||
return Promise.reject(new Error("agent registry is stopping"));
|
|
||||||
const operation = openRuntimeInternal(record, options);
|
const operation = openRuntimeInternal(record, options);
|
||||||
pendingOpenOperations.add(operation);
|
pendingOpenOperations.add(operation);
|
||||||
void operation
|
void operation
|
||||||
@@ -867,14 +967,8 @@ export function createAgentRegistry({
|
|||||||
let canonicalSession;
|
let canonicalSession;
|
||||||
if (record.sessionPath) {
|
if (record.sessionPath) {
|
||||||
const canonicalWorktree = await realpath(record.worktreePath);
|
const canonicalWorktree = await realpath(record.worktreePath);
|
||||||
const sessionDir = sessionDirectoryFor(
|
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalWorktree);
|
||||||
sessionRoot,
|
canonicalSession = await ownedSessionPath(sessionDir, record.sessionPath);
|
||||||
canonicalWorktree,
|
|
||||||
);
|
|
||||||
canonicalSession = await ownedSessionPath(
|
|
||||||
sessionDir,
|
|
||||||
record.sessionPath,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
prepared.push({ record, canonicalSession });
|
prepared.push({ record, canonicalSession });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -974,7 +1068,7 @@ export function createAgentRegistry({
|
|||||||
// Remove desired-open/UI intent now, but retain the session lease until
|
// Remove desired-open/UI intent now, but retain the session lease until
|
||||||
// the old child can no longer write to its JSONL file.
|
// the old child can no longer write to its JSONL file.
|
||||||
unindexRuntime(runtime, { releaseSessionLease: false });
|
unindexRuntime(runtime, { releaseSessionLease: false });
|
||||||
await persistWorkspace();
|
if (!runtime.ephemeral) await persistWorkspace();
|
||||||
runtime.stopped = true;
|
runtime.stopped = true;
|
||||||
runtime.supervisor?.stop();
|
runtime.supervisor?.stop();
|
||||||
let stopConfirmed = true;
|
let stopConfirmed = true;
|
||||||
@@ -1002,6 +1096,10 @@ export function createAgentRegistry({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (stopConfirmed) releaseClosedSessionLease(runtime);
|
if (stopConfirmed) releaseClosedSessionLease(runtime);
|
||||||
|
if (runtime.ephemeral && stopConfirmed)
|
||||||
|
await rm(runtime.sessionDir, { recursive: true, force: true }).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
runtime.state = "stopped";
|
runtime.state = "stopped";
|
||||||
publishWorkspace("runtime_closed", runtime, {
|
publishWorkspace("runtime_closed", runtime, {
|
||||||
runtimeId: runtime.runtimeId,
|
runtimeId: runtime.runtimeId,
|
||||||
@@ -1012,6 +1110,8 @@ export function createAgentRegistry({
|
|||||||
|
|
||||||
function closeRuntime(runtimeId) {
|
function closeRuntime(runtimeId) {
|
||||||
const runtime = getRuntime(runtimeId);
|
const runtime = getRuntime(runtimeId);
|
||||||
|
if (runtime.promoting)
|
||||||
|
return runtime.promotionPromise.then(() => closeRuntime(runtimeId));
|
||||||
if (runtime.state === "closing" || runtime.state === "stopped")
|
if (runtime.state === "closing" || runtime.state === "stopped")
|
||||||
return Promise.reject(new Error("session runtime is closing"));
|
return Promise.reject(new Error("session runtime is closing"));
|
||||||
const wasWorkingAtRequest =
|
const wasWorkingAtRequest =
|
||||||
@@ -1030,6 +1130,136 @@ export function createAgentRegistry({
|
|||||||
return result;
|
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(
|
async function refreshAfterLegacySessionChange(
|
||||||
runtime,
|
runtime,
|
||||||
response,
|
response,
|
||||||
@@ -1057,6 +1287,13 @@ export function createAgentRegistry({
|
|||||||
runtime.sessionId = undefined;
|
runtime.sessionId = undefined;
|
||||||
runtime.sessionName = undefined;
|
runtime.sessionName = undefined;
|
||||||
runtime.firstMessage = undefined;
|
runtime.firstMessage = undefined;
|
||||||
|
runtime.extensions.clear();
|
||||||
|
runtime.queueCount = 0;
|
||||||
|
runtime.activeTool = undefined;
|
||||||
|
runtime.error = undefined;
|
||||||
|
runtime.errorAttention = false;
|
||||||
|
runtime.attention = false;
|
||||||
|
runtime.lastActivity = new Date().toISOString();
|
||||||
if (runtime.legacyDefault)
|
if (runtime.legacyDefault)
|
||||||
await persistSessionReference(
|
await persistSessionReference(
|
||||||
runtime.sessionDir,
|
runtime.sessionDir,
|
||||||
@@ -1066,6 +1303,12 @@ export function createAgentRegistry({
|
|||||||
await persistWorkspace();
|
await persistWorkspace();
|
||||||
const state = await runtime.adapter.send({ type: "get_state" });
|
const state = await runtime.adapter.send({ type: "get_state" });
|
||||||
await updateRuntimeIdentity(runtime, state, { adapter: runtime.adapter });
|
await updateRuntimeIdentity(runtime, state, { adapter: runtime.adapter });
|
||||||
|
runtime.state = state?.data?.isStreaming ? "streaming" : "idle";
|
||||||
|
runtime.stateVersion += 1;
|
||||||
|
publishAgent(runtime, "agent_state", { state: runtime.state });
|
||||||
|
publishWorkspace("runtime_renewed", runtime, {
|
||||||
|
runtime: publicRuntime(runtime),
|
||||||
|
});
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,6 +1330,7 @@ export function createAgentRegistry({
|
|||||||
[...managedWorktrees].map((worktreePath) => [worktreePath, []]),
|
[...managedWorktrees].map((worktreePath) => [worktreePath, []]),
|
||||||
);
|
);
|
||||||
for (const runtime of runtimesByRuntimeId.values()) {
|
for (const runtime of runtimesByRuntimeId.values()) {
|
||||||
|
if (runtime.ephemeral) continue;
|
||||||
let group = grouped.get(runtime.worktreePath);
|
let group = grouped.get(runtime.worktreePath);
|
||||||
if (!group) {
|
if (!group) {
|
||||||
group = [];
|
group = [];
|
||||||
@@ -1105,17 +1349,14 @@ export function createAgentRegistry({
|
|||||||
workingCount: runtimes.filter((entry) => entry.state === "streaming")
|
workingCount: runtimes.filter((entry) => entry.state === "streaming")
|
||||||
.length,
|
.length,
|
||||||
attentionCount: runtimes.filter((entry) => entry.attention).length,
|
attentionCount: runtimes.filter((entry) => entry.attention).length,
|
||||||
recoveringCount: runtimes.filter(
|
recoveringCount: runtimes.filter((entry) => entry.state === "recovering")
|
||||||
(entry) => entry.state === "recovering",
|
.length,
|
||||||
).length,
|
|
||||||
errorCount: runtimes.filter((entry) =>
|
errorCount: runtimes.filter((entry) =>
|
||||||
["error", "failed"].includes(entry.state),
|
["error", "failed"].includes(entry.state),
|
||||||
).length,
|
).length,
|
||||||
runtimes,
|
runtimes,
|
||||||
}))
|
}))
|
||||||
.sort((left, right) =>
|
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath)),
|
||||||
left.worktreePath.localeCompare(right.worktreePath),
|
|
||||||
),
|
|
||||||
...(manifestIssue ? { issue: manifestIssue } : {}),
|
...(manifestIssue ? { issue: manifestIssue } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1130,10 +1371,7 @@ export function createAgentRegistry({
|
|||||||
const loaded = await workspaceStore.load();
|
const loaded = await workspaceStore.load();
|
||||||
manifestIssue = loaded.issue;
|
manifestIssue = loaded.issue;
|
||||||
if (loaded.migrating) {
|
if (loaded.migrating) {
|
||||||
const sessionDir = sessionDirectoryFor(
|
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalHomeWorktree);
|
||||||
sessionRoot,
|
|
||||||
canonicalHomeWorktree,
|
|
||||||
);
|
|
||||||
await mkdir(sessionDir, {
|
await mkdir(sessionDir, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
mode: SESSION_DIRECTORY_MODE,
|
mode: SESSION_DIRECTORY_MODE,
|
||||||
@@ -1143,9 +1381,7 @@ export function createAgentRegistry({
|
|||||||
runtimeId: randomUUID(),
|
runtimeId: randomUUID(),
|
||||||
worktreePath: canonicalHomeWorktree,
|
worktreePath: canonicalHomeWorktree,
|
||||||
legacyDefault: true,
|
legacyDefault: true,
|
||||||
...(reference.sessionPath
|
...(reference.sessionPath ? { sessionPath: reference.sessionPath } : {}),
|
||||||
? { sessionPath: reference.sessionPath }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
if (reference.sessionPath) await restoreRecords([migrationRecord]);
|
if (reference.sessionPath) await restoreRecords([migrationRecord]);
|
||||||
else await openRuntime(migrationRecord);
|
else await openRuntime(migrationRecord);
|
||||||
@@ -1170,6 +1406,15 @@ export function createAgentRegistry({
|
|||||||
});
|
});
|
||||||
await restoreRecords(records);
|
await restoreRecords(records);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!loaded.issue &&
|
||||||
|
![...runtimesByAgentId.values()].some((runtime) => runtime.adapter)
|
||||||
|
) {
|
||||||
|
await openRuntime({
|
||||||
|
worktreePath: canonicalHomeWorktree,
|
||||||
|
legacyDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
return this.listAgents()[0];
|
return this.listAgents()[0];
|
||||||
},
|
},
|
||||||
async selectWorktree(worktreePath) {
|
async selectWorktree(worktreePath) {
|
||||||
@@ -1179,8 +1424,7 @@ export function createAgentRegistry({
|
|||||||
...(runtimesByWorktreePath.get(canonicalPath) ?? []),
|
...(runtimesByWorktreePath.get(canonicalPath) ?? []),
|
||||||
].filter((runtime) => runtime.agentId);
|
].filter((runtime) => runtime.agentId);
|
||||||
const existing =
|
const existing =
|
||||||
liveRuntimes.find((runtime) => runtime.legacyDefault) ??
|
liveRuntimes.find((runtime) => runtime.legacyDefault) ?? liveRuntimes[0];
|
||||||
liveRuntimes[0];
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!existing.legacyDefault) {
|
if (!existing.legacyDefault) {
|
||||||
await setLegacyDefault(existing);
|
await setLegacyDefault(existing);
|
||||||
@@ -1197,25 +1441,62 @@ export function createAgentRegistry({
|
|||||||
const opened = await openRuntime({
|
const opened = await openRuntime({
|
||||||
worktreePath: canonicalPath,
|
worktreePath: canonicalPath,
|
||||||
legacyDefault: true,
|
legacyDefault: true,
|
||||||
...(reference.sessionPath
|
...(reference.sessionPath ? { sessionPath: reference.sessionPath } : {}),
|
||||||
? { sessionPath: reference.sessionPath }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
return publicAgent(getRuntime(opened.runtimeId));
|
return publicAgent(getRuntime(opened.runtimeId));
|
||||||
},
|
},
|
||||||
async createSessionRuntime(worktreePath) {
|
async createSessionRuntime(worktreePath) {
|
||||||
return openRuntime({ worktreePath });
|
return openRuntime({ worktreePath });
|
||||||
},
|
},
|
||||||
|
async createQuickRuntime(worktreePath) {
|
||||||
|
return openRuntime({ worktreePath, ephemeral: true });
|
||||||
|
},
|
||||||
|
promoteQuickRuntime,
|
||||||
async openSessionRuntime(worktreePath, sessionPath) {
|
async openSessionRuntime(worktreePath, sessionPath) {
|
||||||
return openRuntime({ worktreePath, sessionPath });
|
return openRuntime({ worktreePath, sessionPath });
|
||||||
},
|
},
|
||||||
closeSessionRuntime: closeRuntime,
|
closeSessionRuntime: closeRuntime,
|
||||||
|
async closeQuickRuntime(runtimeId) {
|
||||||
|
const runtime = getRuntime(runtimeId);
|
||||||
|
if (runtime.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() {
|
listAgents() {
|
||||||
return [...runtimesByAgentId.values()]
|
return [...runtimesByAgentId.values()]
|
||||||
.map(publicAgent)
|
.map(publicAgent)
|
||||||
.sort((left, right) =>
|
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath));
|
||||||
left.worktreePath.localeCompare(right.worktreePath),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
async listDirectories() {
|
async listDirectories() {
|
||||||
let entries = [];
|
let entries = [];
|
||||||
@@ -1240,9 +1521,9 @@ export function createAgentRegistry({
|
|||||||
]);
|
]);
|
||||||
return [...paths]
|
return [...paths]
|
||||||
.map((worktreePath) => {
|
.map((worktreePath) => {
|
||||||
const runtime = [
|
const runtime = [...(runtimesByWorktreePath.get(worktreePath) ?? [])].find(
|
||||||
...(runtimesByWorktreePath.get(worktreePath) ?? []),
|
(entry) => entry.agentId,
|
||||||
].find((entry) => entry.agentId);
|
);
|
||||||
return {
|
return {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
state: runtime?.state ?? "inactive",
|
state: runtime?.state ?? "inactive",
|
||||||
@@ -1250,9 +1531,7 @@ export function createAgentRegistry({
|
|||||||
...(runtime ? { agentId: runtime.agentId } : {}),
|
...(runtime ? { agentId: runtime.agentId } : {}),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((left, right) =>
|
.sort((left, right) => left.worktreePath.localeCompare(right.worktreePath));
|
||||||
left.worktreePath.localeCompare(right.worktreePath),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
async forgetDirectory(worktreePath) {
|
async forgetDirectory(worktreePath) {
|
||||||
let canonicalPath = path.normalize(worktreePath);
|
let canonicalPath = path.normalize(worktreePath);
|
||||||
@@ -1292,14 +1571,15 @@ export function createAgentRegistry({
|
|||||||
},
|
},
|
||||||
getWorkspace: workspace,
|
getWorkspace: workspace,
|
||||||
getWorkspaceSummary() {
|
getWorkspaceSummary() {
|
||||||
const runtimes = [...runtimesByRuntimeId.values()];
|
const runtimes = [...runtimesByRuntimeId.values()].filter(
|
||||||
|
(runtime) => !runtime.ephemeral,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
bridgeInstanceId,
|
bridgeInstanceId,
|
||||||
latestSeq: workspaceSequence,
|
latestSeq: workspaceSequence,
|
||||||
openCount: runtimes.length,
|
openCount: runtimes.length,
|
||||||
workingCount: runtimes.filter(
|
workingCount: runtimes.filter((runtime) => runtime.state === "streaming")
|
||||||
(runtime) => runtime.state === "streaming",
|
.length,
|
||||||
).length,
|
|
||||||
attentionCount: runtimes.filter((runtime) => runtime.attention).length,
|
attentionCount: runtimes.filter((runtime) => runtime.attention).length,
|
||||||
recoveringCount: runtimes.filter(
|
recoveringCount: runtimes.filter(
|
||||||
(runtime) => runtime.state === "recovering",
|
(runtime) => runtime.state === "recovering",
|
||||||
@@ -1323,11 +1603,11 @@ export function createAgentRegistry({
|
|||||||
};
|
};
|
||||||
return enqueue(runtime, async () => {
|
return enqueue(runtime, async () => {
|
||||||
const adapter = runtime.adapter;
|
const adapter = runtime.adapter;
|
||||||
const safe = async (command, fallback) => {
|
const safe = async (command, fallback, { includeError = false } = {}) => {
|
||||||
try {
|
try {
|
||||||
return await adapter.send({ type: command });
|
return await adapter.send({ type: command });
|
||||||
} catch {
|
} catch (error) {
|
||||||
return fallback;
|
return includeError ? { ...fallback, error: String(error) } : fallback;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const state = await safe("get_state", { data: {} });
|
const state = await safe("get_state", { data: {} });
|
||||||
@@ -1336,9 +1616,11 @@ export function createAgentRegistry({
|
|||||||
data: { messages: [] },
|
data: { messages: [] },
|
||||||
});
|
});
|
||||||
const commands = await safe("get_commands", { data: { commands: [] } });
|
const commands = await safe("get_commands", { data: { commands: [] } });
|
||||||
const models = await safe("get_available_models", {
|
const models = await safe(
|
||||||
data: { models: [] },
|
"get_available_models",
|
||||||
});
|
{ data: { models: [] } },
|
||||||
|
{ includeError: true },
|
||||||
|
);
|
||||||
await updateRuntimeIdentity(runtime, state, { adapter });
|
await updateRuntimeIdentity(runtime, state, { adapter });
|
||||||
return {
|
return {
|
||||||
bridgeInstanceId,
|
bridgeInstanceId,
|
||||||
@@ -1392,10 +1674,23 @@ export function createAgentRegistry({
|
|||||||
return publicAgent(runtime);
|
return publicAgent(runtime);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async renewSession(agentId) {
|
||||||
|
const runtime = getAgent(agentId);
|
||||||
|
return enqueue(runtime, async () => {
|
||||||
|
if (!runtime.adapter) throw new Error("Pi is not currently running");
|
||||||
|
if (runtime.state === "streaming" || runtime.queueCount > 0)
|
||||||
|
await runtime.adapter.send({ type: "abort" });
|
||||||
|
const response = await beginLegacyNewSession(runtime);
|
||||||
|
if (response?.data?.cancelled)
|
||||||
|
throw new Error("Pi cancelled the session renewal");
|
||||||
|
return publicAgent(runtime);
|
||||||
|
});
|
||||||
|
},
|
||||||
async restart(agentId) {
|
async restart(agentId) {
|
||||||
const runtime = getAgent(agentId);
|
const runtime = getAgent(agentId);
|
||||||
return enqueue(runtime, async () => {
|
return enqueue(runtime, async () => {
|
||||||
const replacedAdapter = runtime.adapter;
|
const replacedAdapter = runtime.adapter;
|
||||||
|
if (!replacedAdapter) throw new Error("Pi is not currently running");
|
||||||
// Detach the old child before stopping it so late events cannot mutate
|
// Detach the old child before stopping it so late events cannot mutate
|
||||||
// presentation state that belongs to the replacement generation.
|
// presentation state that belongs to the replacement generation.
|
||||||
runtime.adapter = undefined;
|
runtime.adapter = undefined;
|
||||||
@@ -1406,13 +1701,36 @@ export function createAgentRegistry({
|
|||||||
runtime.attention = runtime.errorAttention;
|
runtime.attention = runtime.errorAttention;
|
||||||
runtime.supervisor.markHealthy();
|
runtime.supervisor.markHealthy();
|
||||||
runtime.recoveryPromise = runtime.supervisor.handleUnexpectedExit();
|
runtime.recoveryPromise = runtime.supervisor.handleUnexpectedExit();
|
||||||
await runtime.recoveryPromise;
|
const nextAdapter = await runtime.recoveryPromise;
|
||||||
|
if (!nextAdapter) {
|
||||||
|
runtime.state = "failed";
|
||||||
|
runtime.stateVersion += 1;
|
||||||
|
runtime.error = {
|
||||||
|
code: "restart_failed",
|
||||||
|
message: "Pi did not restart after the recovery attempts were exhausted",
|
||||||
|
};
|
||||||
|
runtime.errorAttention = true;
|
||||||
|
runtime.attention = true;
|
||||||
|
publishAgent(runtime, "agent_state", {
|
||||||
|
state: runtime.state,
|
||||||
|
error: runtime.error,
|
||||||
|
});
|
||||||
|
throw new Error(runtime.error.message);
|
||||||
|
}
|
||||||
|
runtime.error = undefined;
|
||||||
|
runtime.errorAttention = false;
|
||||||
|
runtime.attention = runtime.extensions.size > 0;
|
||||||
|
publishAgent(runtime, "agent_state", { state: runtime.state });
|
||||||
|
publishWorkspace("runtime_recovered", runtime, {
|
||||||
|
runtime: publicRuntime(runtime),
|
||||||
|
});
|
||||||
return publicAgent(runtime);
|
return publicAgent(runtime);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async route(agentId, operation, payload = {}) {
|
async route(agentId, operation, payload = {}) {
|
||||||
const runtime = getAgent(agentId);
|
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 () => {
|
return enqueue(runtime, async () => {
|
||||||
if (operation === "switch_session") {
|
if (operation === "switch_session") {
|
||||||
if (runtime.state === "streaming")
|
if (runtime.state === "streaming")
|
||||||
@@ -1458,9 +1776,7 @@ export function createAgentRegistry({
|
|||||||
operation === "extension_response" &&
|
operation === "extension_response" &&
|
||||||
!runtime.extensions.has(payload.requestId)
|
!runtime.extensions.has(payload.requestId)
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error("extension request is not actionable for this runtime");
|
||||||
"extension request is not actionable for this runtime",
|
|
||||||
);
|
|
||||||
const response = await routeCommand(
|
const response = await routeCommand(
|
||||||
runtime.adapter,
|
runtime.adapter,
|
||||||
operation,
|
operation,
|
||||||
@@ -1473,8 +1789,7 @@ export function createAgentRegistry({
|
|||||||
}
|
}
|
||||||
if (operation === "extension_response") {
|
if (operation === "extension_response") {
|
||||||
runtime.extensions.delete(payload.requestId);
|
runtime.extensions.delete(payload.requestId);
|
||||||
runtime.attention =
|
runtime.attention = runtime.errorAttention || runtime.extensions.size > 0;
|
||||||
runtime.errorAttention || runtime.extensions.size > 0;
|
|
||||||
}
|
}
|
||||||
return delivery ? { ...response, delivery } : response;
|
return delivery ? { ...response, delivery } : response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1491,7 +1806,7 @@ export function createAgentRegistry({
|
|||||||
async stop() {
|
async stop() {
|
||||||
stopping = true;
|
stopping = true;
|
||||||
while (pendingOpenOperations.size > 0)
|
while (pendingOpenOperations.size > 0)
|
||||||
await Promise.allSettled([...pendingOpenOperations]);
|
await Promise.allSettled(pendingOpenOperations);
|
||||||
const runtimes = [...runtimesByRuntimeId.values()];
|
const runtimes = [...runtimesByRuntimeId.values()];
|
||||||
await Promise.all(runtimes.map((runtime) => runtime.operationQueue));
|
await Promise.all(runtimes.map((runtime) => runtime.operationQueue));
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -1507,5 +1822,4 @@ export function createAgentRegistry({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sessionDirectoryMode = SESSION_DIRECTORY_MODE;
|
|
||||||
export const sessionDirectoryPath = sessionDirectoryFor;
|
export const sessionDirectoryPath = sessionDirectoryFor;
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import { startBridgeService } from "./service.js";
|
import { startBridgeService } from "./service.js";
|
||||||
|
|
||||||
export async function startBridgeDaemon(options) {
|
|
||||||
return startBridgeService(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runBridgeDaemon(options) {
|
export async function runBridgeDaemon(options) {
|
||||||
const daemon = await startBridgeDaemon(options);
|
const daemon = await startBridgeService(options);
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
if (stopping) return;
|
if (stopping) return;
|
||||||
stopping = true;
|
stopping = true;
|
||||||
await daemon.close();
|
await daemon.close();
|
||||||
};
|
};
|
||||||
process.stdout.write(
|
process.stdout.write(`${JSON.stringify({ socketPath: daemon.socketPath })}\n`);
|
||||||
`${JSON.stringify({ socketPath: daemon.socketPath })}\n`,
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const shutdown = async () => {
|
const shutdown = async () => {
|
||||||
await stop();
|
await stop();
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ function normalizePiEvent(event) {
|
|||||||
case "extension_ui_request":
|
case "extension_ui_request":
|
||||||
return { type: "extension_ui_request", data: { event } };
|
return { type: "extension_ui_request", data: { event } };
|
||||||
case "extension_error":
|
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 } };
|
return { type: "transcript", data: { event } };
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -209,6 +210,7 @@ export function startPiRpcAdapter({
|
|||||||
"unsupported_event",
|
"unsupported_event",
|
||||||
`Pi RPC emitted unsupported event: ${event.type}`,
|
`Pi RPC emitted unsupported event: ${event.type}`,
|
||||||
),
|
),
|
||||||
|
{ terminal: false },
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -229,7 +231,6 @@ export function startPiRpcAdapter({
|
|||||||
"frame_too_large",
|
"frame_too_large",
|
||||||
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
|
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
|
||||||
),
|
),
|
||||||
{ terminal: false },
|
|
||||||
);
|
);
|
||||||
const consumeStdout = (text) => {
|
const consumeStdout = (text) => {
|
||||||
let remaining = text;
|
let remaining = text;
|
||||||
@@ -303,10 +304,7 @@ export function startPiRpcAdapter({
|
|||||||
send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) {
|
send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) {
|
||||||
if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
|
if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new PiRpcError(
|
new PiRpcError("invalid_command", "Pi RPC command must include a type"),
|
||||||
"invalid_command",
|
|
||||||
"Pi RPC command must include a type",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (Object.hasOwn(commandInput, "id")) {
|
if (Object.hasOwn(commandInput, "id")) {
|
||||||
@@ -382,10 +380,7 @@ export function startPiRpcAdapter({
|
|||||||
if (closed) return exited;
|
if (closed) return exited;
|
||||||
intentionalStop = true;
|
intentionalStop = true;
|
||||||
rejectPending(
|
rejectPending(
|
||||||
new PiRpcError(
|
new PiRpcError("child_stopped", "Pi RPC child was stopped by the bridge"),
|
||||||
"child_stopped",
|
|
||||||
"Pi RPC child was stopped by the bridge",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
child.kill("SIGTERM");
|
child.kill("SIGTERM");
|
||||||
return exited;
|
return exited;
|
||||||
|
|||||||
@@ -66,5 +66,3 @@ export async function startBridgeServer({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runtimeDirectoryMode = RUNTIME_DIRECTORY_MODE;
|
|
||||||
|
|||||||
@@ -40,12 +40,24 @@ export async function startBridgeService({
|
|||||||
return registry.getWorkspace();
|
return registry.getWorkspace();
|
||||||
case "get_workspace_summary":
|
case "get_workspace_summary":
|
||||||
return registry.getWorkspaceSummary();
|
return registry.getWorkspaceSummary();
|
||||||
|
case "get_model_catalog":
|
||||||
|
return { models: await registry.getModelCatalog() };
|
||||||
case "create_session_runtime":
|
case "create_session_runtime":
|
||||||
return {
|
return {
|
||||||
runtime: await registry.createSessionRuntime(
|
runtime: await registry.createSessionRuntime(
|
||||||
request.payload.worktreePath,
|
request.payload.worktreePath,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
case "create_quick_runtime":
|
||||||
|
return {
|
||||||
|
runtime: await registry.createQuickRuntime(
|
||||||
|
request.payload.worktreePath,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "promote_quick_runtime":
|
||||||
|
return {
|
||||||
|
runtime: await registry.promoteQuickRuntime(request.payload.runtimeId),
|
||||||
|
};
|
||||||
case "open_session_runtime":
|
case "open_session_runtime":
|
||||||
return {
|
return {
|
||||||
runtime: await registry.openSessionRuntime(
|
runtime: await registry.openSessionRuntime(
|
||||||
@@ -55,6 +67,8 @@ export async function startBridgeService({
|
|||||||
};
|
};
|
||||||
case "close_session_runtime":
|
case "close_session_runtime":
|
||||||
return registry.closeSessionRuntime(request.payload.runtimeId);
|
return registry.closeSessionRuntime(request.payload.runtimeId);
|
||||||
|
case "close_quick_runtime":
|
||||||
|
return registry.closeQuickRuntime(request.payload.runtimeId);
|
||||||
case "list_directory_sessions":
|
case "list_directory_sessions":
|
||||||
return {
|
return {
|
||||||
sessions: await registry.listDirectorySessions(
|
sessions: await registry.listDirectorySessions(
|
||||||
@@ -76,6 +90,8 @@ export async function startBridgeService({
|
|||||||
return { agent: await registry.retry(request.agentId) };
|
return { agent: await registry.retry(request.agentId) };
|
||||||
case "restart":
|
case "restart":
|
||||||
return { agent: await registry.restart(request.agentId) };
|
return { agent: await registry.restart(request.agentId) };
|
||||||
|
case "renew_session":
|
||||||
|
return { agent: await registry.renewSession(request.agentId) };
|
||||||
case "list_sessions":
|
case "list_sessions":
|
||||||
return { sessions: await registry.listSessions(request.agentId) };
|
return { sessions: await registry.listSessions(request.agentId) };
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -163,5 +163,3 @@ export async function startUnixSocketServer({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const socketMode = OWNER_ONLY_MODE;
|
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ function normalizeManifest(value) {
|
|||||||
(value.directories !== undefined &&
|
(value.directories !== undefined &&
|
||||||
(!Array.isArray(value.directories) ||
|
(!Array.isArray(value.directories) ||
|
||||||
!value.directories.every(
|
!value.directories.every(
|
||||||
(directory) =>
|
(directory) => typeof directory === "string" && path.isAbsolute(directory),
|
||||||
typeof directory === "string" && path.isAbsolute(directory),
|
|
||||||
)))
|
)))
|
||||||
)
|
)
|
||||||
throw new Error("workspace manifest has an unsupported or invalid shape");
|
throw new Error("workspace manifest has an unsupported or invalid shape");
|
||||||
@@ -54,9 +53,7 @@ function normalizeManifest(value) {
|
|||||||
for (const runtime of value.runtimes) directories.add(runtime.worktreePath);
|
for (const runtime of value.runtimes) directories.add(runtime.worktreePath);
|
||||||
for (const runtime of value.runtimes) {
|
for (const runtime of value.runtimes) {
|
||||||
if (runtimeIds.has(runtime.runtimeId))
|
if (runtimeIds.has(runtime.runtimeId))
|
||||||
throw new Error(
|
throw new Error(`workspace manifest repeats runtime ${runtime.runtimeId}`);
|
||||||
`workspace manifest repeats runtime ${runtime.runtimeId}`,
|
|
||||||
);
|
|
||||||
runtimeIds.add(runtime.runtimeId);
|
runtimeIds.add(runtime.runtimeId);
|
||||||
if (runtime.sessionPath) {
|
if (runtime.sessionPath) {
|
||||||
if (sessionPaths.has(runtime.sessionPath))
|
if (sessionPaths.has(runtime.sessionPath))
|
||||||
@@ -85,9 +82,7 @@ export function createWorkspaceStore(sessionRoot) {
|
|||||||
await chmod(sessionRoot, DIRECTORY_MODE);
|
await chmod(sessionRoot, DIRECTORY_MODE);
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
manifest: normalizeManifest(
|
manifest: normalizeManifest(JSON.parse(await readFile(filePath, "utf8"))),
|
||||||
JSON.parse(await readFile(filePath, "utf8")),
|
|
||||||
),
|
|
||||||
migrating: false,
|
migrating: false,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -148,6 +143,3 @@ export function createWorkspaceStore(sessionRoot) {
|
|||||||
|
|
||||||
return { filePath, load, save };
|
return { filePath, load, save };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const workspaceManifestVersion = WORKSPACE_VERSION;
|
|
||||||
export const workspaceManifestFile = WORKSPACE_FILE;
|
|
||||||
|
|||||||
@@ -10,21 +10,15 @@ function flag(args, name) {
|
|||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const socketPath = flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
|
const socketPath =
|
||||||
const agentId = flag(args, "--agent");
|
flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
|
||||||
if (!socketPath)
|
if (!socketPath)
|
||||||
throw new Error("Usage: pi-status-bridge-noctalia-relay --socket <path> [--agent <id>]");
|
throw new Error("Usage: pi-status-bridge-noctalia-relay --socket <path>");
|
||||||
const client = await connectLocalClient({ socketPath });
|
const client = await connectLocalClient({ socketPath });
|
||||||
let agent;
|
|
||||||
if (agentId) {
|
|
||||||
const agents = await client.request("list_agents");
|
|
||||||
agent = agents.agents.find((candidate) => candidate.id === agentId);
|
|
||||||
if (!agent) throw new Error(`unknown agent: ${agentId}`);
|
|
||||||
}
|
|
||||||
const relay = createNoctaliaStateRelay({
|
const relay = createNoctaliaStateRelay({
|
||||||
client,
|
client,
|
||||||
...(agent ? { agent } : {}),
|
onState: (state) =>
|
||||||
onState: (state) => void publishNoctaliaState(state).catch((error) =>
|
void publishNoctaliaState(state).catch((error) =>
|
||||||
process.stderr.write(`${error.message}\n`),
|
process.stderr.write(`${error.message}\n`),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -39,6 +33,8 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run().catch((error) => {
|
run().catch((error) => {
|
||||||
process.stderr.write(`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`);
|
process.stderr.write(
|
||||||
|
`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`,
|
||||||
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import path from "node:path";
|
|
||||||
|
|
||||||
function detailForSummary(summary) {
|
function detailForSummary(summary) {
|
||||||
return [
|
return [
|
||||||
`${summary.openCount} open`,
|
`${summary.openCount} open`,
|
||||||
@@ -30,60 +28,16 @@ function summaryState(summary) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function legacyDetail(state) {
|
export function createNoctaliaStateRelay({ client, onState, pollMs = 2_000 }) {
|
||||||
switch (state) {
|
|
||||||
case "streaming": return "Streaming";
|
|
||||||
case "recovering": return "Recovering Pi session";
|
|
||||||
case "failed": return "Recovery failed";
|
|
||||||
case "error": return "Bridge error";
|
|
||||||
default: return "Idle";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Relays workspace-wide presentation state to Noctalia. Passing `agent` keeps
|
|
||||||
* the former agent-scoped behavior for external compatibility callers only.
|
|
||||||
*/
|
|
||||||
export function createNoctaliaStateRelay({ client, agent, onState, pollMs = 2_000 }) {
|
|
||||||
if (!client || typeof client.request !== "function")
|
if (!client || typeof client.request !== "function")
|
||||||
throw new TypeError("client must support request");
|
throw new TypeError("client must support request");
|
||||||
if (agent && (!agent.id || !agent.worktreePath))
|
if (!Number.isSafeInteger(pollMs) || pollMs < 250)
|
||||||
throw new TypeError("agent id and worktreePath are required");
|
|
||||||
if (!agent && (!Number.isSafeInteger(pollMs) || pollMs < 250))
|
|
||||||
throw new TypeError("pollMs must be an integer of at least 250ms");
|
throw new TypeError("pollMs must be an integer of at least 250ms");
|
||||||
if (typeof onState !== "function") throw new TypeError("onState must be a function");
|
if (typeof onState !== "function")
|
||||||
|
throw new TypeError("onState must be a function");
|
||||||
|
|
||||||
let unsubscribe = () => {};
|
|
||||||
let timer;
|
let timer;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
const publishLegacy = (state, attentionCount) => onState({
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
const refresh = async () => {
|
||||||
try {
|
try {
|
||||||
const summary = await client.request("get_workspace_summary");
|
const summary = await client.request("get_workspace_summary");
|
||||||
@@ -92,13 +46,12 @@ export function createNoctaliaStateRelay({ client, agent, onState, pollMs = 2_00
|
|||||||
if (!stopped) timer = setTimeout(() => void refresh(), pollMs);
|
if (!stopped) timer = setTimeout(() => void refresh(), pollMs);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
await refresh();
|
|
||||||
},
|
return {
|
||||||
|
start: refresh,
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
unsubscribe();
|
|
||||||
unsubscribe = () => {};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ const requestOperations = new Map([
|
|||||||
["forget_directory", { agent: false, payload: "worktree" }],
|
["forget_directory", { agent: false, payload: "worktree" }],
|
||||||
["get_workspace", { agent: false, payload: "none" }],
|
["get_workspace", { agent: false, payload: "none" }],
|
||||||
["get_workspace_summary", { agent: false, payload: "none" }],
|
["get_workspace_summary", { agent: false, payload: "none" }],
|
||||||
|
["get_model_catalog", { agent: false, payload: "none" }],
|
||||||
["create_session_runtime", { agent: false, payload: "worktree" }],
|
["create_session_runtime", { agent: false, payload: "worktree" }],
|
||||||
|
["create_quick_runtime", { agent: false, payload: "worktree" }],
|
||||||
|
["promote_quick_runtime", { agent: false, payload: "runtime" }],
|
||||||
["open_session_runtime", { agent: false, payload: "worktreeSession" }],
|
["open_session_runtime", { agent: false, payload: "worktreeSession" }],
|
||||||
["close_session_runtime", { agent: false, payload: "runtime" }],
|
["close_session_runtime", { agent: false, payload: "runtime" }],
|
||||||
|
["close_quick_runtime", { agent: false, payload: "runtime" }],
|
||||||
["list_directory_sessions", { agent: false, payload: "worktree" }],
|
["list_directory_sessions", { agent: false, payload: "worktree" }],
|
||||||
["get_session_runtime_snapshot", { agent: false, payload: "runtime" }],
|
["get_session_runtime_snapshot", { agent: false, payload: "runtime" }],
|
||||||
["subscribe_workspace", { agent: false, payload: "cursor" }],
|
["subscribe_workspace", { agent: false, payload: "cursor" }],
|
||||||
@@ -32,6 +36,7 @@ const requestOperations = new Map([
|
|||||||
["extension_response", { agent: true, payload: "extensionResponse" }],
|
["extension_response", { agent: true, payload: "extensionResponse" }],
|
||||||
["retry", { agent: true, payload: "none" }],
|
["retry", { agent: true, payload: "none" }],
|
||||||
["restart", { agent: true, payload: "none" }],
|
["restart", { agent: true, payload: "none" }],
|
||||||
|
["renew_session", { agent: true, payload: "none" }],
|
||||||
["get_available_models", { agent: true, payload: "none" }],
|
["get_available_models", { agent: true, payload: "none" }],
|
||||||
["get_commands", { agent: true, payload: "none" }],
|
["get_commands", { agent: true, payload: "none" }],
|
||||||
["set_session_name", { agent: true, payload: "sessionName" }],
|
["set_session_name", { agent: true, payload: "sessionName" }],
|
||||||
@@ -102,10 +107,7 @@ function assertString(value, field, { maxLength = 4096, pattern } = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (pattern && !pattern.test(value))
|
if (pattern && !pattern.test(value))
|
||||||
throw new ProtocolError(
|
throw new ProtocolError("invalid_message", `${field} has an invalid format`);
|
||||||
"invalid_message",
|
|
||||||
`${field} has an invalid format`,
|
|
||||||
);
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
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 () => {
|
test("sends abort without waiting behind a blocked prompt", async () => {
|
||||||
const worktrees = await createWorktrees();
|
const worktrees = await createWorktrees();
|
||||||
const fixture = createAdapterFactory();
|
const fixture = createAdapterFactory();
|
||||||
@@ -108,7 +162,7 @@ test("sends abort without waiting behind a blocked prompt", async () => {
|
|||||||
await registry.stop();
|
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 worktrees = await createWorktrees();
|
||||||
const fixture = createAdapterFactory();
|
const fixture = createAdapterFactory();
|
||||||
const registry = createAgentRegistry({
|
const registry = createAgentRegistry({
|
||||||
@@ -119,7 +173,10 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async ()
|
|||||||
await registry.start();
|
await registry.start();
|
||||||
|
|
||||||
fixture.calls[0].options.onError(
|
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 },
|
{ terminal: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,6 +187,31 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async ()
|
|||||||
await registry.stop();
|
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 () => {
|
test("stops agents created during shutdown and rejects new selections", async () => {
|
||||||
const worktrees = await createWorktrees();
|
const worktrees = await createWorktrees();
|
||||||
const fixture = createAdapterFactory();
|
const fixture = createAdapterFactory();
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
@@ -177,9 +177,16 @@ test("closing removes desired-open intent, preserves history, and refuses direct
|
|||||||
await registry.stop();
|
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 paths = await fixture();
|
||||||
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one");
|
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 secondPath = await sessionFile(paths.sessionRoot, paths.home, "two");
|
||||||
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "three");
|
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "three");
|
||||||
const firstAdapters = adapterFactory({
|
const firstAdapters = adapterFactory({
|
||||||
@@ -195,6 +202,7 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
|
|||||||
if (!initial.sessionPath)
|
if (!initial.sessionPath)
|
||||||
await firstRegistry.closeSessionRuntime(initial.runtimeId);
|
await firstRegistry.closeSessionRuntime(initial.runtimeId);
|
||||||
const one = await firstRegistry.openSessionRuntime(paths.home, firstPath);
|
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 two = await firstRegistry.openSessionRuntime(paths.home, secondPath);
|
||||||
const three = await firstRegistry.openSessionRuntime(paths.home, thirdPath);
|
const three = await firstRegistry.openSessionRuntime(paths.home, thirdPath);
|
||||||
await firstRegistry.stop();
|
await firstRegistry.stop();
|
||||||
@@ -224,12 +232,10 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
|
|||||||
startAdapter,
|
startAdapter,
|
||||||
});
|
});
|
||||||
const starting = restored.start();
|
const starting = restored.start();
|
||||||
while (gates.length < 2)
|
while (gates.length < 2) await new Promise((resolve) => setImmediate(resolve));
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
assert.equal(gates.length, 2);
|
assert.equal(gates.length, 2);
|
||||||
gates.splice(0).forEach((resolve) => resolve());
|
gates.splice(0).forEach((resolve) => resolve());
|
||||||
while (gates.length < 1)
|
while (gates.length < 1) await new Promise((resolve) => setImmediate(resolve));
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
assert.equal(gates.length, 1);
|
assert.equal(gates.length, 1);
|
||||||
gates.splice(0).forEach((resolve) => resolve());
|
gates.splice(0).forEach((resolve) => resolve());
|
||||||
await starting;
|
await starting;
|
||||||
@@ -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]),
|
new Set([one.runtimeId, two.runtimeId, three.runtimeId]),
|
||||||
);
|
);
|
||||||
assert.equal(maxActiveStarts, 2);
|
assert.equal(maxActiveStarts, 2);
|
||||||
|
assert.equal(
|
||||||
|
restored
|
||||||
|
.getWorkspace()
|
||||||
|
.directories[0].runtimes.find(
|
||||||
|
(runtime) => runtime.runtimeId === one.runtimeId,
|
||||||
|
)?.label,
|
||||||
|
"Hydrate this session label",
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
restored
|
restored
|
||||||
.listAgents()
|
.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 () => {
|
test("forces a child kill and retains its lease until exit is confirmed", async () => {
|
||||||
const paths = await fixture();
|
const paths = await fixture();
|
||||||
const saved = await sessionFile(
|
const saved = await sessionFile(paths.sessionRoot, paths.home, "forced-close");
|
||||||
paths.sessionRoot,
|
|
||||||
paths.home,
|
|
||||||
"forced-close",
|
|
||||||
);
|
|
||||||
let killed;
|
let killed;
|
||||||
let releaseStop;
|
let releaseStop;
|
||||||
let blockedSavedAdapter = false;
|
let blockedSavedAdapter = false;
|
||||||
@@ -445,6 +455,68 @@ test("legacy new session releases old identity and makes its history reopenable"
|
|||||||
await registry.stop();
|
await registry.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renews one runtime in place, aborts work, and clears its active identity", async () => {
|
||||||
|
const paths = await fixture();
|
||||||
|
const saved = await sessionFile(
|
||||||
|
paths.sessionRoot,
|
||||||
|
paths.home,
|
||||||
|
"renew-me",
|
||||||
|
"renew-me-id",
|
||||||
|
);
|
||||||
|
let currentState = {
|
||||||
|
sessionFile: saved,
|
||||||
|
sessionId: "renew-me-id",
|
||||||
|
sessionName: "Renew me",
|
||||||
|
};
|
||||||
|
const adapters = adapterFactory({ stateFor: () => currentState });
|
||||||
|
const registry = createAgentRegistry({
|
||||||
|
homeWorktree: paths.home,
|
||||||
|
sessionRoot: paths.sessionRoot,
|
||||||
|
startAdapter: adapters.startAdapter,
|
||||||
|
});
|
||||||
|
const original = await registry.start();
|
||||||
|
const adapter = adapters.calls[0].adapter;
|
||||||
|
const originalSend = adapter.send.bind(adapter);
|
||||||
|
adapter.send = async (command) => {
|
||||||
|
if (command.type === "new_session") currentState = {};
|
||||||
|
return originalSend(command);
|
||||||
|
};
|
||||||
|
adapters.calls[0].options.onEvent({
|
||||||
|
type: "extension_ui_request",
|
||||||
|
data: { event: { id: "stale-renew-request", method: "confirm" } },
|
||||||
|
});
|
||||||
|
adapters.calls[0].options.onEvent({
|
||||||
|
type: "agent_state",
|
||||||
|
data: { state: "streaming" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const renewed = await registry.renewSession(original.id);
|
||||||
|
const active = registry.getWorkspace().directories[0].runtimes[0];
|
||||||
|
assert.equal(renewed.id, original.id);
|
||||||
|
assert.equal(renewed.runtimeId, original.runtimeId);
|
||||||
|
assert.equal(active.worktreePath, paths.home);
|
||||||
|
assert.equal(active.sessionPath, undefined);
|
||||||
|
assert.equal(active.sessionId, undefined);
|
||||||
|
assert.equal(active.label, "New session");
|
||||||
|
assert.equal(active.state, "idle");
|
||||||
|
assert.equal(active.attention, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
adapter.sent.slice(-3).map((command) => command.type),
|
||||||
|
["abort", "new_session", "get_state"],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
registry
|
||||||
|
.workspaceEventsAfter(0)
|
||||||
|
.events.some((event) => event.type === "runtime_renewed"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
currentState = { sessionFile: saved, sessionId: "renew-me-id" };
|
||||||
|
const reopened = await registry.openSessionRuntime(paths.home, saved);
|
||||||
|
assert.notEqual(reopened.runtimeId, original.runtimeId);
|
||||||
|
await registry.stop();
|
||||||
|
});
|
||||||
|
|
||||||
test("persists a first-prompt session when the runtime settles and awaits refresh on shutdown", async () => {
|
test("persists a first-prompt session when the runtime settles and awaits refresh on shutdown", async () => {
|
||||||
const paths = await fixture();
|
const paths = await fixture();
|
||||||
const createdPath = await sessionFile(
|
const createdPath = await sessionFile(
|
||||||
@@ -611,6 +683,28 @@ test("non-default runtime snapshots do not replace the legacy session pointer",
|
|||||||
await registry.stop();
|
await registry.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reports available-model RPC failures in runtime snapshots", async () => {
|
||||||
|
const paths = await fixture();
|
||||||
|
const adapters = adapterFactory();
|
||||||
|
const registry = createAgentRegistry({
|
||||||
|
homeWorktree: paths.home,
|
||||||
|
sessionRoot: paths.sessionRoot,
|
||||||
|
startAdapter: adapters.startAdapter,
|
||||||
|
});
|
||||||
|
const runtime = await registry.start();
|
||||||
|
const originalSend = adapters.calls[0].adapter.send;
|
||||||
|
adapters.calls[0].adapter.send = async function (command) {
|
||||||
|
if (command.type === "get_available_models")
|
||||||
|
throw new Error("model registry unavailable");
|
||||||
|
return originalSend.call(this, command);
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = await registry.getSessionRuntimeSnapshot(runtime.runtimeId);
|
||||||
|
assert.deepEqual(snapshot.models.data.models, []);
|
||||||
|
assert.match(snapshot.models.error, /model registry unavailable/);
|
||||||
|
await registry.stop();
|
||||||
|
});
|
||||||
|
|
||||||
test("retains a corrupt workspace manifest and reports its issue", async () => {
|
test("retains a corrupt workspace manifest and reports its issue", async () => {
|
||||||
const paths = await fixture();
|
const paths = await fixture();
|
||||||
await mkdir(paths.sessionRoot, { recursive: true });
|
await mkdir(paths.sessionRoot, { recursive: true });
|
||||||
@@ -622,10 +716,7 @@ test("retains a corrupt workspace manifest and reports its issue", async () => {
|
|||||||
startAdapter: adapterFactory().startAdapter,
|
startAdapter: adapterFactory().startAdapter,
|
||||||
});
|
});
|
||||||
await registry.start();
|
await registry.start();
|
||||||
assert.equal(
|
assert.equal(registry.getWorkspace().issue.code, "invalid_workspace_manifest");
|
||||||
registry.getWorkspace().issue.code,
|
|
||||||
"invalid_workspace_manifest",
|
|
||||||
);
|
|
||||||
assert.equal(await readFile(manifestPath, "utf8"), "{broken");
|
assert.equal(await readFile(manifestPath, "utf8"), "{broken");
|
||||||
await registry.stop();
|
await registry.stop();
|
||||||
});
|
});
|
||||||
@@ -912,7 +1003,7 @@ test("closing retains a saved-session lease until the child stops", async () =>
|
|||||||
await registry.stop();
|
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 paths = await fixture();
|
||||||
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
|
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
|
||||||
const missing = join(sessionDir, "missing.jsonl");
|
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,
|
startAdapter: adapters.startAdapter,
|
||||||
});
|
});
|
||||||
await registry.start();
|
await registry.start();
|
||||||
const runtime = registry.getWorkspace().directories[0].runtimes[0];
|
const runtimes = registry.getWorkspace().directories[0].runtimes;
|
||||||
assert.equal(runtime.state, "failed");
|
const failed = runtimes.find((runtime) => runtime.state === "failed");
|
||||||
assert.equal(runtime.sessionPath, missing);
|
const live = runtimes.find((runtime) => runtime.state === "idle");
|
||||||
assert.equal(adapters.calls.length, 0);
|
assert.equal(failed.sessionPath, missing);
|
||||||
|
assert.equal(live.worktreePath, paths.home);
|
||||||
|
assert.equal(adapters.calls.length, 1);
|
||||||
const manifest = await readJson(
|
const manifest = await readJson(
|
||||||
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
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();
|
await registry.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1025,6 +1120,35 @@ test("manual restart ignores late extension dialogs from the replaced child", as
|
|||||||
await registry.stop();
|
await registry.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("manual restart rejects exhausted recovery and marks the runtime failed", async () => {
|
||||||
|
const paths = await fixture();
|
||||||
|
const adapters = adapterFactory();
|
||||||
|
let starts = 0;
|
||||||
|
const registry = createAgentRegistry({
|
||||||
|
homeWorktree: paths.home,
|
||||||
|
sessionRoot: paths.sessionRoot,
|
||||||
|
startAdapter(options) {
|
||||||
|
starts += 1;
|
||||||
|
if (starts > 1) throw new Error("replacement failed to start");
|
||||||
|
return adapters.startAdapter(options);
|
||||||
|
},
|
||||||
|
maxRecoveryAttempts: 1,
|
||||||
|
recoveryDelayForAttempt: () => 0,
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
const runtime = await registry.start();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
registry.restart(runtime.id),
|
||||||
|
/Pi did not restart after the recovery attempts were exhausted/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
registry.getWorkspace().directories[0].runtimes[0].state,
|
||||||
|
"failed",
|
||||||
|
);
|
||||||
|
await registry.stop();
|
||||||
|
});
|
||||||
|
|
||||||
test("child exit clears extension dialogs that recovery cannot answer", async () => {
|
test("child exit clears extension dialogs that recovery cannot answer", async () => {
|
||||||
const paths = await fixture();
|
const paths = await fixture();
|
||||||
const adapters = adapterFactory();
|
const adapters = adapterFactory();
|
||||||
@@ -1094,7 +1218,7 @@ test("snapshot rejects results from an adapter replaced during recovery", async
|
|||||||
await registry.stop();
|
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 paths = await fixture();
|
||||||
const firstAdapters = adapterFactory();
|
const firstAdapters = adapterFactory();
|
||||||
const first = createAgentRegistry({
|
const first = createAgentRegistry({
|
||||||
@@ -1112,8 +1236,10 @@ test("closing the last runtime persists a zero-runtime restart", async () => {
|
|||||||
startAdapter: restoredAdapters.startAdapter,
|
startAdapter: restoredAdapters.startAdapter,
|
||||||
});
|
});
|
||||||
await restored.start();
|
await restored.start();
|
||||||
assert.equal(restored.getWorkspaceSummary().openCount, 0);
|
assert.equal(restored.getWorkspaceSummary().openCount, 1);
|
||||||
assert.equal(restoredAdapters.calls.length, 0);
|
assert.equal(restoredAdapters.calls.length, 1);
|
||||||
|
assert.equal(restored.listAgents()[0].worktreePath, paths.home);
|
||||||
|
assert.equal(restored.listAgents()[0].state, "idle");
|
||||||
await restored.stop();
|
await restored.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 }]);
|
||||||
|
});
|
||||||
@@ -115,10 +115,7 @@ test("allows a per-command timeout override without serializing it to Pi", async
|
|||||||
type: "get_state",
|
type: "get_state",
|
||||||
id: initialCommand.id,
|
id: initialCommand.id,
|
||||||
});
|
});
|
||||||
await assert.rejects(
|
await assert.rejects(adapter.send({ type: "get_session_stats" }), /timed out/);
|
||||||
adapter.send({ type: "get_session_stats" }),
|
|
||||||
/timed out/,
|
|
||||||
);
|
|
||||||
await adapter.stop();
|
await adapter.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,6 +205,49 @@ test("forwards extension responses without replacing Pi's request ID", async ()
|
|||||||
await adapter.stop();
|
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 () => {
|
test("discards a fragmented oversized frame before processing the next frame", async () => {
|
||||||
const fixture = createFixture();
|
const fixture = createFixture();
|
||||||
const errors = [];
|
const errors = [];
|
||||||
@@ -217,7 +257,7 @@ test("discards a fragmented oversized frame before processing the next frame", a
|
|||||||
sessionDir: "/workspace/sessions",
|
sessionDir: "/workspace/sessions",
|
||||||
spawnProcess: fixture.spawn,
|
spawnProcess: fixture.spawn,
|
||||||
maxFrameBytes: 32,
|
maxFrameBytes: 32,
|
||||||
onError: (error) => errors.push(error),
|
onError: (error, metadata) => errors.push({ error, metadata }),
|
||||||
onEvent: (event) => events.push(event),
|
onEvent: (event) => events.push(event),
|
||||||
});
|
});
|
||||||
const oversized = JSON.stringify({
|
const oversized = JSON.stringify({
|
||||||
@@ -230,8 +270,11 @@ test("discards a fragmented oversized frame before processing the next frame", a
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
errors.map((error) => error.code),
|
errors.map(({ error, metadata }) => ({
|
||||||
["frame_too_large"],
|
code: error.code,
|
||||||
|
terminal: metadata?.terminal !== false,
|
||||||
|
})),
|
||||||
|
[{ code: "frame_too_large", terminal: true }],
|
||||||
);
|
);
|
||||||
assert.deepEqual(events, [
|
assert.deepEqual(events, [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ test("accepts bridge-managed directory and session operations", () => {
|
|||||||
agentId: "agent-main",
|
agentId: "agent-main",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseRequestFrame(request({ op: "renew_session", agentId: "agent-main" })),
|
||||||
|
{
|
||||||
|
version: "v1",
|
||||||
|
id: "request-1",
|
||||||
|
op: "renew_session",
|
||||||
|
agentId: "agent-main",
|
||||||
|
},
|
||||||
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
parseRequestFrame(
|
parseRequestFrame(
|
||||||
request({
|
request({
|
||||||
@@ -115,8 +124,11 @@ test("accepts additive multi-session runtime operations and numeric workspace cu
|
|||||||
{ op: "get_workspace" },
|
{ op: "get_workspace" },
|
||||||
{ op: "get_workspace_summary" },
|
{ op: "get_workspace_summary" },
|
||||||
{ op: "create_session_runtime", payload: { worktreePath } },
|
{ op: "create_session_runtime", payload: { worktreePath } },
|
||||||
|
{ op: "create_quick_runtime", payload: { worktreePath } },
|
||||||
|
{ op: "promote_quick_runtime", payload: { runtimeId: "runtime-1" } },
|
||||||
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
|
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
|
||||||
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
|
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
|
||||||
|
{ op: "close_quick_runtime", payload: { runtimeId: "runtime-1" } },
|
||||||
{ op: "list_directory_sessions", payload: { worktreePath } },
|
{ op: "list_directory_sessions", payload: { worktreePath } },
|
||||||
{
|
{
|
||||||
op: "get_session_runtime_snapshot",
|
op: "get_session_runtime_snapshot",
|
||||||
|
|||||||
@@ -0,0 +1,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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -18,11 +18,12 @@ test("wires the desktop to stable runtime workspace APIs", async () => {
|
|||||||
"close_session_runtime",
|
"close_session_runtime",
|
||||||
"list_directory_sessions",
|
"list_directory_sessions",
|
||||||
"get_session_runtime_snapshot",
|
"get_session_runtime_snapshot",
|
||||||
|
"renew_session",
|
||||||
"subscribe_workspace",
|
"subscribe_workspace",
|
||||||
])
|
])
|
||||||
assert.match(`${app}\n${hook}`, new RegExp(operation));
|
assert.match(`${app}\n${hook}`, new RegExp(operation));
|
||||||
assert.doesNotMatch(app, /switch_session/);
|
assert.doesNotMatch(app, /switch_session/);
|
||||||
assert.doesNotMatch(app, /new_session/);
|
assert.doesNotMatch(app, /["']new_session["']/);
|
||||||
assert.match(reducer, /WorkspaceState\["runtimesById"\]/);
|
assert.match(reducer, /WorkspaceState\["runtimesById"\]/);
|
||||||
assert.match(reducer, /requestGeneration/);
|
assert.match(reducer, /requestGeneration/);
|
||||||
assert.match(reducer, /loadedThroughSeq/);
|
assert.match(reducer, /loadedThroughSeq/);
|
||||||
@@ -54,6 +55,10 @@ test("renders directory sidebar, accessible session tabs, warnings, and reduced
|
|||||||
assert.match(app, /Many Pi sessions are open/);
|
assert.match(app, /Many Pi sessions are open/);
|
||||||
assert.match(css, /prefers-reduced-motion/);
|
assert.match(css, /prefers-reduced-motion/);
|
||||||
assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/);
|
assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/);
|
||||||
|
assert.match(
|
||||||
|
css,
|
||||||
|
/\.session-row-content\s*>\s*strong\s*\{[^}]*color:\s*#f1f3f4/s,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves native session naming, compaction, composer, and extension responses", async () => {
|
test("preserves native session naming, compaction, composer, and extension responses", async () => {
|
||||||
@@ -68,8 +73,9 @@ test("preserves native session naming, compaction, composer, and extension respo
|
|||||||
assert.match(app, /respond_to_extension/);
|
assert.match(app, /respond_to_extension/);
|
||||||
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
|
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
|
||||||
assert.match(conversation, /className="todos-pane"/);
|
assert.match(conversation, /className="todos-pane"/);
|
||||||
assert.match(extension, /role="dialog"/);
|
assert.match(extension, /<dialog/);
|
||||||
assert.match(extension, /aria-modal="true"/);
|
assert.match(extension, /showModal\(\)/);
|
||||||
|
assert.match(extension, /onCancel=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("configures native window behavior and folder dialog capabilities", async () => {
|
test("configures native window behavior and folder dialog capabilities", async () => {
|
||||||
|
|||||||
+1
-2
@@ -2,9 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri + React + Typescript</title>
|
<title>Pi Status UI</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+1461
-17
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -21,9 +21,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
|||||||
@@ -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 +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 |
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for Pi Status windows",
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main",
|
||||||
|
"quick-pane"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
|||||||
+41
-152
@@ -1,7 +1,6 @@
|
|||||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
@@ -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> {
|
pub async fn get_workspace_summary(socket_path: &str) -> Result<WorkspaceSummary, String> {
|
||||||
typed_request(socket_path, "get_workspace_summary", None).await
|
typed_request(socket_path, "get_workspace_summary", None).await
|
||||||
}
|
}
|
||||||
|
pub async fn get_model_catalog(socket_path: &str) -> Result<Value, String> {
|
||||||
|
request(socket_path, "get_model_catalog", None, None).await
|
||||||
|
}
|
||||||
pub async fn create_session_runtime(
|
pub async fn create_session_runtime(
|
||||||
socket_path: &str,
|
socket_path: &str,
|
||||||
worktree_path: &str,
|
worktree_path: &str,
|
||||||
@@ -214,6 +216,28 @@ pub async fn create_session_runtime(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
pub async fn create_quick_runtime(
|
||||||
|
socket_path: &str,
|
||||||
|
worktree_path: &str,
|
||||||
|
) -> Result<RuntimeResult, String> {
|
||||||
|
typed_request(
|
||||||
|
socket_path,
|
||||||
|
"create_quick_runtime",
|
||||||
|
Some(json!({ "worktreePath": worktree_path })),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
pub async fn 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(
|
pub async fn open_session_runtime(
|
||||||
socket_path: &str,
|
socket_path: &str,
|
||||||
worktree_path: &str,
|
worktree_path: &str,
|
||||||
@@ -237,6 +261,17 @@ pub async fn close_session_runtime(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
pub async fn close_quick_runtime(
|
||||||
|
socket_path: &str,
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> Result<CloseRuntimeResult, String> {
|
||||||
|
typed_request(
|
||||||
|
socket_path,
|
||||||
|
"close_quick_runtime",
|
||||||
|
Some(json!({ "runtimeId": runtime_id })),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
pub async fn list_directory_sessions(
|
pub async fn list_directory_sessions(
|
||||||
socket_path: &str,
|
socket_path: &str,
|
||||||
worktree_path: &str,
|
worktree_path: &str,
|
||||||
@@ -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
|
request(socket_path, "new_session", Some(agent_id), None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fallback_transcript(state: &Value) -> Value {
|
pub async fn submit_prompt(
|
||||||
let Some(path) = state.pointer("/data/sessionFile").and_then(Value::as_str) else {
|
socket_path: &str,
|
||||||
return json!({ "data": { "messages": [] } });
|
agent_id: &str,
|
||||||
};
|
message: &str,
|
||||||
let path = Path::new(path);
|
) -> Result<Value, String> {
|
||||||
if !path.is_absolute()
|
|
||||||
|| path.extension().and_then(|extension| extension.to_str()) != Some("jsonl")
|
|
||||||
{
|
|
||||||
return json!({ "data": { "messages": [] } });
|
|
||||||
}
|
|
||||||
let messages = std::fs::read_to_string(path)
|
|
||||||
.ok()
|
|
||||||
.map(|content| {
|
|
||||||
content
|
|
||||||
.lines()
|
|
||||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
|
||||||
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("message"))
|
|
||||||
.filter_map(|entry| entry.get("message").cloned())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
json!({ "data": { "messages": messages } })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compact_commands(value: Value) -> Value {
|
|
||||||
let commands = value
|
|
||||||
.pointer("/data/commands")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|commands| {
|
|
||||||
commands
|
|
||||||
.iter()
|
|
||||||
.map(|command| {
|
|
||||||
let mut compact = serde_json::Map::new();
|
|
||||||
for field in ["name", "description"] {
|
|
||||||
if let Some(value) = command.get(field) {
|
|
||||||
compact.insert(field.to_owned(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Value::Object(compact)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
json!({ "data": { "commands": commands } })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
|
||||||
// Older bridge daemons can still serve the conversation while they await a restart.
|
|
||||||
let stats = request(socket_path, "get_session_stats", Some(agent_id), None)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| json!({ "data": {} }));
|
|
||||||
let (state, commands, models) = tokio::try_join!(
|
|
||||||
request(socket_path, "get_state", Some(agent_id), None),
|
|
||||||
request(socket_path, "get_commands", Some(agent_id), None),
|
|
||||||
request(socket_path, "get_available_models", Some(agent_id), None),
|
|
||||||
)?;
|
|
||||||
Ok(json!({
|
|
||||||
"state": state,
|
|
||||||
"stats": stats,
|
|
||||||
"transcript": fallback_transcript(&state),
|
|
||||||
"commands": compact_commands(commands),
|
|
||||||
"models": models
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) -> Result<Value, String> {
|
|
||||||
request(
|
request(
|
||||||
socket_path,
|
socket_path,
|
||||||
"submit_prompt",
|
"submit_prompt",
|
||||||
@@ -860,91 +834,6 @@ mod tests {
|
|||||||
std::fs::remove_file(path).expect("socket cleanup");
|
std::fs::remove_file(path).expect("socket cleanup");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reads_messages_from_the_saved_session_file_when_transcript_rpc_is_unavailable() {
|
|
||||||
let path = format!(
|
|
||||||
"{}/pi-status-ui-history-{}.jsonl",
|
|
||||||
env::temp_dir().display(),
|
|
||||||
std::process::id()
|
|
||||||
);
|
|
||||||
std::fs::write(
|
|
||||||
&path,
|
|
||||||
concat!(
|
|
||||||
"{\"type\":\"session\",\"id\":\"session-1\"}\n",
|
|
||||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}\n",
|
|
||||||
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.expect("session fixture");
|
|
||||||
let transcript = fallback_transcript(&json!({
|
|
||||||
"data": { "sessionFile": path }
|
|
||||||
}));
|
|
||||||
assert_eq!(
|
|
||||||
transcript["data"]["messages"],
|
|
||||||
json!([
|
|
||||||
{ "role": "user", "content": "Hello" },
|
|
||||||
{ "role": "assistant", "content": "Hi" }
|
|
||||||
])
|
|
||||||
);
|
|
||||||
std::fs::remove_file(path).expect("session cleanup");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn loads_state_history_commands_and_models_as_one_snapshot() {
|
|
||||||
let path = format!(
|
|
||||||
"{}/pi-status-ui-load-{}.sock",
|
|
||||||
env::temp_dir().display(),
|
|
||||||
std::process::id()
|
|
||||||
);
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
let listener = UnixListener::bind(&path).expect("listener");
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
for _ in 0..4 {
|
|
||||||
let (stream, _) = listener.accept().await.expect("connection");
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut line = String::new();
|
|
||||||
let mut reader = BufReader::new(stream);
|
|
||||||
reader.read_line(&mut line).await.expect("request");
|
|
||||||
let request: Value = serde_json::from_str(&line).expect("JSON request");
|
|
||||||
assert_eq!(request["agentId"], "agent-1");
|
|
||||||
let result = match request["op"].as_str() {
|
|
||||||
Some("get_state") => json!({ "data": { "isStreaming": false } }),
|
|
||||||
Some("get_session_stats") => json!({
|
|
||||||
"data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } }
|
|
||||||
}),
|
|
||||||
Some("get_commands") => json!({ "data": { "commands": [{
|
|
||||||
"name": "resume",
|
|
||||||
"description": "Resume a saved session",
|
|
||||||
"sourceInfo": { "path": "/very/large/extension/metadata" }
|
|
||||||
}] } }),
|
|
||||||
Some("get_available_models") => json!({ "data": { "models": [] } }),
|
|
||||||
other => panic!("unexpected operation: {other:?}"),
|
|
||||||
};
|
|
||||||
let mut response = serde_json::to_vec(&json!({
|
|
||||||
"id": "tauri-ui", "ok": true, "result": result
|
|
||||||
}))
|
|
||||||
.expect("response JSON");
|
|
||||||
response.push(b'\n');
|
|
||||||
reader
|
|
||||||
.get_mut()
|
|
||||||
.write_all(&response)
|
|
||||||
.await
|
|
||||||
.expect("response");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let snapshot = load_agent(&path, "agent-1").await.expect("snapshot");
|
|
||||||
assert_eq!(snapshot["state"]["data"]["isStreaming"], false);
|
|
||||||
assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000);
|
|
||||||
assert_eq!(snapshot["transcript"]["data"]["messages"], json!([]));
|
|
||||||
assert_eq!(
|
|
||||||
snapshot["commands"]["data"]["commands"],
|
|
||||||
json!([{ "name": "resume", "description": "Resume a saved session" }])
|
|
||||||
);
|
|
||||||
server.await.expect("server succeeds");
|
|
||||||
std::fs::remove_file(path).expect("socket cleanup");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime(id: &str) -> Value {
|
fn runtime(id: &str) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"runtimeId": id,
|
"runtimeId": id,
|
||||||
|
|||||||
+414
-33
@@ -2,10 +2,19 @@ mod bridge;
|
|||||||
mod ui_state;
|
mod ui_state;
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{env, path::Path, sync::{Arc, Mutex}, time::Duration};
|
use std::{
|
||||||
use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
|
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 LegacySubscription(Mutex<Option<JoinHandle<()>>>);
|
||||||
|
struct PendingPromotedRuntime(Mutex<Option<String>>);
|
||||||
struct WorkspaceSubscription {
|
struct WorkspaceSubscription {
|
||||||
generation: Arc<bridge::SubscriptionGeneration>,
|
generation: Arc<bridge::SubscriptionGeneration>,
|
||||||
task: Mutex<Option<JoinHandle<()>>>,
|
task: Mutex<Option<JoinHandle<()>>>,
|
||||||
@@ -45,6 +54,148 @@ fn socket_path() -> Result<String, String> {
|
|||||||
bridge::default_socket_path()
|
bridge::default_socket_path()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requested_quick_pane(args: &[String]) -> bool {
|
||||||
|
args.iter().any(|arg| arg == "--quick")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn 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> {
|
fn requested_new_worktree_argument(args: &[String]) -> Option<String> {
|
||||||
args.iter()
|
args.iter()
|
||||||
.position(|arg| arg == "--worktree")
|
.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]) {
|
fn launch_new_session(app: AppHandle, args: &[String]) {
|
||||||
let Some(worktree_path) = requested_new_worktree(&app, args) else { return };
|
let Some(worktree_path) = requested_new_worktree(&app, args) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
// Give a newly-created webview time to register its frontend listeners.
|
// Give a newly-created webview time to register its frontend listeners.
|
||||||
tokio::time::sleep(Duration::from_millis(350)).await;
|
tokio::time::sleep(Duration::from_millis(350)).await;
|
||||||
let _ = app.emit("workspace-new-session", json!({
|
let _ = app.emit(
|
||||||
|
"workspace-new-session",
|
||||||
|
json!({
|
||||||
"phase": "starting",
|
"phase": "starting",
|
||||||
"detail": "Starting a new Pi session…"
|
"detail": "Starting a new Pi session…"
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
match socket_path().map(|socket| (socket, worktree_path)) {
|
match socket_path().map(|socket| (socket, worktree_path)) {
|
||||||
Ok((socket, worktree_path)) => match bridge::create_session_runtime(&socket, &worktree_path).await {
|
Ok((socket, worktree_path)) => {
|
||||||
Ok(result) => { let _ = app.emit("workspace-new-session", json!({
|
match bridge::create_session_runtime(&socket, &worktree_path).await {
|
||||||
|
Ok(result) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"workspace-new-session",
|
||||||
|
json!({
|
||||||
"phase": "ready",
|
"phase": "ready",
|
||||||
"runtimeId": result.runtime.runtime_id,
|
"runtimeId": result.runtime.runtime_id,
|
||||||
"detail": "New session ready"
|
"detail": "New session ready"
|
||||||
})); }
|
}),
|
||||||
Err(error) => { let _ = app.emit("workspace-new-session", json!({
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"workspace-new-session",
|
||||||
|
json!({
|
||||||
"phase": "error",
|
"phase": "error",
|
||||||
"detail": format!("Could not start session: {error}")
|
"detail": format!("Could not start session: {error}")
|
||||||
})); }
|
}),
|
||||||
},
|
);
|
||||||
Err(error) => { let _ = app.emit("workspace-new-session", json!({
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"workspace-new-session",
|
||||||
|
json!({
|
||||||
"phase": "error",
|
"phase": "error",
|
||||||
"detail": error
|
"detail": error
|
||||||
})); }
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -107,11 +280,96 @@ async fn get_workspace_summary() -> Result<bridge::WorkspaceSummary, String> {
|
|||||||
bridge::get_workspace_summary(&socket_path()?).await
|
bridge::get_workspace_summary(&socket_path()?).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_model_catalog() -> Result<Value, String> {
|
||||||
|
bridge::get_model_catalog(&socket_path()?).await
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
#[tauri::command]
|
||||||
async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
|
async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
|
||||||
bridge::create_session_runtime(&socket_path()?, &worktree_path).await
|
bridge::create_session_runtime(&socket_path()?, &worktree_path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn create_quick_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
|
||||||
|
bridge::create_quick_runtime(&socket_path()?, &worktree_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
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]
|
#[tauri::command]
|
||||||
async fn open_session_runtime(
|
async fn open_session_runtime(
|
||||||
worktree_path: String,
|
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
|
bridge::close_session_runtime(&socket_path()?, &runtime_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn close_quick_runtime(runtime_id: String) -> Result<bridge::CloseRuntimeResult, String> {
|
||||||
|
bridge::close_quick_runtime(&socket_path()?, &runtime_id).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn list_directory_sessions(
|
async fn list_directory_sessions(
|
||||||
worktree_path: String,
|
worktree_path: String,
|
||||||
@@ -177,11 +440,6 @@ async fn forget_directory(worktree_path: String) -> Result<Value, String> {
|
|||||||
bridge::forget_directory(&socket_path()?, &worktree_path).await
|
bridge::forget_directory(&socket_path()?, &worktree_path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
async fn load_agent(agent_id: String) -> Result<Value, String> {
|
|
||||||
bridge::load_agent(&socket_path()?, &agent_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn list_sessions(agent_id: String) -> Result<Value, String> {
|
async fn list_sessions(agent_id: String) -> Result<Value, String> {
|
||||||
bridge::list_sessions(&socket_path()?, &agent_id).await
|
bridge::list_sessions(&socket_path()?, &agent_id).await
|
||||||
@@ -217,6 +475,11 @@ async fn restart(agent_id: String) -> Result<(), String> {
|
|||||||
bridge::command(&socket_path()?, &agent_id, "restart").await
|
bridge::command(&socket_path()?, &agent_id, "restart").await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn renew_session(agent_id: String) -> Result<(), String> {
|
||||||
|
bridge::command(&socket_path()?, &agent_id, "renew_session").await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn set_model(agent_id: String, provider: String, model_id: String) -> Result<(), String> {
|
async fn set_model(agent_id: String, provider: String, model_id: String) -> Result<(), String> {
|
||||||
bridge::set_model(&socket_path()?, &agent_id, &provider, &model_id).await
|
bridge::set_model(&socket_path()?, &agent_id, &provider, &model_id).await
|
||||||
@@ -239,7 +502,8 @@ async fn pi_rpc_command(agent_id: String, command: String, input: Value) -> Resu
|
|||||||
"pi_rpc_command",
|
"pi_rpc_command",
|
||||||
Some(&agent_id),
|
Some(&agent_id),
|
||||||
Some(serde_json::json!({ "command": command, "input": input })),
|
Some(serde_json::json!({ "command": command, "input": input })),
|
||||||
).await
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -347,34 +611,49 @@ pub fn run() {
|
|||||||
let builder = tauri::Builder::default()
|
let builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.manage(LegacySubscription(Mutex::new(None)))
|
.manage(LegacySubscription(Mutex::new(None)))
|
||||||
|
.manage(PendingPromotedRuntime(Mutex::new(None)))
|
||||||
.manage(WorkspaceSubscription {
|
.manage(WorkspaceSubscription {
|
||||||
generation: Arc::new(bridge::SubscriptionGeneration::default()),
|
generation: Arc::new(bridge::SubscriptionGeneration::default()),
|
||||||
task: Mutex::new(None),
|
task: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
|
if requested_quick_pane(&args) {
|
||||||
|
toggle_quick_pane(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
launch_new_session(app.clone(), &args);
|
launch_new_session(app.clone(), &args);
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
apply_main_window_action(app, &args);
|
||||||
match window_action(&args, window.is_visible().unwrap_or(false)) {
|
}))
|
||||||
WindowAction::Hide => {
|
.on_window_event(|window, event| {
|
||||||
|
if should_hide_close_request(window.label()) {
|
||||||
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
api.prevent_close();
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
}
|
}
|
||||||
WindowAction::ShowAndFocus => {
|
|
||||||
let _ = window.show();
|
|
||||||
let _ = window.set_focus();
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}))
|
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
|
if requested_quick_pane(&launch_args) {
|
||||||
|
toggle_quick_pane(app.handle());
|
||||||
|
} else {
|
||||||
launch_new_session(app.handle().clone(), &launch_args);
|
launch_new_session(app.handle().clone(), &launch_args);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_workspace,
|
get_workspace,
|
||||||
get_workspace_summary,
|
get_workspace_summary,
|
||||||
|
get_model_catalog,
|
||||||
|
resize_quick_pane,
|
||||||
|
dismiss_quick_pane,
|
||||||
create_session_runtime,
|
create_session_runtime,
|
||||||
|
create_quick_runtime,
|
||||||
|
promote_quick_runtime,
|
||||||
|
get_pending_promoted_runtime,
|
||||||
|
consume_pending_promoted_runtime,
|
||||||
open_session_runtime,
|
open_session_runtime,
|
||||||
close_session_runtime,
|
close_session_runtime,
|
||||||
|
close_quick_runtime,
|
||||||
list_directory_sessions,
|
list_directory_sessions,
|
||||||
get_session_runtime_snapshot,
|
get_session_runtime_snapshot,
|
||||||
load_ui_state,
|
load_ui_state,
|
||||||
@@ -385,7 +664,6 @@ pub fn run() {
|
|||||||
list_directories,
|
list_directories,
|
||||||
select_worktree,
|
select_worktree,
|
||||||
forget_directory,
|
forget_directory,
|
||||||
load_agent,
|
|
||||||
list_sessions,
|
list_sessions,
|
||||||
switch_session,
|
switch_session,
|
||||||
new_session,
|
new_session,
|
||||||
@@ -393,6 +671,7 @@ pub fn run() {
|
|||||||
abort,
|
abort,
|
||||||
retry,
|
retry,
|
||||||
restart,
|
restart,
|
||||||
|
renew_session,
|
||||||
set_model,
|
set_model,
|
||||||
set_thinking_level,
|
set_thinking_level,
|
||||||
set_session_name,
|
set_session_name,
|
||||||
@@ -428,10 +707,112 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_session_uses_an_explicit_absolute_worktree() {
|
fn new_session_uses_an_explicit_absolute_worktree() {
|
||||||
let args = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "/workspace".into()];
|
let args = vec![
|
||||||
assert_eq!(requested_new_worktree_argument(&args), Some("/workspace".into()));
|
"pi-status-ui".into(),
|
||||||
let relative = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "workspace".into()];
|
"--new".into(),
|
||||||
assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None);
|
"--worktree".into(),
|
||||||
|
"/workspace".into(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
requested_new_worktree_argument(&args),
|
||||||
|
Some("/workspace".into())
|
||||||
|
);
|
||||||
|
let relative = vec![
|
||||||
|
"pi-status-ui".into(),
|
||||||
|
"--new".into(),
|
||||||
|
"--worktree".into(),
|
||||||
|
"workspace".into(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const MAX_DRAFT_BYTES: usize = 128 * 1024;
|
|||||||
const MAX_TOTAL_DRAFT_BYTES: usize = 1024 * 1024;
|
const MAX_TOTAL_DRAFT_BYTES: usize = 1024 * 1024;
|
||||||
const MAX_TOTAL_STRING_BYTES: usize = 1536 * 1024;
|
const MAX_TOTAL_STRING_BYTES: usize = 1536 * 1024;
|
||||||
const MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
|
const MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
|
||||||
|
const DEFAULT_THINKING_LEVEL: &str = "medium";
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
@@ -37,6 +38,39 @@ pub struct LastSeenState {
|
|||||||
pub last_activity: Option<String>,
|
pub last_activity: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct QuickPaneSettings {
|
||||||
|
pub workspace: String,
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
pub follow_up_provider: String,
|
||||||
|
pub follow_up_model: String,
|
||||||
|
pub instructions: String,
|
||||||
|
pub maximum_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)]
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
pub struct UiStateV1 {
|
pub struct UiStateV1 {
|
||||||
@@ -61,6 +95,8 @@ pub struct UiStateV1 {
|
|||||||
pub default_new_session_worktree: Option<String>,
|
pub default_new_session_worktree: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub dismissed_collision_warning: Option<bool>,
|
pub dismissed_collision_warning: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub quick_pane: QuickPaneSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiStateV1 {
|
impl Default for UiStateV1 {
|
||||||
@@ -77,6 +113,7 @@ impl Default for UiStateV1 {
|
|||||||
interface_scale: None,
|
interface_scale: None,
|
||||||
default_new_session_worktree: None,
|
default_new_session_worktree: None,
|
||||||
dismissed_collision_warning: None,
|
dismissed_collision_warning: None,
|
||||||
|
quick_pane: QuickPaneSettings::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +219,44 @@ pub fn validate(state: &UiStateV1) -> Result<(), String> {
|
|||||||
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
|
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
|
||||||
return Err("UI state strings exceed the total size limit".to_owned());
|
return Err("UI state strings exceed the total size limit".to_owned());
|
||||||
}
|
}
|
||||||
|
let quick = &state.quick_pane;
|
||||||
|
let quick_is_default = quick.workspace.is_empty()
|
||||||
|
&& quick.provider.is_empty()
|
||||||
|
&& quick.model.is_empty()
|
||||||
|
&& quick.follow_up_provider.is_empty()
|
||||||
|
&& quick.follow_up_model.is_empty();
|
||||||
|
if !quick_is_default {
|
||||||
|
bounded(&quick.workspace, "quick-pane workspace")?;
|
||||||
|
if !Path::new(&quick.workspace).is_absolute() || !Path::new(&quick.workspace).is_dir() {
|
||||||
|
return Err("Quick-pane workspace must be an existing absolute directory".to_owned());
|
||||||
|
}
|
||||||
|
bounded(&quick.provider, "quick-pane provider")?;
|
||||||
|
bounded(&quick.model, "quick-pane model")?;
|
||||||
|
bounded(&quick.follow_up_provider, "quick-pane follow-up provider")?;
|
||||||
|
bounded(&quick.follow_up_model, "quick-pane follow-up model")?;
|
||||||
|
}
|
||||||
|
if quick.instructions.is_empty() || quick.instructions.len() > 4096 {
|
||||||
|
return Err("Quick-pane instructions must contain 1 to 4096 bytes".to_owned());
|
||||||
|
}
|
||||||
|
if !(1..=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 let Some(scale) = state.interface_scale {
|
||||||
if !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
|
if !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
|
||||||
return Err("Interface scale is outside the supported range".to_owned());
|
return Err("Interface scale is outside the supported range".to_owned());
|
||||||
@@ -217,7 +292,16 @@ pub fn load_from(app_data_dir: &Path) -> Result<UiStateV1, String> {
|
|||||||
if content.len() > MAX_FILE_BYTES {
|
if content.len() > MAX_FILE_BYTES {
|
||||||
return Err("UI state file exceeds the size limit".to_owned());
|
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}"))?;
|
.map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?;
|
||||||
validate(&state)?;
|
validate(&state)?;
|
||||||
Ok(state)
|
Ok(state)
|
||||||
@@ -368,6 +452,47 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_pane_settings_validate_complete_existing_workspace() {
|
||||||
|
let root = temp_dir("quick-pane");
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
let mut state = UiStateV1::default();
|
||||||
|
state.quick_pane.workspace = root.to_string_lossy().into_owned();
|
||||||
|
state.quick_pane.provider = "provider".into();
|
||||||
|
state.quick_pane.model = "quick-model".into();
|
||||||
|
state.quick_pane.follow_up_provider = "provider".into();
|
||||||
|
state.quick_pane.follow_up_model = "follow-up-model".into();
|
||||||
|
assert!(validate(&state).is_ok());
|
||||||
|
state.quick_pane.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]
|
#[test]
|
||||||
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
|
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
|
||||||
let root = temp_dir("replace");
|
let root = temp_dir("replace");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
|
"label": "main",
|
||||||
"title": "Pi Status UI",
|
"title": "Pi Status UI",
|
||||||
"width": 1100,
|
"width": 1100,
|
||||||
"height": 840,
|
"height": 840,
|
||||||
@@ -23,6 +24,21 @@
|
|||||||
"alwaysOnTop": true,
|
"alwaysOnTop": true,
|
||||||
"skipTaskbar": true,
|
"skipTaskbar": true,
|
||||||
"shadow": 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": {
|
"security": {
|
||||||
|
|||||||
+520
-164
@@ -66,16 +66,17 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
.workflow-header {
|
.workflow-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-height: 24px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
.workspace-heading,
|
.workspace-heading {
|
||||||
.status-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
color: #f0a347;
|
color: #f0a347;
|
||||||
@@ -84,39 +85,17 @@ select:focus-visible {
|
|||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
}
|
}
|
||||||
.workflow-header h1 {
|
.workflow-header h1 {
|
||||||
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.status-row {
|
.workflow-header > button {
|
||||||
margin-top: 1px;
|
padding: 4px 8px;
|
||||||
color: #aaa6a0;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.state-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
border: 1px solid #555;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.state-pill.working {
|
|
||||||
color: #ffd39a;
|
|
||||||
border-color: #f0a347;
|
|
||||||
background: #4b3420;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(240, 163, 71, 0.22),
|
|
||||||
0 0 10px rgba(240, 163, 71, 0.2);
|
|
||||||
}
|
|
||||||
.state-pill.recovering {
|
|
||||||
color: #e0c2a1;
|
|
||||||
border-color: #8d6745;
|
|
||||||
}
|
|
||||||
.state-pill.error {
|
|
||||||
color: #ffb4ba;
|
|
||||||
border-color: #a65b67;
|
|
||||||
}
|
|
||||||
.working-pulse {
|
.working-pulse {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
@@ -126,17 +105,6 @@ select:focus-visible {
|
|||||||
box-shadow: 0 0 0 0 rgba(240, 163, 71, 0.7);
|
box-shadow: 0 0 0 0 rgba(240, 163, 71, 0.7);
|
||||||
animation: work-pulse 1.35s ease-out infinite;
|
animation: work-pulse 1.35s ease-out infinite;
|
||||||
}
|
}
|
||||||
.working-banner {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border: 1px solid #b76f26;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #41301f;
|
|
||||||
color: #ffd39a;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.workspace-warning {
|
.workspace-warning {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border: 1px solid #8c713c;
|
border: 1px solid #8c713c;
|
||||||
@@ -158,6 +126,18 @@ select:focus-visible {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
.command-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #a65b67;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #3d252b;
|
||||||
|
color: #ffd7db;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
.workspace-layout {
|
.workspace-layout {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -244,107 +224,242 @@ select:focus-visible {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
.session-strip {
|
.session-strip {
|
||||||
display: block;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.session-tab-bar {
|
.session-tab-bar {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: end;
|
grid-template-columns: 28px minmax(0, 1fr) 28px auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
min-width: 0;
|
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 {
|
.session-tabs {
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 5px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
gap: 2px;
|
overflow-y: hidden;
|
||||||
padding-left: 4px;
|
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 {
|
.session-tab {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex: 0 0 auto;
|
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"] {
|
.session-tab > button[role="tab"] {
|
||||||
position: relative;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
min-width: 0;
|
||||||
min-width: 124px;
|
align-content: center;
|
||||||
max-width: 190px;
|
gap: 3px;
|
||||||
padding: 6px 8px 5px;
|
padding: 5px 7px 5px 10px;
|
||||||
border: 1px solid #555;
|
border: 0;
|
||||||
border-bottom: 0;
|
border-radius: 0;
|
||||||
border-radius: 8px 8px 0 0;
|
background: transparent;
|
||||||
background: #303136;
|
|
||||||
color: #d5d7dc;
|
color: #d5d7dc;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.session-tab.selected > button[role="tab"] {
|
.session-tab > button[role="tab"]:hover {
|
||||||
border-color: #d88735;
|
background: rgba(255, 255, 255, 0.025);
|
||||||
background: #3b3935;
|
|
||||||
color: #fff5e7;
|
|
||||||
box-shadow: inset 0 2px #f0a347;
|
|
||||||
}
|
}
|
||||||
.session-tab > button span {
|
.session-tab.selected > button[role="tab"] {
|
||||||
|
color: #fff5e7;
|
||||||
|
}
|
||||||
|
.session-tab-label {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.session-tab small {
|
.session-tab-meta {
|
||||||
grid-row: 2;
|
display: flex;
|
||||||
color: #788077;
|
min-width: 0;
|
||||||
font-size: 10px;
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
.session-tab small.streaming,
|
.session-tab-meta small {
|
||||||
.session-tab small.recovering {
|
overflow: hidden;
|
||||||
color: #ffc170;
|
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 {
|
.close-session {
|
||||||
align-self: stretch;
|
font-size: 16px;
|
||||||
border: 1px solid #505257;
|
}
|
||||||
border-bottom: 0;
|
.session-tab.selected .renew-session,
|
||||||
border-left: 0;
|
.session-tab.selected .close-session {
|
||||||
border-radius: 0 8px 0 0;
|
border-left-color: #594d40;
|
||||||
padding: 4px 8px;
|
color: #f3deca;
|
||||||
background: #303136;
|
}
|
||||||
color: #b8bbc1;
|
.renew-session:hover:not(:disabled) {
|
||||||
|
background: #294139;
|
||||||
|
color: #c8ffef;
|
||||||
|
}
|
||||||
|
.close-session:hover:not(:disabled) {
|
||||||
|
background: #4a3131;
|
||||||
|
color: #ffd7db;
|
||||||
}
|
}
|
||||||
.unread-dot {
|
.unread-dot {
|
||||||
width: 7px;
|
width: 6px;
|
||||||
height: 7px;
|
height: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f0a347;
|
background: #f0a347;
|
||||||
}
|
}
|
||||||
.attention-dot {
|
.attention-dot {
|
||||||
color: #ffcb6b;
|
color: #ffcb6b;
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.session-tab-actions {
|
.session-tab-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 3px;
|
align-self: stretch;
|
||||||
padding: 0 0 2px 5px;
|
gap: 4px;
|
||||||
|
padding-left: 2px;
|
||||||
|
border-left: 1px solid #424449;
|
||||||
}
|
}
|
||||||
.session-tab-actions button {
|
.session-tab-actions button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 44px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 1px solid #55585d;
|
border: 1px solid #51545a;
|
||||||
border-bottom: 0;
|
border-radius: 7px;
|
||||||
border-radius: 6px 6px 0 0;
|
padding: 0 9px;
|
||||||
padding: 5px 8px;
|
background: #303237;
|
||||||
background: #383a40;
|
color: #dde0e5;
|
||||||
color: #e2e4e8;
|
|
||||||
}
|
}
|
||||||
.session-tab-actions button:hover {
|
.session-tab-actions button:hover:not(:disabled) {
|
||||||
background: #4a4540;
|
border-color: #8a6339;
|
||||||
|
background: #3d372f;
|
||||||
color: #ffc170;
|
color: #ffc170;
|
||||||
}
|
}
|
||||||
.session-tab-actions .new-session {
|
.session-tab-actions .new-session-tab {
|
||||||
border-color: #d88735;
|
border-color: #9a6938;
|
||||||
background: #4a3827;
|
background: #443526;
|
||||||
color: #ffc170;
|
color: #ffc170;
|
||||||
}
|
}
|
||||||
.session-tab-actions span {
|
.session-tab-actions button > span:first-child {
|
||||||
margin-right: 5px;
|
font-size: 16px;
|
||||||
font-size: 15px;
|
line-height: 1;
|
||||||
line-height: 0;
|
}
|
||||||
|
.session-action-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.directory-add {
|
.directory-add {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -356,18 +471,68 @@ select:focus-visible {
|
|||||||
padding: 7px;
|
padding: 7px;
|
||||||
}
|
}
|
||||||
.workflow {
|
.workflow {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: minmax(0, 1fr) auto;
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
.session-lock {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 18;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(20, 18, 16, 0.86);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
.session-lock-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: min(420px, 92%);
|
||||||
|
padding: 15px 17px;
|
||||||
|
border: 1px solid #f0a347;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #33271d;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.48);
|
||||||
|
color: #ffd9aa;
|
||||||
|
}
|
||||||
|
.session-lock-card > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.session-lock-card strong {
|
||||||
|
color: #fff2df;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.session-lock-card p {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.session-lock-card small {
|
||||||
|
color: #d8b892;
|
||||||
|
}
|
||||||
|
.session-lock-bars {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
.workflow-main {
|
.workflow-main {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 190px;
|
grid-template-columns: minmax(0, 1fr) 190px;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
.transcript-container {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.transcript {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
.transcript,
|
.transcript,
|
||||||
.todos-pane,
|
.todos-pane,
|
||||||
.settings,
|
.settings,
|
||||||
@@ -385,6 +550,33 @@ select:focus-visible {
|
|||||||
inset 3px 0 #f0a347,
|
inset 3px 0 #f0a347,
|
||||||
0 0 16px rgba(240, 163, 71, 0.25);
|
0 0 16px rgba(240, 163, 71, 0.25);
|
||||||
}
|
}
|
||||||
|
.transcript:focus-visible {
|
||||||
|
outline: 3px solid #f0a347;
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
.scroll-to-latest {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 12px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #f0a347;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3d2d1d;
|
||||||
|
box-shadow: 0 5px 14px rgba(0, 0, 0, 0.4);
|
||||||
|
color: #ffd39a;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.scroll-to-latest:hover {
|
||||||
|
background: #4b3420;
|
||||||
|
color: #fff0da;
|
||||||
|
}
|
||||||
.agent-working {
|
.agent-working {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -489,30 +681,62 @@ select:focus-visible {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.32;
|
line-height: 1.32;
|
||||||
}
|
}
|
||||||
.tool-result summary {
|
.tool-result-visible > strong,
|
||||||
display: flex;
|
.tool-result-label {
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
color: #a9f2df;
|
color: #a9f2df;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
.tool-result summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
.tool-result summary::-webkit-details-marker {
|
.tool-result summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.tool-result summary::after {
|
.tool-result summary::after {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
content: "▾";
|
content: "▾";
|
||||||
|
color: #a9f2df;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.tool-result-preview {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: block;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-left: 2px solid #547d72;
|
||||||
|
background: #222b29;
|
||||||
|
color: #c9d8d4;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.32;
|
||||||
|
text-transform: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.tool-result-hidden-hint {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #91a69f;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
.tool-result[open] summary::after {
|
.tool-result[open] summary::after {
|
||||||
content: "▴";
|
content: "▴";
|
||||||
}
|
}
|
||||||
.tool-result[open] summary {
|
.tool-result[open] summary {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
.tool-result[open] .tool-result-preview,
|
||||||
|
.tool-result[open] .tool-result-hidden-hint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.pending-message {
|
.pending-message {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
@@ -566,66 +790,111 @@ select:focus-visible {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: #303030;
|
background: #303030;
|
||||||
}
|
}
|
||||||
.pi-controls label {
|
.session-status-strip {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.pi-controls select {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
appearance: none;
|
align-items: center;
|
||||||
color-scheme: dark;
|
justify-content: space-between;
|
||||||
padding: 5px 25px 5px 6px;
|
gap: 8px;
|
||||||
border: 1px solid #70522f;
|
padding: 4px 6px;
|
||||||
border-radius: 4px;
|
border: 1px solid #454545;
|
||||||
background: #2c2a27
|
border-left: 3px solid #687168;
|
||||||
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")
|
border-radius: 5px;
|
||||||
no-repeat right 7px center;
|
background: #292a29;
|
||||||
color: #f2ece4;
|
color: #b7bcb7;
|
||||||
}
|
}
|
||||||
.pi-controls select:hover {
|
.session-status-primary,
|
||||||
border-color: #d88735;
|
.session-status-meta,
|
||||||
background-color: #383129;
|
.status-chip {
|
||||||
}
|
|
||||||
.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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
}
|
||||||
|
.session-status-primary {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: #8f988e;
|
}
|
||||||
font-size: 11px;
|
.session-status-primary strong {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #d8ddd8;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.session-status-detail {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #9da49d;
|
||||||
|
font-size: 10px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.session-status-meta {
|
||||||
|
max-width: 68%;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.session-status-meta::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.status-chip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: 1px solid #494b49;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #323332;
|
||||||
|
color: #bcc1bc;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status-chip small {
|
||||||
|
color: #7f877f;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.work-dot {
|
.work-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
|
flex: 0 0 auto;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #687168;
|
background: #687168;
|
||||||
}
|
}
|
||||||
.composer-progress.working {
|
.session-status-strip.working {
|
||||||
padding: 4px 6px;
|
border-color: #60492f;
|
||||||
border-left: 3px solid #f0a347;
|
border-left-color: #f0a347;
|
||||||
background: #3b3025;
|
background: #332d26;
|
||||||
color: #ffd39a;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
.composer-progress.working .work-dot {
|
.session-status-strip.working .session-status-primary strong,
|
||||||
|
.session-status-strip.working .session-status-detail {
|
||||||
|
color: #ffd39a;
|
||||||
|
}
|
||||||
|
.session-status-strip.working .work-dot {
|
||||||
background: #f0a347;
|
background: #f0a347;
|
||||||
box-shadow: 0 0 8px #f0a347;
|
box-shadow: 0 0 8px #f0a347;
|
||||||
animation: work-pulse 1.35s ease-out infinite;
|
animation: work-pulse 1.35s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
.session-status-strip.recovering {
|
||||||
|
border-left-color: #b9824b;
|
||||||
|
}
|
||||||
|
.session-status-strip.error {
|
||||||
|
border-color: #6f3f47;
|
||||||
|
border-left-color: #e47682;
|
||||||
|
background: #34272a;
|
||||||
|
}
|
||||||
|
.session-status-strip.error .session-status-primary strong,
|
||||||
|
.session-status-strip.error .session-status-detail {
|
||||||
|
color: #ffb4ba;
|
||||||
|
}
|
||||||
.composer {
|
.composer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
@@ -654,6 +923,25 @@ select:focus-visible {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
.restart-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px solid #b76f26;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #41301f;
|
||||||
|
color: #ffd39a;
|
||||||
|
}
|
||||||
|
.restart-progress > span:last-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
.restart-progress small {
|
||||||
|
color: #d9b987;
|
||||||
|
}
|
||||||
.default-worktree-control {
|
.default-worktree-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -694,6 +982,56 @@ select:focus-visible {
|
|||||||
color: #b9afa4;
|
color: #b9afa4;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
.quick-pane-settings {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 560px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #4b4b4b;
|
||||||
|
}
|
||||||
|
.quick-pane-settings h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.quick-pane-settings label,
|
||||||
|
.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 {
|
.extension-widget {
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border-left: 3px solid #f0a347;
|
border-left: 3px solid #f0a347;
|
||||||
@@ -742,17 +1080,14 @@ select:focus-visible {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
transform: scale(1.35);
|
transform: scale(1.35);
|
||||||
}
|
}
|
||||||
.modal-backdrop,
|
dialog.session-picker::backdrop,
|
||||||
.extension-backdrop {
|
dialog.extension::backdrop {
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
background: rgba(0, 0, 0, 0.62);
|
background: rgba(0, 0, 0, 0.62);
|
||||||
}
|
}
|
||||||
|
dialog.session-picker:not([open]),
|
||||||
|
dialog.extension:not([open]) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.session-picker,
|
.session-picker,
|
||||||
.extension {
|
.extension {
|
||||||
width: min(680px, 90vw);
|
width: min(680px, 90vw);
|
||||||
@@ -760,10 +1095,12 @@ select:focus-visible {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin: auto;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border: 1px solid #b76f26;
|
border: 1px solid #b76f26;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #2d2e31;
|
background: #2d2e31;
|
||||||
|
color: #f1f3f4;
|
||||||
}
|
}
|
||||||
.session-picker {
|
.session-picker {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -820,6 +1157,9 @@ select:focus-visible {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
.session-row-content > strong {
|
||||||
|
color: #f1f3f4;
|
||||||
|
}
|
||||||
.session-preview,
|
.session-preview,
|
||||||
.session-metadata {
|
.session-metadata {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -883,6 +1223,11 @@ select:focus-visible {
|
|||||||
.notification {
|
.notification {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
@keyframes tab-action-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@keyframes work-pulse {
|
@keyframes work-pulse {
|
||||||
70% {
|
70% {
|
||||||
box-shadow: 0 0 0 7px rgba(240, 163, 71, 0);
|
box-shadow: 0 0 0 7px rgba(240, 163, 71, 0);
|
||||||
@@ -915,11 +1260,22 @@ select:focus-visible {
|
|||||||
.workflow-main {
|
.workflow-main {
|
||||||
grid-template-columns: minmax(0, 1fr) 165px;
|
grid-template-columns: minmax(0, 1fr) 165px;
|
||||||
}
|
}
|
||||||
.session-strip-actions {
|
.session-tab-bar {
|
||||||
flex-direction: column;
|
grid-template-columns: 26px minmax(0, 1fr) 26px auto;
|
||||||
}
|
}
|
||||||
.session-tab > button[role="tab"] {
|
.session-scroll-button {
|
||||||
min-width: 125px;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
+1297
File diff suppressed because it is too large
Load Diff
+847
-138
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -1,17 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { RpcCommand, RpcField } from "../commands/rpc";
|
import type { RpcCommand, RpcField } from "../commands/rpc";
|
||||||
|
|
||||||
const controls = (container: HTMLElement | null) =>
|
type RpcInputValue =
|
||||||
Array.from(
|
| string
|
||||||
container?.querySelectorAll<HTMLElement>(
|
| boolean
|
||||||
"button, input, select, textarea",
|
| null
|
||||||
) ?? [],
|
| RpcInputValue[]
|
||||||
).filter((node) => !node.hasAttribute("disabled"));
|
| { [key: string]: RpcInputValue }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
function valueFor(field: RpcField, value: string | boolean): unknown {
|
function valueFor(field: RpcField, value: string | boolean): RpcInputValue {
|
||||||
if (field.kind === "boolean") return value === true;
|
if (field.kind === "boolean") return value === true;
|
||||||
if (field.kind === "json")
|
if (field.kind === "json")
|
||||||
return value ? JSON.parse(value as string) : undefined;
|
return value ? (JSON.parse(value as string) as RpcInputValue) : undefined;
|
||||||
return value || undefined;
|
return value || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,31 +20,32 @@ export function CommandFormDialog({
|
|||||||
command,
|
command,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
pending = false,
|
||||||
|
commandError,
|
||||||
}: {
|
}: {
|
||||||
command?: RpcCommand;
|
command?: RpcCommand;
|
||||||
onSubmit: (input: Record<string, unknown>) => void;
|
onSubmit: (input: Record<string, unknown>) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
pending?: boolean;
|
||||||
|
commandError?: string;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLElement>(null);
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
const opener = useRef<HTMLElement | null>(null);
|
|
||||||
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
||||||
const [error, setError] = useState<string>();
|
const [validationError, setValidationError] = useState<string>();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!command) return;
|
if (!command) return;
|
||||||
opener.current = document.activeElement as HTMLElement | null;
|
|
||||||
setValues({});
|
setValues({});
|
||||||
setError(undefined);
|
setValidationError(undefined);
|
||||||
const frame = requestAnimationFrame(() =>
|
const dialog = ref.current;
|
||||||
controls(ref.current)[0]?.focus(),
|
dialog?.showModal();
|
||||||
);
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(frame);
|
if (dialog?.open) dialog.close();
|
||||||
opener.current?.focus();
|
|
||||||
};
|
};
|
||||||
}, [command]);
|
}, [command]);
|
||||||
if (!command) return null;
|
if (!command) return null;
|
||||||
const fields = command.input ?? [];
|
const fields = command.input ?? [];
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
|
if (pending) return;
|
||||||
try {
|
try {
|
||||||
const input: Record<string, unknown> = {};
|
const input: Record<string, unknown> = {};
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
@@ -57,35 +59,18 @@ export function CommandFormDialog({
|
|||||||
}
|
}
|
||||||
onSubmit(input);
|
onSubmit(input);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
setError(cause instanceof Error ? cause.message : "Invalid input");
|
setValidationError(cause instanceof Error ? cause.message : "Invalid input");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="extension-backdrop">
|
<dialog
|
||||||
<section
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="extension"
|
className="extension"
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="command-form-title"
|
aria-labelledby="command-form-title"
|
||||||
onKeyDown={(event) => {
|
aria-busy={pending}
|
||||||
if (event.key === "Escape") {
|
onCancel={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onCancel();
|
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>
|
<p className="extension-eyebrow">Pi RPC command</p>
|
||||||
@@ -99,6 +84,7 @@ export function CommandFormDialog({
|
|||||||
aria-label={field.label}
|
aria-label={field.label}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={values[field.name] === true}
|
checked={values[field.name] === true}
|
||||||
|
disabled={pending}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValues({
|
setValues({
|
||||||
...values,
|
...values,
|
||||||
@@ -110,6 +96,7 @@ export function CommandFormDialog({
|
|||||||
<select
|
<select
|
||||||
aria-label={field.label}
|
aria-label={field.label}
|
||||||
value={(values[field.name] as string) ?? ""}
|
value={(values[field.name] as string) ?? ""}
|
||||||
|
disabled={pending}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValues({
|
setValues({
|
||||||
...values,
|
...values,
|
||||||
@@ -126,6 +113,7 @@ export function CommandFormDialog({
|
|||||||
<textarea
|
<textarea
|
||||||
aria-label={field.label}
|
aria-label={field.label}
|
||||||
value={(values[field.name] as string) ?? ""}
|
value={(values[field.name] as string) ?? ""}
|
||||||
|
disabled={pending}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValues({
|
setValues({
|
||||||
...values,
|
...values,
|
||||||
@@ -137,6 +125,7 @@ export function CommandFormDialog({
|
|||||||
<input
|
<input
|
||||||
aria-label={field.label}
|
aria-label={field.label}
|
||||||
value={(values[field.name] as string) ?? ""}
|
value={(values[field.name] as string) ?? ""}
|
||||||
|
disabled={pending}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValues({
|
setValues({
|
||||||
...values,
|
...values,
|
||||||
@@ -147,14 +136,17 @@ export function CommandFormDialog({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{error && <p role="alert">{error}</p>}
|
{(validationError || commandError) && (
|
||||||
|
<p role="alert">{validationError || commandError}</p>
|
||||||
|
)}
|
||||||
<div className="extension-options">
|
<div className="extension-options">
|
||||||
<button onClick={submit}>Run command</button>
|
<button disabled={pending} onClick={submit}>
|
||||||
<button className="quiet" onClick={onCancel}>
|
{pending ? "Running…" : "Run command"}
|
||||||
|
</button>
|
||||||
|
<button className="quiet" disabled={pending} onClick={onCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</dialog>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { useEffect, useMemo, useRef } from "react";
|
|||||||
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
|
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
|
||||||
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
|
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
|
||||||
import { CommandPalette } from "./CommandPalette";
|
import { CommandPalette } from "./CommandPalette";
|
||||||
|
import { ModelSelector } from "./ModelSelector";
|
||||||
|
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]) {
|
export function messageText(message: RuntimeView["messages"][number]) {
|
||||||
if (typeof message.content === "string") return message.content;
|
if (typeof message.content === "string") return message.content;
|
||||||
@@ -15,21 +25,77 @@ export function messageText(message: RuntimeView["messages"][number]) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
function visibleLines(text: string) {
|
||||||
|
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||||
|
while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactCount(value: number) {
|
||||||
|
const units = [
|
||||||
|
{ threshold: 1_000_000_000, divisor: 1_000_000_000, suffix: "b" },
|
||||||
|
{ threshold: 1_000_000, divisor: 1_000_000, suffix: "m" },
|
||||||
|
{ threshold: 1_000, divisor: 1_000, suffix: "k" },
|
||||||
|
];
|
||||||
|
const unit = units.find((candidate) => Math.abs(value) >= candidate.threshold);
|
||||||
|
if (!unit) return String(value);
|
||||||
|
const scaled = Math.round((value / unit.divisor) * 10) / 10;
|
||||||
|
return `${scaled}${unit.suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeStatusLabel(isWorking: boolean, state: string) {
|
||||||
|
if (isWorking) return "Working";
|
||||||
|
return state === "idle" ? "Pi ready" : state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainStatusText(value: string) {
|
||||||
|
return value
|
||||||
|
.normalize("NFC")
|
||||||
|
.replace(/\u001B\[[0-?]*[ -/]*[@-~]/gu, "")
|
||||||
|
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/gu, "")
|
||||||
|
.replace(
|
||||||
|
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uE000-\uF8FF\uFEFF\uFFFD]/gu,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function TranscriptMessage({
|
function TranscriptMessage({
|
||||||
message,
|
message,
|
||||||
}: {
|
}: {
|
||||||
message: RuntimeView["messages"][number];
|
message: RuntimeView["messages"][number];
|
||||||
}) {
|
}) {
|
||||||
const role = message.role ?? "system";
|
const role = message.role ?? "system";
|
||||||
if (role === "toolResult")
|
if (role === "toolResult") {
|
||||||
|
const text = messageText(message);
|
||||||
|
const lines = visibleLines(text);
|
||||||
|
const label = `Tool result · ${message.toolName ?? "tool"}`;
|
||||||
|
if (lines.length <= 2)
|
||||||
|
return (
|
||||||
|
<article className={`message ${role} tool-result-visible`}>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<pre>{text}</pre>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<article className={`message ${role}`}>
|
<article className={`message ${role}`}>
|
||||||
<details className="tool-result">
|
<details className="tool-result">
|
||||||
<summary>Tool result · {message.toolName ?? "tool"}</summary>
|
<summary>
|
||||||
<pre>{messageText(message)}</pre>
|
<span className="tool-result-label">{label}</span>
|
||||||
|
<span className="tool-result-preview">
|
||||||
|
{lines.slice(0, 2).join("\n")}
|
||||||
|
</span>
|
||||||
|
<span className="tool-result-hidden-hint">
|
||||||
|
{lines.length - 2} more {lines.length - 2 === 1 ? "line" : "lines"}{" "}
|
||||||
|
hidden · Expand to view
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<pre className="tool-result-full">{text}</pre>
|
||||||
</details>
|
</details>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<article className={`message ${role}`}>
|
<article className={`message ${role}`}>
|
||||||
<strong>{message.role ?? "message"}</strong>
|
<strong>{message.role ?? "message"}</strong>
|
||||||
@@ -75,8 +141,13 @@ export function ConversationWorkspace({
|
|||||||
onScroll,
|
onScroll,
|
||||||
onModel,
|
onModel,
|
||||||
onThinking,
|
onThinking,
|
||||||
|
focusRequest = 0,
|
||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
isAborting = false,
|
isAborting = false,
|
||||||
|
isModelUpdating = false,
|
||||||
|
locked = false,
|
||||||
|
lockReason = "renewing",
|
||||||
|
status,
|
||||||
onCommand,
|
onCommand,
|
||||||
}: {
|
}: {
|
||||||
runtime: RuntimeView;
|
runtime: RuntimeView;
|
||||||
@@ -84,35 +155,102 @@ export function ConversationWorkspace({
|
|||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onAbort: () => void;
|
onAbort: () => void;
|
||||||
onScroll: (atBottom: boolean, scrollTop: number) => void;
|
onScroll: (atBottom: boolean, scrollTop: number) => void;
|
||||||
onModel: (model: Model) => void;
|
onModel: (model: Model) => Promise<boolean>;
|
||||||
onThinking: (level: string) => void;
|
onThinking: (level: string) => Promise<boolean>;
|
||||||
|
focusRequest?: number;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
isAborting?: boolean;
|
isAborting?: boolean;
|
||||||
|
isModelUpdating?: boolean;
|
||||||
|
locked?: boolean;
|
||||||
|
lockReason?: "renewing" | "restarting";
|
||||||
|
status?: SessionStatus;
|
||||||
onCommand: (command: RpcCommand) => void;
|
onCommand: (command: RpcCommand) => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLElement>(null);
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
|
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
|
||||||
const runtimeIsWorking =
|
const runtimeIsWorking =
|
||||||
runtime.summary.state === "streaming" ||
|
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
|
||||||
runtime.progress.phase === "working";
|
|
||||||
const isWorking =
|
const isWorking =
|
||||||
isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
|
isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
|
||||||
|
const presentedStatus = status ?? {
|
||||||
|
label: runtimeStatusLabel(isWorking, runtime.summary.state),
|
||||||
|
detail: isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail,
|
||||||
|
phase: isWorking ? ("working" as const) : runtime.progress.phase,
|
||||||
|
bridge: "unknown",
|
||||||
|
extensions: Object.values(runtime.extensionStatuses),
|
||||||
|
};
|
||||||
|
const visibleStatus = {
|
||||||
|
label: plainStatusText(presentedStatus.label) || "Pi status",
|
||||||
|
detail: plainStatusText(presentedStatus.detail) || "No status details",
|
||||||
|
bridge: plainStatusText(presentedStatus.bridge) || "unknown",
|
||||||
|
extensions: presentedStatus.extensions.map(plainStatusText).filter(Boolean),
|
||||||
|
};
|
||||||
|
const activeTool = runtime.progress.tool
|
||||||
|
? plainStatusText(runtime.progress.tool)
|
||||||
|
: undefined;
|
||||||
|
const 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(() => {
|
useEffect(() => {
|
||||||
if (runtime.scroll.atBottom && ref.current)
|
if (runtime.scroll.atBottom && ref.current)
|
||||||
ref.current.scrollTop = ref.current.scrollHeight;
|
ref.current.scrollTop = ref.current.scrollHeight;
|
||||||
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
|
}, [
|
||||||
|
runtime.messages,
|
||||||
|
runtime.pendingSubmissions,
|
||||||
|
runtime.scroll.atBottom,
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="workflow"
|
className="workflow"
|
||||||
id="session-workspace"
|
id="session-workspace"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
|
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
|
||||||
|
aria-busy={locked}
|
||||||
>
|
>
|
||||||
<div className="workflow-main">
|
<div className="workflow-main" inert={locked ? true : undefined}>
|
||||||
|
<div className="transcript-container">
|
||||||
<section
|
<section
|
||||||
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
|
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
|
||||||
aria-label="Conversation"
|
aria-label="Conversation"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
tabIndex={-1}
|
||||||
onScroll={(event) => {
|
onScroll={(event) => {
|
||||||
const node = event.currentTarget;
|
const node = event.currentTarget;
|
||||||
onScroll(
|
onScroll(
|
||||||
@@ -121,26 +259,6 @@ export function ConversationWorkspace({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{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}
|
|
||||||
</span>
|
|
||||||
{runtime.progress.tool && (
|
|
||||||
<small>Using {runtime.progress.tool}</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{runtime.loading && !runtime.messages.length && (
|
{runtime.loading && !runtime.messages.length && (
|
||||||
<p className="muted">Loading session…</p>
|
<p className="muted">Loading session…</p>
|
||||||
)}
|
)}
|
||||||
@@ -161,13 +279,39 @@ export function ConversationWorkspace({
|
|||||||
<pre>{pending.text}</pre>
|
<pre>{pending.text}</pre>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
{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}
|
||||||
|
</span>
|
||||||
|
{runtime.progress.tool && <small>Using {runtime.progress.tool}</small>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{!runtime.scroll.atBottom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="scroll-to-latest"
|
||||||
|
aria-label="Scroll to latest message"
|
||||||
|
title="Scroll to latest message"
|
||||||
|
onClick={scrollToLatest}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<aside className="todos-pane" aria-label="Current todos">
|
<aside className="todos-pane" aria-label="Current todos">
|
||||||
<div className="todos-heading">
|
<div className="todos-heading">
|
||||||
<strong>Todos</strong>
|
<strong>Todos</strong>
|
||||||
<span>
|
<span>{tasks.filter((task) => task.status !== "completed").length}</span>
|
||||||
{tasks.filter((task) => task.status !== "completed").length}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{tasks.length ? (
|
{tasks.length ? (
|
||||||
<ol className="todo-list">
|
<ol className="todo-list">
|
||||||
@@ -186,7 +330,7 @@ export function ConversationWorkspace({
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<footer className="workflow-footer">
|
<footer className="workflow-footer" inert={locked ? true : undefined}>
|
||||||
{Object.values(runtime.extensionWidgets)
|
{Object.values(runtime.extensionWidgets)
|
||||||
.flatMap((widget) => widget.lines)
|
.flatMap((widget) => widget.lines)
|
||||||
.map((line, index) => (
|
.map((line, index) => (
|
||||||
@@ -197,51 +341,85 @@ export function ConversationWorkspace({
|
|||||||
<section
|
<section
|
||||||
className="pi-controls"
|
className="pi-controls"
|
||||||
aria-label="Model and thinking controls"
|
aria-label="Model and thinking controls"
|
||||||
|
aria-busy={isModelUpdating || locked}
|
||||||
>
|
>
|
||||||
<label>
|
<ModelSelector
|
||||||
Model
|
models={runtime.models}
|
||||||
<select
|
current={runtime.state.model}
|
||||||
value={`${runtime.state.model?.provider ?? ""}/${runtime.state.model?.id ?? ""}`}
|
onSelect={onModel}
|
||||||
onChange={(event) => {
|
pending={isModelUpdating || locked}
|
||||||
const [provider, id] = event.currentTarget.value.split("/");
|
/>
|
||||||
onModel({ provider, id });
|
<ThinkingSelector
|
||||||
}}
|
model={selectedThinkingModel ?? runtime.state.model}
|
||||||
>
|
|
||||||
<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"}
|
value={runtime.state.thinkingLevel ?? "medium"}
|
||||||
onChange={(event) => onThinking(event.currentTarget.value)}
|
disabled={isModelUpdating || locked}
|
||||||
>
|
onChange={(level) => void onThinking(level)}
|
||||||
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
|
/>
|
||||||
(level) => (
|
|
||||||
<option key={level}>{level}</option>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</section>
|
</section>
|
||||||
<div
|
<div
|
||||||
className={`composer-progress ${isSubmitting ? "working" : runtime.progress.phase}`}
|
className={`session-status-strip ${presentedStatus.phase}`}
|
||||||
role="status"
|
role="status"
|
||||||
|
aria-label="Session status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<span className="work-dot" />
|
<span className="session-status-primary">
|
||||||
<span>
|
<span className="work-dot" aria-hidden="true" />
|
||||||
{isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail}
|
<strong>{visibleStatus.label}</strong>
|
||||||
|
<span className="session-status-detail">{visibleStatus.detail}</span>
|
||||||
|
</span>
|
||||||
|
<span className="session-status-meta" aria-label="Status details">
|
||||||
|
{inputTokens !== undefined && (
|
||||||
|
<span className="status-chip" title="Input tokens">
|
||||||
|
<small>In</small>
|
||||||
|
{compactCount(inputTokens)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{outputTokens !== undefined && (
|
||||||
|
<span className="status-chip" title="Output tokens">
|
||||||
|
<small>Out</small>
|
||||||
|
{compactCount(outputTokens)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cacheReadTokens !== undefined && (
|
||||||
|
<span className="status-chip" title="Cache-read tokens">
|
||||||
|
<small>Cache read</small>
|
||||||
|
{compactCount(cacheReadTokens)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{undividedTokens !== undefined && (
|
||||||
|
<span className="status-chip" title="Total tokens">
|
||||||
|
<small>Tokens</small>
|
||||||
|
{compactCount(undividedTokens)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{contextDetail && (
|
||||||
|
<span className="status-chip" title="Current context window usage">
|
||||||
|
<small>Context</small>
|
||||||
|
{contextDetail}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{runtime.summary.queueCount > 0 && (
|
||||||
|
<span className="status-chip">
|
||||||
|
<small>Queue</small>
|
||||||
|
{runtime.summary.queueCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="status-chip bridge-status">
|
||||||
|
<small>Bridge</small>
|
||||||
|
{visibleStatus.bridge}
|
||||||
|
</span>
|
||||||
|
{activeTool && (
|
||||||
|
<span className="status-chip">
|
||||||
|
<small>Tool</small>
|
||||||
|
{activeTool}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{visibleStatus.extensions.map((entry, index) => (
|
||||||
|
<span className="status-chip extension-status" key={`${entry}-${index}`}>
|
||||||
|
{entry}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</span>
|
</span>
|
||||||
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="composer">
|
<div className="composer">
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
@@ -251,8 +429,9 @@ export function ConversationWorkspace({
|
|||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Prompt"
|
aria-label="Prompt"
|
||||||
|
ref={promptRef}
|
||||||
value={runtime.draft}
|
value={runtime.draft}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || locked}
|
||||||
onChange={(event) => onDraft(event.currentTarget.value)}
|
onChange={(event) => onDraft(event.currentTarget.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
@@ -267,7 +446,7 @@ export function ConversationWorkspace({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="quiet"
|
className="quiet"
|
||||||
disabled={isAborting}
|
disabled={isAborting || locked}
|
||||||
onClick={onAbort}
|
onClick={onAbort}
|
||||||
>
|
>
|
||||||
{isAborting ? "Aborting…" : "Abort"}
|
{isAborting ? "Aborting…" : "Abort"}
|
||||||
@@ -276,6 +455,7 @@ export function ConversationWorkspace({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={
|
disabled={
|
||||||
|
locked ||
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
!runtime.summary.agentId ||
|
!runtime.summary.agentId ||
|
||||||
!runtime.draft.trim()
|
!runtime.draft.trim()
|
||||||
@@ -287,6 +467,28 @@ export function ConversationWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
{locked && (
|
||||||
|
<div className="session-lock" role="status" aria-live="polite">
|
||||||
|
<div className="session-lock-card">
|
||||||
|
<span className="working-bars session-lock-bars" aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{lockReason === "renewing" ? "Renewing session" : "Restarting Pi"}
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
{lockReason === "renewing"
|
||||||
|
? "Clearing active history and starting a fresh Pi session…"
|
||||||
|
: "Restoring this Pi session and its extensions…"}
|
||||||
|
</p>
|
||||||
|
<small>Input and output are temporarily locked.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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\)\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { Extension } from "../workspace/types";
|
import type { Extension } from "../workspace/types";
|
||||||
|
|
||||||
function controls(container: HTMLElement | null) {
|
|
||||||
return [
|
|
||||||
...(container?.querySelectorAll<HTMLElement>(
|
|
||||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
|
||||||
) ?? []),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExtensionDialog({
|
export function ExtensionDialog({
|
||||||
extension,
|
extension,
|
||||||
onRespond,
|
onRespond,
|
||||||
@@ -16,57 +8,31 @@ export function ExtensionDialog({
|
|||||||
extension?: Extension;
|
extension?: Extension;
|
||||||
onRespond: (response: Record<string, unknown>) => void;
|
onRespond: (response: Record<string, unknown>) => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLElement>(null);
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
const opener = useRef<HTMLElement | null>(null);
|
|
||||||
const respond = useRef(onRespond);
|
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
respond.current = onRespond;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!extension) return;
|
if (!extension) return;
|
||||||
opener.current = document.activeElement as HTMLElement | null;
|
|
||||||
setValue(
|
setValue(
|
||||||
extension.prefill ?? extension.initialValue ?? extension.text ?? "",
|
extension.prefill ?? extension.initialValue ?? extension.text ?? "",
|
||||||
);
|
);
|
||||||
const frame = requestAnimationFrame(() =>
|
const dialog = ref.current;
|
||||||
controls(ref.current)[0]?.focus(),
|
dialog?.showModal();
|
||||||
);
|
|
||||||
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 () => {
|
return () => {
|
||||||
cancelAnimationFrame(frame);
|
if (dialog?.open) dialog.close();
|
||||||
window.removeEventListener("keydown", keydown);
|
|
||||||
opener.current?.focus();
|
|
||||||
};
|
};
|
||||||
}, [extension]);
|
}, [extension]);
|
||||||
|
|
||||||
if (!extension) return null;
|
if (!extension) return null;
|
||||||
return (
|
return (
|
||||||
<div className="extension-backdrop">
|
<dialog
|
||||||
<section
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="extension"
|
className="extension"
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="extension-title"
|
aria-labelledby="extension-title"
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onRespond({ cancelled: true });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<p className="extension-eyebrow">Pi agent request</p>
|
<p className="extension-eyebrow">Pi agent request</p>
|
||||||
<h2 id="extension-title">Review request</h2>
|
<h2 id="extension-title">Review request</h2>
|
||||||
@@ -125,13 +91,9 @@ export function ExtensionDialog({
|
|||||||
<button onClick={() => onRespond({ value })}>Submit</button>
|
<button onClick={() => onRespond({ value })}>Submit</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button className="quiet" onClick={() => onRespond({ cancelled: true })}>
|
||||||
className="quiet"
|
|
||||||
onClick={() => onRespond({ cancelled: true })}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</dialog>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { DirectorySession } from "../workspace/types";
|
import type { DirectorySession } from "../workspace/types";
|
||||||
|
|
||||||
const focusableSelector =
|
|
||||||
'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])';
|
|
||||||
|
|
||||||
function sessionTitle(session: DirectorySession) {
|
function sessionTitle(session: DirectorySession) {
|
||||||
return session.name ?? session.firstMessage ?? "Untitled session";
|
return session.name ?? session.firstMessage ?? "Untitled session";
|
||||||
}
|
}
|
||||||
@@ -26,9 +23,7 @@ function SessionRow({
|
|||||||
onOpen: (session: DirectorySession) => void;
|
onOpen: (session: DirectorySession) => void;
|
||||||
}) {
|
}) {
|
||||||
const showPreview = Boolean(
|
const showPreview = Boolean(
|
||||||
session.name &&
|
session.name && session.firstMessage && session.firstMessage !== session.name,
|
||||||
session.firstMessage &&
|
|
||||||
session.firstMessage !== session.name,
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
@@ -71,47 +66,6 @@ function SessionResults({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDialogFocus(
|
|
||||||
dialogRef: RefObject<HTMLElement | null>,
|
|
||||||
searchRef: RefObject<HTMLInputElement | null>,
|
|
||||||
onClose: () => void,
|
|
||||||
) {
|
|
||||||
const openerRef = useRef<HTMLElement | null>(
|
|
||||||
document.activeElement as HTMLElement | null,
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = dialogRef.current;
|
|
||||||
const controls = () => [
|
|
||||||
...(dialog?.querySelectorAll<HTMLElement>(focusableSelector) ?? []),
|
|
||||||
];
|
|
||||||
searchRef.current?.focus();
|
|
||||||
const keydown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== "Tab") return;
|
|
||||||
const focusable = controls();
|
|
||||||
const first = focusable[0];
|
|
||||||
const last = focusable[focusable.length - 1];
|
|
||||||
if (!first || !last) return;
|
|
||||||
if (event.shiftKey && document.activeElement === first) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", keydown);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", keydown);
|
|
||||||
openerRef.current?.focus();
|
|
||||||
};
|
|
||||||
}, [dialogRef, onClose, searchRef]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionPicker({
|
export function SessionPicker({
|
||||||
sessions,
|
sessions,
|
||||||
onOpen,
|
onOpen,
|
||||||
@@ -122,23 +76,29 @@ export function SessionPicker({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const dialogRef = useRef<HTMLElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
|
||||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
const filteredSessions = useMemo(
|
const filteredSessions = useMemo(
|
||||||
() => sessions.filter((session) => matchesQuery(session, normalizedQuery)),
|
() => sessions.filter((session) => matchesQuery(session, normalizedQuery)),
|
||||||
[normalizedQuery, sessions],
|
[normalizedQuery, sessions],
|
||||||
);
|
);
|
||||||
useDialogFocus(dialogRef, searchRef, onClose);
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
dialog?.showModal();
|
||||||
|
return () => {
|
||||||
|
if (dialog?.open) dialog.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
const resultLabel = `${filteredSessions.length} saved session${filteredSessions.length === 1 ? "" : "s"}`;
|
const resultLabel = `${filteredSessions.length} saved session${filteredSessions.length === 1 ? "" : "s"}`;
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop">
|
<dialog
|
||||||
<section
|
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className="session-picker"
|
className="session-picker"
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="session-picker-title"
|
aria-labelledby="session-picker-title"
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="picker-heading">
|
<div className="picker-heading">
|
||||||
<h2 id="session-picker-title">Saved sessions</h2>
|
<h2 id="session-picker-title">Saved sessions</h2>
|
||||||
@@ -149,7 +109,7 @@ export function SessionPicker({
|
|||||||
Search saved sessions
|
Search saved sessions
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={searchRef}
|
autoFocus
|
||||||
id="session-search"
|
id="session-search"
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
@@ -164,13 +124,8 @@ export function SessionPicker({
|
|||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<p className="muted">No saved sessions for this directory.</p>
|
<p className="muted">No saved sessions for this directory.</p>
|
||||||
) : (
|
) : (
|
||||||
<SessionResults
|
<SessionResults sessions={filteredSessions} query={query} onOpen={onOpen} />
|
||||||
sessions={filteredSessions}
|
|
||||||
query={query}
|
|
||||||
onOpen={onOpen}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</dialog>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
import type { RuntimeView } from "../workspace/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -6,32 +12,88 @@ type Props = {
|
|||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onClose: (runtime: RuntimeView) => void;
|
onClose: (runtime: RuntimeView) => void;
|
||||||
|
onRenew: (runtime: RuntimeView) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
onResume: () => void;
|
onResume: () => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
closingRuntimeId?: string;
|
closingRuntimeIds?: ReadonlySet<string>;
|
||||||
|
renewingRuntimeIds?: ReadonlySet<string>;
|
||||||
restoreFocus?: boolean;
|
restoreFocus?: boolean;
|
||||||
onFocusRestored?: () => void;
|
onFocusRestored?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SessionTabs({
|
export function SessionTabs({
|
||||||
runtimes,
|
runtimes,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
|
onRenew,
|
||||||
onNew,
|
onNew,
|
||||||
onResume,
|
onResume,
|
||||||
busy,
|
busy,
|
||||||
closingRuntimeId,
|
closingRuntimeIds = new Set(),
|
||||||
|
renewingRuntimeIds = new Set(),
|
||||||
restoreFocus,
|
restoreFocus,
|
||||||
onFocusRestored,
|
onFocusRestored,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
|
const tabsRef = useRef<HTMLDivElement>(null);
|
||||||
const newSessionRef = useRef<HTMLButtonElement>(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(() => {
|
useEffect(() => {
|
||||||
if (!restoreFocus) return;
|
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?.();
|
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) {
|
function keydown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||||
let next: number | undefined;
|
let next: number | undefined;
|
||||||
if (!runtimes.length) return;
|
if (!runtimes.length) return;
|
||||||
@@ -42,20 +104,55 @@ export function SessionTabs({
|
|||||||
else if (event.key === "End") next = runtimes.length - 1;
|
else if (event.key === "End") next = runtimes.length - 1;
|
||||||
if (next === undefined) return;
|
if (next === undefined) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
refs.current[runtimes[next].summary.runtimeId]?.focus();
|
const runtimeId = runtimes[next].summary.runtimeId;
|
||||||
onSelect(runtimes[next].summary.runtimeId);
|
refs.current[runtimeId]?.focus();
|
||||||
|
reveal(runtimeId);
|
||||||
|
onSelect(runtimeId);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="session-strip">
|
<div className="session-strip">
|
||||||
<div className="session-tab-bar" aria-label="Sessions">
|
<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) => {
|
{runtimes.map((runtime, index) => {
|
||||||
const summary = runtime.summary;
|
const summary = runtime.summary;
|
||||||
const selected = summary.runtimeId === selectedId;
|
const selected = summary.runtimeId === selectedId;
|
||||||
|
const renewing = renewingRuntimeIds.has(summary.runtimeId);
|
||||||
|
const closing = closingRuntimeIds.has(summary.runtimeId);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`session-tab ${selected ? "selected" : ""}`}
|
className={`session-tab ${selected ? "selected" : ""}`}
|
||||||
key={summary.runtimeId}
|
key={summary.runtimeId}
|
||||||
|
role="presentation"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -66,15 +163,19 @@ export function SessionTabs({
|
|||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
refs.current[summary.runtimeId] = element;
|
refs.current[summary.runtimeId] = element;
|
||||||
}}
|
}}
|
||||||
|
onFocus={() => reveal(summary.runtimeId)}
|
||||||
onKeyDown={(event) => keydown(event, index)}
|
onKeyDown={(event) => keydown(event, index)}
|
||||||
onClick={() => onSelect(summary.runtimeId)}
|
onClick={() => onSelect(summary.runtimeId)}
|
||||||
>
|
>
|
||||||
<span>{summary.label}</span>
|
<span className="session-tab-label">{summary.label}</span>
|
||||||
<small className={summary.state}>
|
<span className="session-tab-meta">
|
||||||
|
<i
|
||||||
|
className={`session-state-dot ${summary.state}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
{summary.state}
|
{summary.state}
|
||||||
{summary.queueCount
|
{summary.queueCount ? ` · ${summary.queueCount} queued` : ""}
|
||||||
? ` · ${summary.queueCount} queued`
|
|
||||||
: ""}
|
|
||||||
</small>
|
</small>
|
||||||
{runtime.unread && (
|
{runtime.unread && (
|
||||||
<i className="unread-dot" aria-label="Unread activity" />
|
<i className="unread-dot" aria-label="Unread activity" />
|
||||||
@@ -84,32 +185,69 @@ export function SessionTabs({
|
|||||||
!
|
!
|
||||||
</b>
|
</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>
|
||||||
<button
|
<button
|
||||||
className="close-session"
|
className="close-session"
|
||||||
aria-label={`Close ${summary.label}`}
|
aria-label={
|
||||||
disabled={closingRuntimeId === summary.runtimeId}
|
closing ? `Closing ${summary.label}` : `Close ${summary.label}`
|
||||||
|
}
|
||||||
|
aria-busy={closing}
|
||||||
|
disabled={renewing || closing}
|
||||||
onClick={() => onClose(runtime)}
|
onClick={() => onClose(runtime)}
|
||||||
>
|
>
|
||||||
{closingRuntimeId === summary.runtimeId ? "…" : "×"}
|
{closing ? "…" : "×"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<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
|
<button
|
||||||
className="new-session"
|
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="new-session-tab"
|
||||||
ref={newSessionRef}
|
ref={newSessionRef}
|
||||||
|
aria-label={busy ? "Starting new session" : "New session"}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={onNew}
|
onClick={onNew}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">+</span>
|
<span aria-hidden="true">{busy ? "…" : "+"}</span>
|
||||||
{busy ? "Starting…" : "New"}
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { DirectorySidebar } from "./DirectorySidebar";
|
import { DirectorySidebar } from "./DirectorySidebar";
|
||||||
@@ -42,6 +42,8 @@ describe("multi-session navigation", () => {
|
|||||||
it("renders directory aggregates and accessible keyboard session tabs", async () => {
|
it("renders directory aggregates and accessible keyboard session tabs", async () => {
|
||||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
|
const onNew = vi.fn();
|
||||||
|
const onRenew = vi.fn();
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<>
|
<>
|
||||||
<DirectorySidebar
|
<DirectorySidebar
|
||||||
@@ -58,7 +60,8 @@ describe("multi-session navigation", () => {
|
|||||||
selectedId="a"
|
selectedId="a"
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onNew={vi.fn()}
|
onRenew={onRenew}
|
||||||
|
onNew={onNew}
|
||||||
onResume={vi.fn()}
|
onResume={vi.fn()}
|
||||||
/>
|
/>
|
||||||
</>,
|
</>,
|
||||||
@@ -66,6 +69,21 @@ describe("multi-session navigation", () => {
|
|||||||
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
|
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
|
||||||
const tabs = screen.getAllByRole("tab");
|
const tabs = screen.getAllByRole("tab");
|
||||||
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
|
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
|
||||||
|
const closeFirst = screen.getByRole("button", { name: "Close a" });
|
||||||
|
const renewFirst = screen.getByRole("button", { name: "Renew a" });
|
||||||
|
expect(tabs[0].parentElement?.lastElementChild).toBe(closeFirst);
|
||||||
|
expect(closeFirst.previousElementSibling).toBe(renewFirst);
|
||||||
|
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
|
||||||
|
const newSession = screen.getByRole("button", { name: "New session" });
|
||||||
|
expect(tablist.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.click(tabs[0]);
|
||||||
await userEvent.keyboard("{ArrowRight}");
|
await userEvent.keyboard("{ArrowRight}");
|
||||||
expect(onSelect).toHaveBeenCalledWith("b");
|
expect(onSelect).toHaveBeenCalledWith("b");
|
||||||
@@ -75,12 +93,86 @@ describe("multi-session navigation", () => {
|
|||||||
selectedId="b"
|
selectedId="b"
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
|
onRenew={vi.fn()}
|
||||||
onNew={vi.fn()}
|
onNew={vi.fn()}
|
||||||
onResume={vi.fn()}
|
onResume={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
||||||
});
|
});
|
||||||
|
it("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", () => {
|
it("restores focus to the selected adjacent tab after close", () => {
|
||||||
const onFocusRestored = vi.fn();
|
const onFocusRestored = vi.fn();
|
||||||
render(
|
render(
|
||||||
@@ -89,6 +181,7 @@ describe("multi-session navigation", () => {
|
|||||||
selectedId="b"
|
selectedId="b"
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
|
onRenew={vi.fn()}
|
||||||
onNew={vi.fn()}
|
onNew={vi.fn()}
|
||||||
onResume={vi.fn()}
|
onResume={vi.fn()}
|
||||||
restoreFocus
|
restoreFocus
|
||||||
@@ -98,13 +191,64 @@ describe("multi-session navigation", () => {
|
|||||||
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
|
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
|
||||||
expect(onFocusRestored).toHaveBeenCalled();
|
expect(onFocusRestored).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("collapses a tool result until its summary is expanded", async () => {
|
it("shows two-line tool results and previews longer collapsed results", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const runtime = runtimeView(summary("tool-result"));
|
const runtime = runtimeView(summary("tool-result"));
|
||||||
runtime.messages = [
|
runtime.messages = [
|
||||||
{ role: "toolResult", toolName: "read", content: "Detailed tool output" },
|
{ role: "toolResult", toolName: "read", content: "first\nsecond\n" },
|
||||||
];
|
];
|
||||||
const { 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
|
<ConversationWorkspace
|
||||||
runtime={runtime}
|
runtime={runtime}
|
||||||
onDraft={vi.fn()}
|
onDraft={vi.fn()}
|
||||||
@@ -116,10 +260,146 @@ describe("multi-session navigation", () => {
|
|||||||
onCommand={vi.fn()}
|
onCommand={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const details = container.querySelector("details");
|
const thinking = screen.getByRole("combobox", { name: "Thinking" });
|
||||||
expect(details).not.toHaveAttribute("open");
|
expect(
|
||||||
await user.click(screen.getByText("Tool result · read"));
|
within(thinking)
|
||||||
expect(details).toHaveAttribute("open");
|
.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", () => {
|
it("shows an aborting control and labels an accepted follow-up", () => {
|
||||||
const runtime = runtimeView(summary("working", "streaming"));
|
const runtime = runtimeView(summary("working", "streaming"));
|
||||||
@@ -191,9 +471,7 @@ describe("multi-session navigation", () => {
|
|||||||
|
|
||||||
await user.type(search, "release");
|
await user.type(search, "release");
|
||||||
expect(screen.getByRole("status")).toHaveTextContent("1 saved session");
|
expect(screen.getByRole("status")).toHaveTextContent("1 saved session");
|
||||||
expect(
|
expect(screen.getByRole("button", { name: /Planning/ })).toBeInTheDocument();
|
||||||
screen.getByRole("button", { name: /Planning/ }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: /Investigate/ })).toBeNull();
|
expect(screen.queryByRole("button", { name: /Investigate/ })).toBeNull();
|
||||||
await user.clear(search);
|
await user.clear(search);
|
||||||
await user.type(search, "INCIDENT-42");
|
await user.type(search, "INCIDENT-42");
|
||||||
@@ -203,29 +481,16 @@ describe("multi-session navigation", () => {
|
|||||||
await user.type(search, "missing");
|
await user.type(search, "missing");
|
||||||
expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument();
|
expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it("focuses search, traps focus, and restores the opener", async () => {
|
it("opens a native modal and handles cancellation", () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const opener = document.createElement("button");
|
|
||||||
document.body.append(opener);
|
|
||||||
opener.focus();
|
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const { unmount } = render(
|
render(<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />);
|
||||||
<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />,
|
const dialog = screen.getByRole("dialog", { name: "Saved sessions" });
|
||||||
);
|
expect(dialog).toHaveAttribute("open");
|
||||||
const search = screen.getByRole("searchbox", {
|
expect(
|
||||||
name: "Search saved sessions",
|
screen.getByRole("searchbox", { name: "Search saved sessions" }),
|
||||||
});
|
).toHaveFocus();
|
||||||
const close = screen.getByRole("button", { name: "Close" });
|
fireEvent(dialog, new Event("cancel", { cancelable: true }));
|
||||||
expect(search).toHaveFocus();
|
|
||||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
|
||||||
expect(close).toHaveFocus();
|
|
||||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
|
||||||
expect(search).toHaveFocus();
|
|
||||||
await user.keyboard("{Escape}");
|
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
unmount();
|
|
||||||
expect(opener).toHaveFocus();
|
|
||||||
document.body.removeChild(opener);
|
|
||||||
});
|
});
|
||||||
it("labels permission requests with the action and exact details", async () => {
|
it("labels permission requests with the action and exact details", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -235,8 +500,7 @@ describe("multi-session navigation", () => {
|
|||||||
extension={{
|
extension={{
|
||||||
method: "confirm",
|
method: "confirm",
|
||||||
title: "Run a shell command",
|
title: "Run a shell command",
|
||||||
message:
|
message: "rm -rf ./build\nThis removes the generated build directory.",
|
||||||
"rm -rf ./build\nThis removes the generated build directory.",
|
|
||||||
}}
|
}}
|
||||||
onRespond={onRespond}
|
onRespond={onRespond}
|
||||||
/>,
|
/>,
|
||||||
@@ -264,9 +528,10 @@ describe("multi-session navigation", () => {
|
|||||||
"Existing request",
|
"Existing request",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it("routes new, saved-open, and close controls through callbacks", async () => {
|
it("routes new, saved-open, renew, and close controls through callbacks", async () => {
|
||||||
const onNew = vi.fn(),
|
const onNew = vi.fn(),
|
||||||
onResume = vi.fn(),
|
onResume = vi.fn(),
|
||||||
|
onRenew = vi.fn(),
|
||||||
onClose = vi.fn();
|
onClose = vi.fn();
|
||||||
render(
|
render(
|
||||||
<SessionTabs
|
<SessionTabs
|
||||||
@@ -274,15 +539,22 @@ describe("multi-session navigation", () => {
|
|||||||
selectedId="a"
|
selectedId="a"
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
onRenew={onRenew}
|
||||||
onNew={onNew}
|
onNew={onNew}
|
||||||
onResume={onResume}
|
onResume={onResume}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "New" }));
|
await userEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Load" }));
|
await userEvent.click(screen.getByRole("button", { name: "Load" }));
|
||||||
|
await userEvent.click(screen.getByLabelText("Renew a"));
|
||||||
await userEvent.click(screen.getByLabelText("Close a"));
|
await userEvent.click(screen.getByLabelText("Close a"));
|
||||||
expect(onNew).toHaveBeenCalled();
|
expect(onNew).toHaveBeenCalled();
|
||||||
expect(onResume).toHaveBeenCalled();
|
expect(onResume).toHaveBeenCalled();
|
||||||
|
expect(onRenew).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
summary: expect.objectContaining({ runtimeId: "a" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -1,9 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import QuickPane from "./quick-pane/QuickPane";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
{getCurrentWindow().label === "quick-pane" ? <QuickPane /> : <App />}
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach } from "vitest";
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
if (!HTMLDialogElement.prototype.showModal) {
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||||
|
this.open = true;
|
||||||
|
};
|
||||||
|
HTMLDialogElement.prototype.close = function close() {
|
||||||
|
this.open = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -42,7 +42,10 @@ export async function loadPresentation() {
|
|||||||
return invoke<UiState>("load_ui_state");
|
return invoke<UiState>("load_ui_state");
|
||||||
}
|
}
|
||||||
export async function savePresentation(state: UiState) {
|
export async function savePresentation(state: UiState) {
|
||||||
await invoke("save_ui_state", { state });
|
const current = await loadPresentation();
|
||||||
|
await invoke("save_ui_state", {
|
||||||
|
state: { ...state, quickPane: current.quickPane },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
export function legacyPreferences(storage: Pick<Storage, "getItem">) {
|
export function legacyPreferences(storage: Pick<Storage, "getItem">) {
|
||||||
const path = storage.getItem(selectedPathKey) ?? undefined;
|
const path = storage.getItem(selectedPathKey) ?? undefined;
|
||||||
|
|||||||
@@ -74,6 +74,176 @@ describe("workspace reducer", () => {
|
|||||||
expect(state.runtimesById.b.messages[0].content).toBe("b");
|
expect(state.runtimesById.b.messages[0].content).toBe("b");
|
||||||
expect(state.runtimesById.b.unread).toBe(true);
|
expect(state.runtimesById.b.unread).toBe(true);
|
||||||
});
|
});
|
||||||
|
it("uses the runtime summary as the authoritative snapshot working state", () => {
|
||||||
|
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "snapshotRequested",
|
||||||
|
runtimeId: "a",
|
||||||
|
generation: 1,
|
||||||
|
});
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "snapshotLoaded",
|
||||||
|
runtimeId: "a",
|
||||||
|
generation: 1,
|
||||||
|
snapshot: {
|
||||||
|
bridgeInstanceId: "epoch",
|
||||||
|
latestSeq: 1,
|
||||||
|
runtime: { ...runtime("a"), state: "streaming" },
|
||||||
|
state: { data: { isStreaming: false } },
|
||||||
|
extensions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(state.runtimesById.a.progress.phase).toBe("working");
|
||||||
|
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "snapshotRequested",
|
||||||
|
runtimeId: "a",
|
||||||
|
generation: 2,
|
||||||
|
});
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "snapshotLoaded",
|
||||||
|
runtimeId: "a",
|
||||||
|
generation: 2,
|
||||||
|
snapshot: {
|
||||||
|
bridgeInstanceId: "epoch",
|
||||||
|
latestSeq: 2,
|
||||||
|
runtime: runtime("a"),
|
||||||
|
state: { data: { isStreaming: true } },
|
||||||
|
extensions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(state.runtimesById.a.progress.phase).toBe("idle");
|
||||||
|
});
|
||||||
|
it("requests another snapshot when a live event overtakes the current load", () => {
|
||||||
|
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "snapshotRequested",
|
||||||
|
runtimeId: "a",
|
||||||
|
generation: 1,
|
||||||
|
});
|
||||||
|
state = applyWorkspaceEvent(state, {
|
||||||
|
bridgeInstanceId: "epoch",
|
||||||
|
seq: 1,
|
||||||
|
type: "runtime_event",
|
||||||
|
data: {
|
||||||
|
runtimeId: "a",
|
||||||
|
eventType: "queue",
|
||||||
|
eventData: { event: { pendingMessageCount: 1 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "snapshotLoaded",
|
||||||
|
runtimeId: "a",
|
||||||
|
generation: 1,
|
||||||
|
snapshot: {
|
||||||
|
bridgeInstanceId: "epoch",
|
||||||
|
latestSeq: 0,
|
||||||
|
runtime: runtime("a"),
|
||||||
|
extensions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.runtimesById.a.loading).toBe(false);
|
||||||
|
expect(state.runtimesById.a.snapshotRetry).toBe(true);
|
||||||
|
expect(state.runtimesById.a.summary.queueCount).toBe(1);
|
||||||
|
});
|
||||||
|
it("preserves known models and exposes model discovery failures", () => {
|
||||||
|
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "snapshotRequested",
|
||||||
|
runtimeId: "a",
|
||||||
|
generation: 1,
|
||||||
|
});
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "snapshotLoaded",
|
||||||
|
runtimeId: "a",
|
||||||
|
generation: 1,
|
||||||
|
snapshot: {
|
||||||
|
bridgeInstanceId: "epoch",
|
||||||
|
latestSeq: 1,
|
||||||
|
runtime: runtime("a"),
|
||||||
|
models: {
|
||||||
|
data: { models: [{ provider: "openai", id: "gpt-5" }] },
|
||||||
|
},
|
||||||
|
extensions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "snapshotRequested",
|
||||||
|
runtimeId: "a",
|
||||||
|
generation: 2,
|
||||||
|
});
|
||||||
|
state = workspaceReducer(state, {
|
||||||
|
type: "snapshotLoaded",
|
||||||
|
runtimeId: "a",
|
||||||
|
generation: 2,
|
||||||
|
snapshot: {
|
||||||
|
bridgeInstanceId: "epoch",
|
||||||
|
latestSeq: 1,
|
||||||
|
runtime: runtime("a"),
|
||||||
|
models: {
|
||||||
|
data: { models: [] },
|
||||||
|
error: "RPC timed out",
|
||||||
|
},
|
||||||
|
extensions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.runtimesById.a.models).toEqual([
|
||||||
|
{ provider: "openai", id: "gpt-5" },
|
||||||
|
]);
|
||||||
|
expect(state.runtimesById.a.modelError).toContain("RPC timed out");
|
||||||
|
});
|
||||||
|
it("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", () => {
|
it("removes the optimistic submission when Pi returns its structured user message", () => {
|
||||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||||
state = workspaceReducer(state, {
|
state = workspaceReducer(state, {
|
||||||
@@ -259,8 +429,8 @@ describe("workspace reducer", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
expect(
|
expect(
|
||||||
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed
|
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed.summary
|
||||||
.summary.agentId,
|
.agentId,
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => {
|
it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => {
|
||||||
@@ -318,9 +488,7 @@ describe("workspace reducer", () => {
|
|||||||
it("clears stale extension and transcript state for every runtime after an epoch change", () => {
|
it("clears stale extension and transcript state for every runtime after an epoch change", () => {
|
||||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||||
state.runtimesById.b.messages = [{ role: "assistant", content: "old" }];
|
state.runtimesById.b.messages = [{ role: "assistant", content: "old" }];
|
||||||
state.runtimesById.b.extensions = [
|
state.runtimesById.b.extensions = [{ id: "dead-request", method: "confirm" }];
|
||||||
{ id: "dead-request", method: "confirm" },
|
|
||||||
];
|
|
||||||
state.runtimesById.b.unread = true;
|
state.runtimesById.b.unread = true;
|
||||||
const reset = workspaceReducer(state, {
|
const reset = workspaceReducer(state, {
|
||||||
type: "hostEvent",
|
type: "hostEvent",
|
||||||
@@ -340,6 +508,43 @@ describe("workspace reducer", () => {
|
|||||||
expect(restored.runtimesById.b.extensions).toEqual([]);
|
expect(restored.runtimesById.b.extensions).toEqual([]);
|
||||||
expect(restored.runtimesById.b.unread).toBe(false);
|
expect(restored.runtimesById.b.unread).toBe(false);
|
||||||
});
|
});
|
||||||
|
it("clears active history and input when a runtime is renewed", () => {
|
||||||
|
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||||
|
state.runtimesById.a.messages = [
|
||||||
|
{ role: "user", content: "old conversation" },
|
||||||
|
];
|
||||||
|
state.runtimesById.a.pendingSubmissions = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
text: "old pending input",
|
||||||
|
userMessageCount: 1,
|
||||||
|
phase: "sent",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.runtimesById.a.extensions = [
|
||||||
|
{ id: "old-extension", method: "confirm" },
|
||||||
|
];
|
||||||
|
state.runtimesById.a.models = [{ provider: "openai", id: "old-model" }];
|
||||||
|
state.runtimesById.a.draft = "old draft";
|
||||||
|
state.runtimesById.a.scroll = { atBottom: false, scrollTop: 88 };
|
||||||
|
state.runtimesById.a.unread = true;
|
||||||
|
|
||||||
|
const renewed = applyWorkspaceEvent(state, {
|
||||||
|
bridgeInstanceId: "epoch",
|
||||||
|
seq: 1,
|
||||||
|
type: "runtime_renewed",
|
||||||
|
data: { runtimeId: "a", runtime: runtime("a") },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(renewed.runtimesById.a.messages).toEqual([]);
|
||||||
|
expect(renewed.runtimesById.a.pendingSubmissions).toEqual([]);
|
||||||
|
expect(renewed.runtimesById.a.extensions).toEqual([]);
|
||||||
|
expect(renewed.runtimesById.a.models).toEqual([]);
|
||||||
|
expect(renewed.runtimesById.a.draft).toBe("");
|
||||||
|
expect(renewed.runtimesById.a.scroll).toEqual({ atBottom: true });
|
||||||
|
expect(renewed.runtimesById.a.unread).toBe(false);
|
||||||
|
expect(renewed.bridge.status).toBe("resyncing");
|
||||||
|
});
|
||||||
it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => {
|
it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => {
|
||||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||||
const next = applyWorkspaceEvent(state, {
|
const next = applyWorkspaceEvent(state, {
|
||||||
|
|||||||
+67
-18
@@ -16,6 +16,28 @@ const idleProgress = {
|
|||||||
toolCount: 0,
|
toolCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function snapshotProgress(runtime: RuntimeSummary) {
|
||||||
|
if (runtime.state === "streaming")
|
||||||
|
return {
|
||||||
|
phase: "working" as const,
|
||||||
|
detail: "Thinking and preparing a response",
|
||||||
|
toolCount: 0,
|
||||||
|
};
|
||||||
|
if (runtime.state === "recovering")
|
||||||
|
return {
|
||||||
|
phase: "recovering" as const,
|
||||||
|
detail: "Restoring this Pi session",
|
||||||
|
toolCount: 0,
|
||||||
|
};
|
||||||
|
if (runtime.state === "failed")
|
||||||
|
return {
|
||||||
|
phase: "error" as const,
|
||||||
|
detail: runtime.error?.message ?? "This Pi session failed",
|
||||||
|
toolCount: 0,
|
||||||
|
};
|
||||||
|
return idleProgress;
|
||||||
|
}
|
||||||
|
|
||||||
function messageText(message: RuntimeView["messages"][number]) {
|
function messageText(message: RuntimeView["messages"][number]) {
|
||||||
if (typeof message.content === "string") return message.content;
|
if (typeof message.content === "string") return message.content;
|
||||||
return (message.content ?? [])
|
return (message.content ?? [])
|
||||||
@@ -57,6 +79,7 @@ export function runtimeView(
|
|||||||
messages: previous?.messages ?? [],
|
messages: previous?.messages ?? [],
|
||||||
commands: previous?.commands ?? [],
|
commands: previous?.commands ?? [],
|
||||||
models: previous?.models ?? [],
|
models: previous?.models ?? [],
|
||||||
|
modelError: previous?.modelError,
|
||||||
extensions: previous?.extensions ?? [],
|
extensions: previous?.extensions ?? [],
|
||||||
extensionStatuses: previous?.extensionStatuses ?? {},
|
extensionStatuses: previous?.extensionStatuses ?? {},
|
||||||
extensionWidgets: previous?.extensionWidgets ?? {},
|
extensionWidgets: previous?.extensionWidgets ?? {},
|
||||||
@@ -67,6 +90,7 @@ export function runtimeView(
|
|||||||
scroll: previous?.scroll ?? { atBottom: true },
|
scroll: previous?.scroll ?? { atBottom: true },
|
||||||
unread: previous?.unread ?? false,
|
unread: previous?.unread ?? false,
|
||||||
loading: previous?.loading ?? false,
|
loading: previous?.loading ?? false,
|
||||||
|
snapshotRetry: previous?.snapshotRetry ?? false,
|
||||||
error: previous?.error,
|
error: previous?.error,
|
||||||
requestGeneration: previous?.requestGeneration ?? 0,
|
requestGeneration: previous?.requestGeneration ?? 0,
|
||||||
loadedThroughSeq: previous?.loadedThroughSeq ?? 0,
|
loadedThroughSeq: previous?.loadedThroughSeq ?? 0,
|
||||||
@@ -84,6 +108,7 @@ function resetRuntimeView(
|
|||||||
messages: [],
|
messages: [],
|
||||||
commands: [],
|
commands: [],
|
||||||
models: [],
|
models: [],
|
||||||
|
modelError: undefined,
|
||||||
extensions: [],
|
extensions: [],
|
||||||
extensionStatuses: {},
|
extensionStatuses: {},
|
||||||
extensionWidgets: {},
|
extensionWidgets: {},
|
||||||
@@ -92,6 +117,7 @@ function resetRuntimeView(
|
|||||||
unread: false,
|
unread: false,
|
||||||
progress: idleProgress,
|
progress: idleProgress,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
snapshotRetry: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
requestGeneration: 0,
|
requestGeneration: 0,
|
||||||
loadedThroughSeq: 0,
|
loadedThroughSeq: 0,
|
||||||
@@ -147,9 +173,7 @@ export function reconcileWorkspace(
|
|||||||
ids,
|
ids,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const paths = workspace.directories.map(
|
const paths = workspace.directories.map((directory) => directory.worktreePath);
|
||||||
(directory) => directory.worktreePath,
|
|
||||||
);
|
|
||||||
const directoryOrder = ordered(
|
const directoryOrder = ordered(
|
||||||
persisted?.directoryOrder ?? state.directoryOrder,
|
persisted?.directoryOrder ?? state.directoryOrder,
|
||||||
paths,
|
paths,
|
||||||
@@ -158,9 +182,7 @@ export function reconcileWorkspace(
|
|||||||
const selectedRuntimeId =
|
const selectedRuntimeId =
|
||||||
preferred && runtimesById[preferred]
|
preferred && runtimesById[preferred]
|
||||||
? preferred
|
? preferred
|
||||||
: directoryOrder.flatMap(
|
: directoryOrder.flatMap((path) => runtimeOrderByDirectory[path] ?? [])[0];
|
||||||
(path) => runtimeOrderByDirectory[path] ?? [],
|
|
||||||
)[0];
|
|
||||||
const selectedDirectoryPath = selectedRuntimeId
|
const selectedDirectoryPath = selectedRuntimeId
|
||||||
? runtimesById[selectedRuntimeId]?.summary.worktreePath
|
? runtimesById[selectedRuntimeId]?.summary.worktreePath
|
||||||
: directoryOrder[0];
|
: directoryOrder[0];
|
||||||
@@ -261,6 +283,26 @@ export function applyWorkspaceEvent(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const id = event.data?.runtimeId;
|
const id = event.data?.runtimeId;
|
||||||
|
if (
|
||||||
|
event.type === "runtime_renewed" &&
|
||||||
|
id &&
|
||||||
|
state.runtimesById[id] &&
|
||||||
|
event.data?.runtime
|
||||||
|
) {
|
||||||
|
const renewed = resetRuntimeView(event.data.runtime, state.runtimesById[id]);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
bridge: { ...state.bridge, latestSeq: event.seq, status: "resyncing" },
|
||||||
|
runtimesById: {
|
||||||
|
...state.runtimesById,
|
||||||
|
[id]: {
|
||||||
|
...renewed,
|
||||||
|
draft: "",
|
||||||
|
scroll: { atBottom: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes(
|
["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes(
|
||||||
event.type ?? "",
|
event.type ?? "",
|
||||||
@@ -456,8 +498,7 @@ export function workspaceReducer(
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedDirectoryPath: action.worktreePath,
|
selectedDirectoryPath: action.worktreePath,
|
||||||
selectedRuntimeId:
|
selectedRuntimeId: state.runtimeOrderByDirectory[action.worktreePath]?.[0],
|
||||||
state.runtimeOrderByDirectory[action.worktreePath]?.[0],
|
|
||||||
};
|
};
|
||||||
if (action.type === "selectRuntime")
|
if (action.type === "selectRuntime")
|
||||||
return {
|
return {
|
||||||
@@ -483,20 +524,29 @@ export function workspaceReducer(
|
|||||||
const view = { ...state.runtimesById[runtimeId] };
|
const view = { ...state.runtimesById[runtimeId] };
|
||||||
if (action.type === "snapshotRequested") {
|
if (action.type === "snapshotRequested") {
|
||||||
view.loading = true;
|
view.loading = true;
|
||||||
|
view.snapshotRetry = false;
|
||||||
view.error = undefined;
|
view.error = undefined;
|
||||||
view.requestGeneration = action.generation;
|
view.requestGeneration = action.generation;
|
||||||
} else if (action.type === "snapshotFailed") {
|
} else if (action.type === "snapshotFailed") {
|
||||||
if (view.requestGeneration !== action.generation) return state;
|
if (view.requestGeneration !== action.generation) return state;
|
||||||
view.loading = false;
|
view.loading = false;
|
||||||
|
view.snapshotRetry = false;
|
||||||
view.error = action.error;
|
view.error = action.error;
|
||||||
} else if (action.type === "snapshotLoaded") {
|
} else if (action.type === "snapshotLoaded") {
|
||||||
|
if (view.requestGeneration !== action.generation) return state;
|
||||||
if (
|
if (
|
||||||
view.requestGeneration !== action.generation ||
|
|
||||||
action.snapshot.bridgeInstanceId !== state.bridge.instanceId ||
|
action.snapshot.bridgeInstanceId !== state.bridge.instanceId ||
|
||||||
action.snapshot.latestSeq < view.loadedThroughSeq
|
action.snapshot.latestSeq < view.loadedThroughSeq
|
||||||
)
|
) {
|
||||||
return state;
|
|
||||||
view.loading = false;
|
view.loading = false;
|
||||||
|
view.snapshotRetry = true;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
runtimesById: { ...state.runtimesById, [runtimeId]: view },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
view.loading = false;
|
||||||
|
view.snapshotRetry = false;
|
||||||
view.summary = action.snapshot.runtime;
|
view.summary = action.snapshot.runtime;
|
||||||
view.state = action.snapshot.state?.data ?? {};
|
view.state = action.snapshot.state?.data ?? {};
|
||||||
view.stats = action.snapshot.stats?.data ?? {};
|
view.stats = action.snapshot.stats?.data ?? {};
|
||||||
@@ -506,16 +556,15 @@ export function workspaceReducer(
|
|||||||
view.messages,
|
view.messages,
|
||||||
);
|
);
|
||||||
view.commands = action.snapshot.commands?.data?.commands ?? [];
|
view.commands = action.snapshot.commands?.data?.commands ?? [];
|
||||||
|
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.models = action.snapshot.models?.data?.models ?? [];
|
||||||
|
view.modelError = undefined;
|
||||||
|
}
|
||||||
view.extensions = action.snapshot.extensions ?? [];
|
view.extensions = action.snapshot.extensions ?? [];
|
||||||
view.loadedThroughSeq = action.snapshot.latestSeq;
|
view.loadedThroughSeq = action.snapshot.latestSeq;
|
||||||
view.progress = view.state.isStreaming
|
view.progress = snapshotProgress(action.snapshot.runtime);
|
||||||
? {
|
|
||||||
phase: "working",
|
|
||||||
detail: "Thinking and preparing a response",
|
|
||||||
toolCount: 0,
|
|
||||||
}
|
|
||||||
: idleProgress;
|
|
||||||
} else if (action.type === "draftChanged") view.draft = action.draft;
|
} else if (action.type === "draftChanged") view.draft = action.draft;
|
||||||
else if (action.type === "scrollChanged")
|
else if (action.type === "scrollChanged")
|
||||||
view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop };
|
view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop };
|
||||||
|
|||||||
@@ -60,19 +60,23 @@ export type Message = {
|
|||||||
| string
|
| string
|
||||||
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
|
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
|
||||||
};
|
};
|
||||||
|
type ThinkingLevel =
|
||||||
|
| "off"
|
||||||
|
| "minimal"
|
||||||
|
| "low"
|
||||||
|
| "medium"
|
||||||
|
| "high"
|
||||||
|
| "xhigh"
|
||||||
|
| "max";
|
||||||
|
|
||||||
export type AgentState = {
|
export type AgentState = {
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: ThinkingLevel;
|
||||||
sessionName?: string;
|
sessionName?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
messageCount?: number;
|
messageCount?: number;
|
||||||
pendingMessageCount?: number;
|
pendingMessageCount?: number;
|
||||||
model?: {
|
model?: Model & { contextWindow?: number };
|
||||||
provider?: string;
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
contextWindow?: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
export type SessionStats = {
|
export type SessionStats = {
|
||||||
tokens?: {
|
tokens?: {
|
||||||
@@ -89,7 +93,13 @@ export type SessionStats = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type Command = { name?: string; description?: string };
|
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 = {
|
export type Extension = {
|
||||||
id?: string;
|
id?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
@@ -129,7 +139,7 @@ export type RuntimeSnapshot = {
|
|||||||
stats?: { data?: SessionStats };
|
stats?: { data?: SessionStats };
|
||||||
transcript?: { data?: { messages?: Message[] } };
|
transcript?: { data?: { messages?: Message[] } };
|
||||||
commands?: { data?: { commands?: Command[] } };
|
commands?: { data?: { commands?: Command[] } };
|
||||||
models?: { data?: { models?: Model[] } };
|
models?: { data?: { models?: Model[] }; error?: string };
|
||||||
extensions: Extension[];
|
extensions: Extension[];
|
||||||
};
|
};
|
||||||
export type DirectorySession = {
|
export type DirectorySession = {
|
||||||
@@ -180,6 +190,7 @@ export type RuntimeView = {
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
commands: Command[];
|
commands: Command[];
|
||||||
models: Model[];
|
models: Model[];
|
||||||
|
modelError?: string;
|
||||||
extensions: Extension[];
|
extensions: Extension[];
|
||||||
extensionStatuses: Record<string, string>;
|
extensionStatuses: Record<string, string>;
|
||||||
extensionWidgets: Record<
|
extensionWidgets: Record<
|
||||||
@@ -193,6 +204,7 @@ export type RuntimeView = {
|
|||||||
scroll: { atBottom: boolean; scrollTop?: number };
|
scroll: { atBottom: boolean; scrollTop?: number };
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
snapshotRetry: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
requestGeneration: number;
|
requestGeneration: number;
|
||||||
loadedThroughSeq: number;
|
loadedThroughSeq: number;
|
||||||
@@ -219,6 +231,17 @@ export type WorkspaceState = {
|
|||||||
interfaceScale: number;
|
interfaceScale: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QuickPaneSettings = {
|
||||||
|
workspace: string;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
followUpProvider: string;
|
||||||
|
followUpModel: string;
|
||||||
|
instructions: string;
|
||||||
|
maximumToolCalls: number;
|
||||||
|
defaultThinkingLevel: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UiState = {
|
export type UiState = {
|
||||||
version: 1;
|
version: 1;
|
||||||
directoryOrder: string[];
|
directoryOrder: string[];
|
||||||
@@ -234,4 +257,5 @@ export type UiState = {
|
|||||||
interfaceScale?: number;
|
interfaceScale?: number;
|
||||||
defaultNewSessionWorktree?: string;
|
defaultNewSessionWorktree?: string;
|
||||||
dismissedCollisionWarning?: boolean;
|
dismissedCollisionWarning?: boolean;
|
||||||
|
quickPane?: QuickPaneSettings;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||||
import { initialWorkspaceState, workspaceReducer } from "./reducer";
|
import { initialWorkspaceState, workspaceReducer } from "./reducer";
|
||||||
import {
|
import {
|
||||||
@@ -28,9 +27,13 @@ export function useWorkspace() {
|
|||||||
const legacyMigrationPending = useRef(false);
|
const legacyMigrationPending = useRef(false);
|
||||||
const disposed = useRef(true);
|
const disposed = useRef(true);
|
||||||
const refreshGeneration = useRef(0);
|
const refreshGeneration = useRef(0);
|
||||||
|
const workspaceReady = useRef(false);
|
||||||
|
const pendingPromotedRuntime = useRef<string | undefined>(undefined);
|
||||||
|
const consumingPromotion = useRef(false);
|
||||||
|
|
||||||
const loadSnapshot = useCallback(async (runtimeId: string) => {
|
const loadSnapshot = useCallback(
|
||||||
if (disposed.current) return;
|
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
|
||||||
|
if (disposed.current) return false;
|
||||||
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
|
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
|
||||||
loadGeneration.current[runtimeId] = generation;
|
loadGeneration.current[runtimeId] = generation;
|
||||||
dispatch({ type: "snapshotRequested", runtimeId, generation });
|
dispatch({ type: "snapshotRequested", runtimeId, generation });
|
||||||
@@ -41,6 +44,7 @@ export function useWorkspace() {
|
|||||||
);
|
);
|
||||||
if (!disposed.current)
|
if (!disposed.current)
|
||||||
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
|
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!disposed.current)
|
if (!disposed.current)
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -49,8 +53,24 @@ export function useWorkspace() {
|
|||||||
generation,
|
generation,
|
||||||
error: String(error),
|
error: String(error),
|
||||||
});
|
});
|
||||||
|
if (options.throwOnError) throw error;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshotRetryKey = Object.values(state.runtimesById)
|
||||||
|
.flatMap((runtime) =>
|
||||||
|
runtime.snapshotRetry ? [runtime.summary.runtimeId] : [],
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.join("\n");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!snapshotRetryKey) return;
|
||||||
|
for (const runtimeId of snapshotRetryKey.split("\n"))
|
||||||
|
void loadSnapshot(runtimeId);
|
||||||
|
}, [snapshotRetryKey, loadSnapshot]);
|
||||||
|
|
||||||
const scheduleSnapshot = useCallback(
|
const scheduleSnapshot = useCallback(
|
||||||
(runtimeId: string) => {
|
(runtimeId: string) => {
|
||||||
@@ -132,10 +152,44 @@ export function useWorkspace() {
|
|||||||
[loadSnapshot],
|
[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(() => {
|
useEffect(() => {
|
||||||
disposed.current = false;
|
disposed.current = false;
|
||||||
|
workspaceReady.current = false;
|
||||||
let stop: (() => void) | undefined;
|
let stop: (() => void) | undefined;
|
||||||
let stopNewRuntime: (() => 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<{
|
void listen<{
|
||||||
phase: "starting" | "ready" | "error";
|
phase: "starting" | "ready" | "error";
|
||||||
runtimeId?: string;
|
runtimeId?: string;
|
||||||
@@ -166,10 +220,22 @@ export function useWorkspace() {
|
|||||||
void listen<HostEvent>("workspace-bridge", (message) => {
|
void listen<HostEvent>("workspace-bridge", (message) => {
|
||||||
if (disposed.current) return;
|
if (disposed.current) return;
|
||||||
const host = message.payload;
|
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 });
|
dispatch({ type: "hostEvent", host });
|
||||||
if (host.kind === "resetRequired") void refresh(undefined, true);
|
if (host.kind === "resetRequired") void refresh(undefined, true);
|
||||||
else if (host.kind === "event") {
|
else if (host.kind === "event") {
|
||||||
const runtimeId = host.event.data?.runtimeId;
|
|
||||||
const eventType = host.event.type;
|
const eventType = host.event.type;
|
||||||
const piType = String(host.event.data?.eventData?.event?.type ?? "");
|
const piType = String(host.event.data?.eventData?.event?.type ?? "");
|
||||||
if (
|
if (
|
||||||
@@ -177,7 +243,9 @@ export function useWorkspace() {
|
|||||||
"runtime_opened",
|
"runtime_opened",
|
||||||
"runtime_closed",
|
"runtime_closed",
|
||||||
"runtime_recovered",
|
"runtime_recovered",
|
||||||
|
"runtime_renewed",
|
||||||
"runtime_restore_failed",
|
"runtime_restore_failed",
|
||||||
|
"runtime_promoted",
|
||||||
].includes(eventType ?? "")
|
].includes(eventType ?? "")
|
||||||
) {
|
) {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -196,10 +264,17 @@ export function useWorkspace() {
|
|||||||
});
|
});
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
await promotedListenerReady;
|
||||||
const loaded = await loadPresentation();
|
const loaded = await loadPresentation();
|
||||||
if (disposed.current) return;
|
if (disposed.current) return;
|
||||||
await refresh(loaded);
|
await refresh(loaded);
|
||||||
if (disposed.current) return;
|
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;
|
initialized.current = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!disposed.current)
|
if (!disposed.current)
|
||||||
@@ -213,7 +288,6 @@ export function useWorkspace() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const appWindow = getCurrentWindow();
|
|
||||||
const visibility = () =>
|
const visibility = () =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "setWindowActive",
|
type: "setWindowActive",
|
||||||
@@ -224,17 +298,18 @@ export function useWorkspace() {
|
|||||||
document.addEventListener("visibilitychange", visibility);
|
document.addEventListener("visibilitychange", visibility);
|
||||||
return () => {
|
return () => {
|
||||||
disposed.current = true;
|
disposed.current = true;
|
||||||
|
workspaceReady.current = false;
|
||||||
refreshGeneration.current += 1;
|
refreshGeneration.current += 1;
|
||||||
stop?.();
|
stop?.();
|
||||||
stopNewRuntime?.();
|
stopNewRuntime?.();
|
||||||
|
stopPromotedRuntime?.();
|
||||||
void invoke("unsubscribe_workspace").catch(() => {});
|
void invoke("unsubscribe_workspace").catch(() => {});
|
||||||
window.removeEventListener("focus", visibility);
|
window.removeEventListener("focus", visibility);
|
||||||
window.removeEventListener("blur", visibility);
|
window.removeEventListener("blur", visibility);
|
||||||
document.removeEventListener("visibilitychange", visibility);
|
document.removeEventListener("visibilitychange", visibility);
|
||||||
Object.values(timers.current).forEach(clearTimeout);
|
Object.values(timers.current).forEach(clearTimeout);
|
||||||
void appWindow;
|
|
||||||
};
|
};
|
||||||
}, [refresh, scheduleSnapshot]);
|
}, [consumePromotedRuntime, refresh, scheduleSnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized.current) return;
|
if (!initialized.current) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user