Compare commits
30 Commits
bab754714e
...
6385c20f91
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Quick Pane is a small native window opened by a desktop/window-manager hotkey. It accepts one short request and returns either a useful answer or an explicit escalation within 60 seconds.
|
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
|
## Scope
|
||||||
|
|
||||||
- Cold-launchable, independent Tauri window.
|
- Cold-launchable, independent Tauri window.
|
||||||
- Compact request input, quick-model picker, and workspace picker.
|
- Compact request input, quick-model picker, and workspace picker.
|
||||||
- Configured default workspace, quick model, follow-up model, supplemental prompt, time limit, and read-only tool limit.
|
- 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.
|
- 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.
|
- Advisory read-only/no-subagent instructions, inline permission/clarification prompts, and explicit normal-session handoff.
|
||||||
|
|
||||||
@@ -25,23 +25,29 @@ Quick Pane is a small native window opened by a desktop/window-manager hotkey. I
|
|||||||
|
|
||||||
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.
|
Desktop/WM configuration owns the hotkey so it works while Pi GUI is absent. Settings displays and copies the required command; it does not register or modify system bindings.
|
||||||
|
|
||||||
The app creates or focuses a dedicated small Quick Pane window. `Esc` closes it and discards quick-only state.
|
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
|
## Request lifecycle
|
||||||
|
|
||||||
1. Pane chooses configured default workspace and quick model, with per-request overrides.
|
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.
|
2. It starts an isolated Pi RPC runtime in the chosen workspace.
|
||||||
3. Immutable prompt text instructs one request, read-only inspection, zero subagents, configured wall-clock budget, and configured tool-call budget. Current Pi RPC cannot trusted-enforce those instructions; event-derived caps and cleanup are best effort.
|
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.
|
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 answer, tool status, and inline extension requests.
|
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. On success it displays final answer. On a cap it preserves partial answer, explains the cap, and offers handoff.
|
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
|
## Escalation
|
||||||
|
|
||||||
Mutations, subagents, multi-step work, and likely-over-budget work preflight-escalate. The quick agent explains why. The user clicks **Continue in full session** to open a new normal session with:
|
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;
|
- original request;
|
||||||
- escalation note or partial findings;
|
- transcript-derived current-chat context and escalation note or partial findings;
|
||||||
- selected workspace; and
|
- selected workspace; and
|
||||||
- configured follow-up model.
|
- configured follow-up model.
|
||||||
|
|
||||||
@@ -51,16 +57,16 @@ No full session opens automatically.
|
|||||||
|
|
||||||
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 surfaces Pi extension `select`, `confirm`, `input`, and `editor` requests inline. User explicitly answers every prompt. Existing policy remains authoritative: ordinary workspace reads may be allowed, external-directory reads may ask, and sensitive paths may deny.
|
||||||
|
|
||||||
Quick Pane UI request, output, and transcript stay in memory only. Closing pane removes them and triggers best-effort quick-runtime cleanup. Pi may write temporary session data while the child runs. A deliberate handoff persists only in newly created normal-session history.
|
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
|
## Failure handling
|
||||||
|
|
||||||
Unavailable models, startup failures, and crashes render inline actionable errors: retry, pick another quick model, or open normal session. No silent fallback model is used.
|
Settings-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 validation
|
||||||
|
|
||||||
Settings reject invalid model, workspace, custom prompt, or numeric limit. Failed save keeps previously stored configuration and marks invalid fields.
|
Settings reject invalid model, workspace, custom prompt, or tool-call limit. Failed save keeps previously stored configuration and marks invalid fields.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
From cold or hot desktop state, the hotkey produces an answer or explicit escalation within 60 seconds. Automated component, bridge, persistence, and native tests cover behavior; a manual cold-launch/hotkey smoke test verifies desktop integration.
|
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.
|
||||||
|
|||||||
@@ -14,28 +14,32 @@
|
|||||||
|
|
||||||
## 3. Build Quick Pane React surface
|
## 3. Build Quick Pane React surface
|
||||||
|
|
||||||
- Add isolated state reducer/component for request, selected model/workspace, progress, final answer, partial answer, escalation, extension UI, and errors.
|
- 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 in component memory only and request best-effort runtime/session cleanup on close.
|
- 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.
|
- Add Settings controls and copyable WM launch command.
|
||||||
|
|
||||||
## 4. Add constrained bridge runtime mode
|
## 4. Add constrained bridge runtime mode
|
||||||
|
|
||||||
- Extend runtime creation with Quick Pane metadata and ephemeral session lifecycle.
|
- Extend runtime creation with Quick Pane metadata and ephemeral session lifecycle.
|
||||||
- Build immutable advisory quick-agent instructions around supplemental custom prompt.
|
- Build immutable advisory quick-agent instructions around supplemental custom prompt.
|
||||||
- Use observed tool events and wall-clock cancellation for best-effort caps; document that current Pi RPC cannot trusted-enforce read-only/no-subagent policy or memory-only runtime sessions.
|
- 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 likely-over-budget requests.
|
- Instruct preflight escalation for mutation, subagent, multi-step, or complex requests.
|
||||||
|
|
||||||
## 5. Route interaction and handoff
|
## 5. Route interaction and handoff
|
||||||
|
|
||||||
- Reuse existing extension request/response protocol for inline Quick Pane permission and clarification dialogs.
|
- Reuse existing extension request/response protocol for inline Quick Pane permission and clarification dialogs.
|
||||||
- On explicit continuation, create normal runtime using workspace and follow-up model.
|
- After first completed assistant answer, show manual continuation with valid handoff configuration; never open a session automatically.
|
||||||
- Prefill it with original request plus escalation note/partial findings.
|
- Prefill normal session with transcript-derived current-chat context, workspace, and follow-up model.
|
||||||
|
|
||||||
## 6. Test and document
|
## 6. Test and document
|
||||||
|
|
||||||
- Add unit tests for settings validation/persistence and guardrail classification.
|
- Add unit tests for settings validation/persistence and guardrail classification.
|
||||||
- Add component tests for pane states, picker behavior, inline prompts, error/retry, and cleanup.
|
- Add 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, budget cancellation, ephemeral cleanup, and handoff payload.
|
- 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.
|
- Add manual WM hotkey/cold-launch instructions and smoke checklist.
|
||||||
|
|
||||||
## Sequencing
|
## Sequencing
|
||||||
|
|||||||
@@ -6,36 +6,43 @@
|
|||||||
|
|
||||||
- Valid defaults load when no Quick Pane configuration exists.
|
- Valid defaults load when no Quick Pane configuration exists.
|
||||||
- Valid configuration round-trips through native storage.
|
- Valid configuration round-trips through native storage.
|
||||||
- Invalid workspace, model, prompt, time, and tool values show field errors and retain prior saved state.
|
- 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.
|
- Quick model picker affects quick request only; follow-up model remains configured default.
|
||||||
|
|
||||||
### Runtime guardrails
|
### Runtime guardrails
|
||||||
|
|
||||||
- Quick runtime receives immutable advisory read-only/no-subagent prompt text plus supplemental prompt.
|
- Quick runtime receives immutable advisory read-only/no-subagent prompt text plus supplemental prompt.
|
||||||
- Mutation, subagent, multi-step, and likely-over-budget requests are instructed to preflight-escalate.
|
- Mutation, subagent, multi-step, and complex requests are instructed to preflight-escalate.
|
||||||
- Runtime applies best-effort configured 60-second/6-tool caps from observed events and exposes reason.
|
- Quick Pane has no time-based abort; configured tool-call guidance remains prompt-only.
|
||||||
- Partial result survives a cap; pane transcript is removed and runtime cleanup requested after pane close.
|
- 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.
|
- Tests/documentation state current limitations: Pi RPC cannot trusted-enforce prompt policy or guarantee memory-only temporary sessions.
|
||||||
|
|
||||||
### UI and bridge
|
### UI and bridge
|
||||||
|
|
||||||
- Quick Pane opens/focuses through dedicated native command without changing normal `--new` or `--toggle` behavior.
|
- Quick Pane opens/focuses through dedicated native command without changing normal `--new` or `--toggle` behavior.
|
||||||
- Workspace and quick-model picker values reach runtime creation.
|
- Workspace and quick-model picker values reach runtime creation.
|
||||||
- Permission and clarification extension requests render inline and send correct response payloads.
|
- Permission and clarification extension requests remain visible inline and send correct response payloads; runtime errors do not erase partial transcript or pending extensions.
|
||||||
- Retry, model change, and normal-session actions appear for startup/model/crash errors.
|
- `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.
|
||||||
- Handoff creates normal session with original request, escalation note, workspace, and follow-up model.
|
- `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
|
## Manual smoke checklist
|
||||||
|
|
||||||
1. Configure default workspace, models, prompt, and 60-second/6-tool limits.
|
1. Configure default workspace, models, prompt, and tool-call guidance.
|
||||||
2. Add desktop/WM hotkey using Settings launch command.
|
2. Add desktop/WM hotkey using Settings launch command.
|
||||||
3. From no running GUI process, invoke hotkey and verify Quick Pane opens/focuses.
|
3. From no running GUI process, invoke hotkey and verify Quick Pane opens/focuses.
|
||||||
4. Submit short answer-only request; verify result within 60 seconds.
|
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.
|
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**.
|
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.
|
7. Continue; verify normal session receives handoff and follow-up model.
|
||||||
8. Close Quick Pane; reopen and verify quick transcript is absent.
|
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. Select unavailable model or induce startup failure; verify inline recovery options.
|
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
|
## Exit criteria
|
||||||
|
|
||||||
|
|||||||
+289
-16
@@ -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";
|
||||||
@@ -74,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 "";
|
||||||
@@ -245,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,
|
||||||
@@ -257,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();
|
||||||
@@ -272,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,
|
||||||
@@ -317,9 +389,15 @@ 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) {
|
||||||
@@ -429,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 ||
|
||||||
@@ -476,15 +567,17 @@ export function createAgentRegistry({
|
|||||||
runtime.sessionName = state.sessionName;
|
runtime.sessionName = state.sessionName;
|
||||||
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
|
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
|
||||||
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,
|
||||||
@@ -492,7 +585,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) {
|
||||||
@@ -586,6 +688,8 @@ export function createAgentRegistry({
|
|||||||
adapter: undefined,
|
adapter: undefined,
|
||||||
supervisor: undefined,
|
supervisor: undefined,
|
||||||
stopped: false,
|
stopped: false,
|
||||||
|
promoting: false,
|
||||||
|
promotionPromise: undefined,
|
||||||
recoveryPromise: undefined,
|
recoveryPromise: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -986,7 +1090,9 @@ export function createAgentRegistry({
|
|||||||
}
|
}
|
||||||
if (stopConfirmed) releaseClosedSessionLease(runtime);
|
if (stopConfirmed) releaseClosedSessionLease(runtime);
|
||||||
if (runtime.ephemeral && stopConfirmed)
|
if (runtime.ephemeral && stopConfirmed)
|
||||||
await rm(runtime.sessionDir, { recursive: true, force: true }).catch(() => {});
|
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,
|
||||||
@@ -997,6 +1103,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 =
|
||||||
@@ -1015,6 +1123,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,
|
||||||
@@ -1085,6 +1323,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 = [];
|
||||||
@@ -1160,6 +1399,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) {
|
||||||
@@ -1196,24 +1444,47 @@ export function createAgentRegistry({
|
|||||||
async createQuickRuntime(worktreePath) {
|
async createQuickRuntime(worktreePath) {
|
||||||
return openRuntime({ worktreePath, ephemeral: true });
|
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) {
|
async closeQuickRuntime(runtimeId) {
|
||||||
const runtime = getRuntime(runtimeId);
|
const runtime = getRuntime(runtimeId);
|
||||||
if (!runtime.ephemeral) throw new Error("runtime is not a quick runtime");
|
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);
|
return closeRuntime(runtimeId);
|
||||||
},
|
},
|
||||||
async getModelCatalog() {
|
async getModelCatalog() {
|
||||||
const runtime = [...runtimesByAgentId.values()].find(
|
const candidates = [...runtimesByAgentId.values()].filter(
|
||||||
(candidate) => candidate.adapter,
|
(runtime) =>
|
||||||
|
runtime.adapter &&
|
||||||
|
!runtime.stopped &&
|
||||||
|
!["closing", "stopped", "failed"].includes(runtime.state),
|
||||||
);
|
);
|
||||||
if (!runtime?.adapter) return [];
|
let lastError;
|
||||||
|
for (const runtime of candidates) {
|
||||||
|
const adapter = runtime.adapter;
|
||||||
|
try {
|
||||||
const response = await enqueue(runtime, () =>
|
const response = await enqueue(runtime, () =>
|
||||||
runtime.adapter.send({ type: "get_available_models" }),
|
adapter.send({ type: "get_available_models" }),
|
||||||
);
|
);
|
||||||
return response?.data?.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()]
|
||||||
@@ -1293,7 +1564,9 @@ 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,
|
||||||
@@ -1449,7 +1722,8 @@ export function createAgentRegistry({
|
|||||||
},
|
},
|
||||||
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")
|
||||||
@@ -1525,7 +1799,7 @@ export function createAgentRegistry({
|
|||||||
async stop() {
|
async stop() {
|
||||||
stopping = true;
|
stopping = true;
|
||||||
while (pendingOpenOperations.size > 0)
|
while (pendingOpenOperations.size > 0)
|
||||||
await Promise.allSettled([...pendingOpenOperations]);
|
await Promise.allSettled(pendingOpenOperations);
|
||||||
const runtimes = [...runtimesByRuntimeId.values()];
|
const runtimes = [...runtimesByRuntimeId.values()];
|
||||||
await Promise.all(runtimes.map((runtime) => runtime.operationQueue));
|
await Promise.all(runtimes.map((runtime) => runtime.operationQueue));
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -1541,5 +1815,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;
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export async function startBridgeService({
|
|||||||
request.payload.worktreePath,
|
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(
|
||||||
|
|||||||
@@ -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 = () => {};
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const requestOperations = new Map([
|
|||||||
["get_model_catalog", { 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" }],
|
["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" }],
|
["close_quick_runtime", { agent: false, payload: "runtime" }],
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
@@ -987,7 +987,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");
|
||||||
@@ -1003,14 +1003,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1198,7 +1202,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({
|
||||||
@@ -1216,8 +1220,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, [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ test("accepts additive multi-session runtime operations and numeric workspace cu
|
|||||||
{ 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: "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: "close_quick_runtime", payload: { runtimeId: "runtime-1" } },
|
||||||
|
|||||||
+365
-12
@@ -2,46 +2,209 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
test("Quick Pane polls snapshots, renders answers, and gates handoff", async () => {
|
test("Quick Pane polls snapshots, renders answers, and promotes runtime without a handoff", async () => {
|
||||||
const source = await readFile(
|
const source = await readFile(
|
||||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
assert.match(source, /get_session_runtime_snapshot/);
|
assert.match(source, /get_session_runtime_snapshot/);
|
||||||
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
|
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(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/<pre style=\{\{ whiteSpace: "pre-wrap" \}\}>\{answer\}<\/pre>/,
|
/await invoke<\{ runtime: Runtime \}>\("promote_quick_runtime", \{\s+runtimeId: current\.runtimeId,/,
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
source,
|
|
||||||
/\{\(escalation \|\| error\) && \(\s*<button onClick=\{continueFull\}>Continue in full session<\/button>\s*\)\}/,
|
|
||||||
);
|
);
|
||||||
|
assert.doesNotMatch(source, /Quick Pane transcript:/);
|
||||||
|
assert.doesNotMatch(source, /create_session_runtime/);
|
||||||
|
assert.doesNotMatch(source, /maximumSeconds|Quick time budget/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("main Settings owns Quick Pane configuration while pane keeps request controls", async () => {
|
test("main Settings uses shared catalog selectors with provider derived from models", async () => {
|
||||||
const [pane, app, persistence] = await Promise.all([
|
const [pane, app, catalog, persistence] = await Promise.all([
|
||||||
readFile(
|
readFile(
|
||||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
readFile(new URL("../ui/src/App.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(
|
readFile(
|
||||||
new URL("../ui/src/workspace/persistence.ts", import.meta.url),
|
new URL("../ui/src/workspace/persistence.ts", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
assert.match(app, /load_ui_state/);
|
assert.doesNotMatch(catalog, /get_model_catalog/);
|
||||||
assert.match(app, /save_ui_state/);
|
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(app, /Save Quick Pane settings/);
|
||||||
assert.match(pane, /get_model_catalog/);
|
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, /set_thinking_level/);
|
||||||
|
assert.match(pane, /Loading models…/);
|
||||||
|
assert.match(pane, /catalogStatus !== "ready"/);
|
||||||
assert.doesNotMatch(pane, /showSettings/);
|
assert.doesNotMatch(pane, /showSettings/);
|
||||||
assert.doesNotMatch(pane, /Save Quick Pane settings/);
|
assert.doesNotMatch(pane, /Save Quick Pane settings/);
|
||||||
assert.doesNotMatch(pane, /setRequest\(""\)/);
|
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/);
|
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 () => {
|
test("Quick Pane routes extension responses through existing bridge command", async () => {
|
||||||
const source = await readFile(
|
const source = await readFile(
|
||||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||||
@@ -54,3 +217,193 @@ test("Quick Pane routes extension responses through existing bridge command", as
|
|||||||
assert.match(source, /invoke\("respond_to_extension"/);
|
assert.match(source, /invoke\("respond_to_extension"/);
|
||||||
assert.match(source, /requestId: extension\.id/);
|
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();
|
||||||
|
});
|
||||||
@@ -73,8 +73,9 @@ test("preserves native session naming, compaction, composer, and extension respo
|
|||||||
assert.match(app, /respond_to_extension/);
|
assert.match(app, /respond_to_extension/);
|
||||||
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
|
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
|
||||||
assert.match(conversation, /className="todos-pane"/);
|
assert.match(conversation, /className="todos-pane"/);
|
||||||
assert.match(extension, /role="dialog"/);
|
assert.match(extension, /<dialog/);
|
||||||
assert.match(extension, /aria-modal="true"/);
|
assert.match(extension, /showModal\(\)/);
|
||||||
|
assert.match(extension, /onCancel=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("configures native window behavior and folder dialog capabilities", async () => {
|
test("configures native window behavior and folder dialog capabilities", async () => {
|
||||||
|
|||||||
+1
-2
@@ -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 |
+16
-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};
|
||||||
@@ -228,6 +227,17 @@ pub async fn create_quick_runtime(
|
|||||||
)
|
)
|
||||||
.await
|
.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,
|
||||||
@@ -335,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",
|
||||||
@@ -885,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,
|
||||||
|
|||||||
+369
-41
@@ -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<()>>>,
|
||||||
@@ -49,16 +58,143 @@ fn requested_quick_pane(args: &[String]) -> bool {
|
|||||||
args.iter().any(|arg| arg == "--quick")
|
args.iter().any(|arg| arg == "--quick")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_quick_pane(app: &AppHandle) {
|
fn quick_pane_action(is_visible: bool) -> WindowAction {
|
||||||
if let Some(main) = app.get_webview_window("main") {
|
if is_visible {
|
||||||
let _ = main.hide();
|
WindowAction::Hide
|
||||||
|
} else {
|
||||||
|
WindowAction::ShowAndFocus
|
||||||
}
|
}
|
||||||
if let Some(window) = app.get_webview_window("quick-pane") {
|
}
|
||||||
let _ = window.show();
|
|
||||||
|
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.unminimize();
|
||||||
let _ = window.set_focus();
|
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()
|
||||||
@@ -84,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
|
||||||
})); }
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -127,6 +285,39 @@ async fn get_model_catalog() -> Result<Value, String> {
|
|||||||
bridge::get_model_catalog(&socket_path()?).await
|
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
|
||||||
@@ -137,6 +328,48 @@ async fn create_quick_runtime(worktree_path: String) -> Result<bridge::RuntimeRe
|
|||||||
bridge::create_quick_runtime(&socket_path()?, &worktree_path).await
|
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,
|
||||||
@@ -207,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
|
||||||
@@ -274,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]
|
||||||
@@ -382,31 +611,30 @@ 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) {
|
if requested_quick_pane(&args) {
|
||||||
show_quick_pane(app);
|
toggle_quick_pane(app);
|
||||||
return;
|
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) {
|
if requested_quick_pane(&launch_args) {
|
||||||
show_quick_pane(app.handle());
|
toggle_quick_pane(app.handle());
|
||||||
} else {
|
} else {
|
||||||
launch_new_session(app.handle().clone(), &launch_args);
|
launch_new_session(app.handle().clone(), &launch_args);
|
||||||
}
|
}
|
||||||
@@ -416,8 +644,13 @@ pub fn run() {
|
|||||||
get_workspace,
|
get_workspace,
|
||||||
get_workspace_summary,
|
get_workspace_summary,
|
||||||
get_model_catalog,
|
get_model_catalog,
|
||||||
|
resize_quick_pane,
|
||||||
|
dismiss_quick_pane,
|
||||||
create_session_runtime,
|
create_session_runtime,
|
||||||
create_quick_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,
|
close_quick_runtime,
|
||||||
@@ -431,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,
|
||||||
@@ -475,16 +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]
|
#[test]
|
||||||
fn quick_flag_is_detected_without_affecting_toggle() {
|
fn quick_flag_is_detected_and_toggles_its_own_window() {
|
||||||
assert!(requested_quick_pane(&["--quick".to_owned()]));
|
assert!(requested_quick_pane(&["--quick".to_owned()]));
|
||||||
assert!(!requested_quick_pane(&["--toggle".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)]
|
||||||
@@ -46,8 +47,13 @@ pub struct QuickPaneSettings {
|
|||||||
pub follow_up_provider: String,
|
pub follow_up_provider: String,
|
||||||
pub follow_up_model: String,
|
pub follow_up_model: String,
|
||||||
pub instructions: String,
|
pub instructions: String,
|
||||||
pub maximum_seconds: u32,
|
|
||||||
pub maximum_tool_calls: u32,
|
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 {
|
impl Default for QuickPaneSettings {
|
||||||
@@ -58,9 +64,9 @@ impl Default for QuickPaneSettings {
|
|||||||
model: String::new(),
|
model: String::new(),
|
||||||
follow_up_provider: String::new(),
|
follow_up_provider: String::new(),
|
||||||
follow_up_model: String::new(),
|
follow_up_model: String::new(),
|
||||||
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.".to_owned(),
|
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or complex work.".to_owned(),
|
||||||
maximum_seconds: 60,
|
|
||||||
maximum_tool_calls: 6,
|
maximum_tool_calls: 6,
|
||||||
|
default_thinking_level: default_thinking_level(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,9 +238,14 @@ pub fn validate(state: &UiStateV1) -> Result<(), String> {
|
|||||||
if quick.instructions.is_empty() || quick.instructions.len() > 4096 {
|
if quick.instructions.is_empty() || quick.instructions.len() > 4096 {
|
||||||
return Err("Quick-pane instructions must contain 1 to 4096 bytes".to_owned());
|
return Err("Quick-pane instructions must contain 1 to 4096 bytes".to_owned());
|
||||||
}
|
}
|
||||||
if !(1..=600).contains(&quick.maximum_seconds) || !(1..=64).contains(&quick.maximum_tool_calls)
|
if !(1..=64).contains(&quick.maximum_tool_calls) {
|
||||||
{
|
return Err("Quick-pane tool-call limit is outside the supported range".to_owned());
|
||||||
return Err("Quick-pane limits are 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
|
total_string_bytes = total_string_bytes
|
||||||
.saturating_add(quick.workspace.len())
|
.saturating_add(quick.workspace.len())
|
||||||
@@ -281,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)
|
||||||
@@ -443,8 +463,33 @@ mod tests {
|
|||||||
state.quick_pane.follow_up_provider = "provider".into();
|
state.quick_pane.follow_up_provider = "provider".into();
|
||||||
state.quick_pane.follow_up_model = "follow-up-model".into();
|
state.quick_pane.follow_up_model = "follow-up-model".into();
|
||||||
assert!(validate(&state).is_ok());
|
assert!(validate(&state).is_ok());
|
||||||
state.quick_pane.maximum_seconds = 0;
|
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());
|
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();
|
fs::remove_dir_all(root).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,13 +28,14 @@
|
|||||||
{
|
{
|
||||||
"label": "quick-pane",
|
"label": "quick-pane",
|
||||||
"title": "Pi Quick Pane",
|
"title": "Pi Quick Pane",
|
||||||
"width": 520,
|
"width": 640,
|
||||||
"height": 360,
|
"height": 360,
|
||||||
"minWidth": 400,
|
"minWidth": 520,
|
||||||
"minHeight": 260,
|
"minHeight": 360,
|
||||||
"center": true,
|
"center": true,
|
||||||
"visible": false,
|
"visible": false,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
|
"transparent": true,
|
||||||
"alwaysOnTop": true,
|
"alwaysOnTop": true,
|
||||||
"skipTaskbar": true,
|
"skipTaskbar": true,
|
||||||
"shadow": true
|
"shadow": true
|
||||||
|
|||||||
+21
-144
@@ -717,140 +717,6 @@ select:focus-visible {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: #303030;
|
background: #303030;
|
||||||
}
|
}
|
||||||
.pi-controls label,
|
|
||||||
.model-control {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.pi-controls select,
|
|
||||||
.model-trigger {
|
|
||||||
min-width: 0;
|
|
||||||
appearance: none;
|
|
||||||
color-scheme: dark;
|
|
||||||
padding: 5px 25px 5px 6px;
|
|
||||||
border: 1px solid #70522f;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #2c2a27
|
|
||||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
|
|
||||||
no-repeat right 7px center;
|
|
||||||
color: #f2ece4;
|
|
||||||
}
|
|
||||||
.pi-controls select:hover,
|
|
||||||
.model-trigger:hover {
|
|
||||||
border-color: #d88735;
|
|
||||||
background-color: #383129;
|
|
||||||
}
|
|
||||||
.pi-controls select:focus,
|
|
||||||
.model-trigger: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;
|
|
||||||
}
|
|
||||||
.model-selector {
|
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.model-trigger {
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
.model-trigger > span {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.model-menu {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 6px);
|
|
||||||
left: 0;
|
|
||||||
z-index: 30;
|
|
||||||
display: grid;
|
|
||||||
width: min(400px, calc(100vw - 40px));
|
|
||||||
max-height: min(420px, calc(100vh - 80px));
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #70522f;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: #292724;
|
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
.model-menu input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 7px 9px;
|
|
||||||
border: 1px solid #5b5146;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #1f1e1c;
|
|
||||||
color: #f2ece4;
|
|
||||||
}
|
|
||||||
.model-result-count {
|
|
||||||
margin: 0;
|
|
||||||
color: #aaa39a;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.model-results {
|
|
||||||
display: grid;
|
|
||||||
gap: 7px;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.model-provider-group {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.model-provider-heading {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
padding: 4px 5px 3px;
|
|
||||||
background: #292724;
|
|
||||||
color: #f0a347;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 850;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.model-option {
|
|
||||||
display: grid;
|
|
||||||
gap: 1px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px 7px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background: #35322e;
|
|
||||||
color: #f2ece4;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.model-option:hover {
|
|
||||||
border-color: #8c6338;
|
|
||||||
background: #44382d;
|
|
||||||
}
|
|
||||||
.model-option[aria-selected="true"] {
|
|
||||||
border-color: #f0a347;
|
|
||||||
background: #4b3420;
|
|
||||||
color: #fff2df;
|
|
||||||
}
|
|
||||||
.model-option small {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
color: #aaa39a;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.model-empty {
|
|
||||||
margin: 5px 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.session-status-strip {
|
.session-status-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1055,13 +921,25 @@ select:focus-visible {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
.quick-pane-settings label {
|
.quick-pane-settings label,
|
||||||
|
.quick-pane-model-setting {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
color: #ddd8d0;
|
color: #ddd8d0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
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 input,
|
||||||
.quick-pane-settings textarea {
|
.quick-pane-settings textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1129,17 +1007,14 @@ select:focus-visible {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
transform: scale(1.35);
|
transform: scale(1.35);
|
||||||
}
|
}
|
||||||
.modal-backdrop,
|
dialog.session-picker::backdrop,
|
||||||
.extension-backdrop {
|
dialog.extension::backdrop {
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
background: rgba(0, 0, 0, 0.62);
|
background: rgba(0, 0, 0, 0.62);
|
||||||
}
|
}
|
||||||
|
dialog.session-picker:not([open]),
|
||||||
|
dialog.extension:not([open]) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.session-picker,
|
.session-picker,
|
||||||
.extension {
|
.extension {
|
||||||
width: min(680px, 90vw);
|
width: min(680px, 90vw);
|
||||||
@@ -1147,10 +1022,12 @@ select:focus-visible {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin: auto;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border: 1px solid #b76f26;
|
border: 1px solid #b76f26;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #2d2e31;
|
background: #2d2e31;
|
||||||
|
color: #f1f3f4;
|
||||||
}
|
}
|
||||||
.session-picker {
|
.session-picker {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
+813
-10
@@ -1,22 +1,36 @@
|
|||||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { RuntimeSummary, UiState, Workspace } from "./workspace/types";
|
import type {
|
||||||
|
Model,
|
||||||
|
RuntimeSummary,
|
||||||
|
UiState,
|
||||||
|
Workspace,
|
||||||
|
} from "./workspace/types";
|
||||||
import {
|
import {
|
||||||
initialWorkspaceState,
|
initialWorkspaceState,
|
||||||
reconcileWorkspace,
|
reconcileWorkspace,
|
||||||
workspaceReducer,
|
workspaceReducer,
|
||||||
} from "./workspace/reducer";
|
} from "./workspace/reducer";
|
||||||
|
import { modelIdentityFromValue, modelIdentityValue } from "./model-catalog";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
loadSnapshot: vi.fn(),
|
loadSnapshot: vi.fn(),
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
dispatch: vi.fn(),
|
dispatch: vi.fn(),
|
||||||
|
emitTo: vi.fn(),
|
||||||
useWorkspace: vi.fn(),
|
useWorkspace: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({ emitTo: mocks.emitTo }));
|
||||||
vi.mock("@tauri-apps/api/window", () => ({
|
vi.mock("@tauri-apps/api/window", () => ({
|
||||||
getCurrentWindow: () => ({ startDragging: vi.fn() }),
|
getCurrentWindow: () => ({ startDragging: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
@@ -87,10 +101,23 @@ function deferred<T>() {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("model catalog identity", () => {
|
||||||
|
it("preserves model IDs containing slashes", () => {
|
||||||
|
const identity = {
|
||||||
|
provider: "openrouter",
|
||||||
|
modelId: "anthropic/claude/sonnet",
|
||||||
|
};
|
||||||
|
expect(modelIdentityFromValue(modelIdentityValue(identity))).toEqual(
|
||||||
|
identity,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("App command progress and errors", () => {
|
describe("App command progress and errors", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.invoke.mockResolvedValue(uiState);
|
mocks.invoke.mockResolvedValue(uiState);
|
||||||
|
mocks.emitTo.mockResolvedValue(undefined);
|
||||||
mocks.refresh.mockResolvedValue(undefined);
|
mocks.refresh.mockResolvedValue(undefined);
|
||||||
mocks.loadSnapshot.mockResolvedValue(true);
|
mocks.loadSnapshot.mockResolvedValue(true);
|
||||||
mocks.useWorkspace.mockReturnValue({
|
mocks.useWorkspace.mockReturnValue({
|
||||||
@@ -108,13 +135,792 @@ describe("App command progress and errors", () => {
|
|||||||
expect(status).toHaveTextContent("Ready for your next prompt");
|
expect(status).toHaveTextContent("Ready for your next prompt");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts a session in selected directory with Ctrl+N", async () => {
|
||||||
|
mocks.invoke.mockImplementation((command: string) => {
|
||||||
|
if (command === "create_session_runtime")
|
||||||
|
return Promise.resolve({ runtime: { runtimeId: "runtime-new" } });
|
||||||
|
return Promise.resolve(uiState);
|
||||||
|
});
|
||||||
|
render(<App />);
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
ctrlKey: true,
|
||||||
|
key: "n",
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("create_session_runtime", {
|
||||||
|
worktreePath: "/repo",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads categorized snapshot selectors and derives providers from choices", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded: UiState = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-5",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "gpt-5",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mocks.invoke.mockImplementation((command: string) => {
|
||||||
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||||
|
if (command === "create_quick_runtime")
|
||||||
|
return Promise.resolve({ runtime: { runtimeId: "settings-runtime" } });
|
||||||
|
if (command === "get_session_runtime_snapshot")
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: {
|
||||||
|
models: [
|
||||||
|
{ provider: "openai", id: "gpt-5", name: "GPT 5" },
|
||||||
|
{
|
||||||
|
provider: "openrouter",
|
||||||
|
id: "anthropic/claude/sonnet",
|
||||||
|
name: "Claude",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
});
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||||
|
runtimeId: "settings-runtime",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.invoke).not.toHaveBeenCalledWith("get_model_catalog");
|
||||||
|
|
||||||
|
const quickModel = screen.getByRole("button", {
|
||||||
|
name: /Quick model openai · GPT 5/i,
|
||||||
|
});
|
||||||
|
await user.click(quickModel);
|
||||||
|
expect(screen.getByRole("group", { name: "openrouter" })).toBeInTheDocument();
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("option", { name: /Claude.*anthropic\/claude\/sonnet/i }),
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByRole("combobox", { name: "Default thinking" }), {
|
||||||
|
target: { value: "high" },
|
||||||
|
});
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("save_ui_state", {
|
||||||
|
state: {
|
||||||
|
...loaded,
|
||||||
|
quickPane: {
|
||||||
|
...loaded.quickPane,
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "anthropic/claude/sonnet",
|
||||||
|
defaultThinkingLevel: "high",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses and always closes a fresh temporary runtime whenever Settings opens", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let runtimeCount = 0;
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: { runtimeId?: string }) => {
|
||||||
|
if (command === "load_ui_state")
|
||||||
|
return Promise.resolve({
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "old-model",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "old-model",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (command === "create_quick_runtime") {
|
||||||
|
runtimeCount += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: { runtimeId: `settings-${runtimeCount}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "get_session_runtime_snapshot") {
|
||||||
|
const id = payload?.runtimeId === "settings-1" ? "old-model" : "new-model";
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: {
|
||||||
|
models: [{ provider: "openai", id }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: /Quick model openai · old-model/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||||
|
runtimeId: "settings-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: /Quick model openai · new-model/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||||
|
runtimeId: "settings-2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores older overlapping Settings snapshot while closing both runtimes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "old-model",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "old-model",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const first = deferred<{
|
||||||
|
runtime: Record<string, never>;
|
||||||
|
extensions: never[];
|
||||||
|
models: { data: { models: { provider: string; id: string }[] } };
|
||||||
|
}>();
|
||||||
|
let runtimeCount = 0;
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: { runtimeId?: string }) => {
|
||||||
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||||
|
if (command === "create_quick_runtime") {
|
||||||
|
runtimeCount += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: { runtimeId: `settings-${runtimeCount}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "get_session_runtime_snapshot") {
|
||||||
|
if (payload?.runtimeId === "settings-1") return first.promise;
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: {
|
||||||
|
models: [{ provider: "openai", id: "new-model" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
await waitFor(() => expect(runtimeCount).toBe(1));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: /Quick model openai · new-model/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await act(async () =>
|
||||||
|
first.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: {
|
||||||
|
models: [{ provider: "openai", id: "stale-model" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: /stale-model/i })).toBeNull();
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||||
|
runtimeId: "settings-1",
|
||||||
|
});
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||||
|
runtimeId: "settings-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains a stale-generation probe when its close fails", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "old-model",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "old-model",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const staleSnapshot = deferred<{
|
||||||
|
runtime: Record<string, never>;
|
||||||
|
extensions: never[];
|
||||||
|
models: { data: { models: { provider: string; id: string }[] } };
|
||||||
|
}>();
|
||||||
|
let runtimeCount = 0;
|
||||||
|
let staleCloseCount = 0;
|
||||||
|
const lifecycle: string[] = [];
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: { runtimeId?: string }) => {
|
||||||
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||||
|
if (command === "create_quick_runtime") {
|
||||||
|
runtimeCount += 1;
|
||||||
|
lifecycle.push(`create:settings-${runtimeCount}`);
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: { runtimeId: `settings-${runtimeCount}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "get_session_runtime_snapshot") {
|
||||||
|
if (payload?.runtimeId === "settings-1") return staleSnapshot.promise;
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: { models: [{ provider: "openai", id: "new-model" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "close_quick_runtime") {
|
||||||
|
if (payload?.runtimeId === "settings-1") {
|
||||||
|
staleCloseCount += 1;
|
||||||
|
lifecycle.push(`close:settings-1:${staleCloseCount}`);
|
||||||
|
if (staleCloseCount === 1)
|
||||||
|
return Promise.reject(new Error("stale close failed"));
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
await waitFor(() => expect(runtimeCount).toBe(1));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: /Quick model openai · new-model/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await act(async () =>
|
||||||
|
staleSnapshot.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: {
|
||||||
|
models: [{ provider: "openai", id: "stale-model" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
"Could not load model catalog: Error: stale close failed",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /stale-model/i })).toBeNull();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
await waitFor(() => expect(runtimeCount).toBe(3));
|
||||||
|
expect(lifecycle.indexOf("close:settings-1:2")).toBeLessThan(
|
||||||
|
lifecycle.indexOf("create:settings-3"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("best-effort closes an in-flight Settings probe on unmount", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "model",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "model",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const snapshot = deferred<unknown>();
|
||||||
|
mocks.invoke.mockImplementation((command: string) => {
|
||||||
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||||
|
if (command === "create_quick_runtime")
|
||||||
|
return Promise.resolve({ runtime: { runtimeId: "in-flight-probe" } });
|
||||||
|
if (command === "get_session_runtime_snapshot") return snapshot.promise;
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
});
|
||||||
|
const app = render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("get_session_runtime_snapshot", {
|
||||||
|
runtimeId: "in-flight-probe",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.unmount();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||||
|
runtimeId: "in-flight-probe",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes temporary runtime and blocks save for snapshot failure or empty catalog", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "old-model",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "old-model",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let openCount = 0;
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: { runtimeId?: string }) => {
|
||||||
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||||
|
if (command === "create_quick_runtime") {
|
||||||
|
openCount += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: { runtimeId: `settings-${openCount}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "get_session_runtime_snapshot") {
|
||||||
|
if (payload?.runtimeId === "settings-1")
|
||||||
|
return Promise.reject(new Error("offline"));
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: { data: { models: [] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Could not load model catalog: Error: offline"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||||
|
runtimeId: "settings-1",
|
||||||
|
});
|
||||||
|
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||||
|
runtimeId: "settings-2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates selections and refreshes catalog for an edited workspace", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "old-model",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "old-model",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let runtimeCount = 0;
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(
|
||||||
|
command: string,
|
||||||
|
payload?: { runtimeId?: string; worktreePath?: string },
|
||||||
|
) => {
|
||||||
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||||
|
if (command === "create_quick_runtime") {
|
||||||
|
runtimeCount += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "get_session_runtime_snapshot") {
|
||||||
|
const id = payload?.runtimeId === "probe-1" ? "old-model" : "new-model";
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: {
|
||||||
|
models: [{ provider: "openai", id }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: /Quick model openai · old-model/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||||
|
).toBeEnabled(),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Workspace"), {
|
||||||
|
target: { value: "/other" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(screen.queryByRole("button", { name: /old-model/i })).toBeNull();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("create_quick_runtime", {
|
||||||
|
worktreePath: "/other",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: /Quick model openai · new-model/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reconcile current settings from an out-of-order old-workspace response", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "old-model",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "old-model",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const oldSnapshot = deferred<{
|
||||||
|
runtime: Record<string, never>;
|
||||||
|
extensions: never[];
|
||||||
|
models: { data: { models: Model[] } };
|
||||||
|
}>();
|
||||||
|
let runtimeCount = 0;
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: { runtimeId?: string }) => {
|
||||||
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||||
|
if (command === "create_quick_runtime") {
|
||||||
|
runtimeCount += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "get_session_runtime_snapshot") {
|
||||||
|
if (payload?.runtimeId === "probe-1") return oldSnapshot.promise;
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: {
|
||||||
|
models: [{ provider: "new-provider", id: "new-model" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
await waitFor(() => expect(runtimeCount).toBe(1));
|
||||||
|
fireEvent.change(screen.getByLabelText("Workspace"), {
|
||||||
|
target: { value: "/other" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", {
|
||||||
|
name: /Quick model new-provider · new-model/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await act(async () =>
|
||||||
|
oldSnapshot.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: {
|
||||||
|
models: [{ provider: "old-provider", id: "stale-model" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: /stale-model/i })).toBeNull();
|
||||||
|
expect(screen.getByLabelText("Workspace")).toHaveValue("/other");
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||||
|
runtimeId: "probe-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a retained probe close before creating the next Settings probe", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "model",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "model",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let runtimeCount = 0;
|
||||||
|
let firstProbeCloseCount = 0;
|
||||||
|
const lifecycle: string[] = [];
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: { runtimeId?: string }) => {
|
||||||
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||||
|
if (command === "create_quick_runtime") {
|
||||||
|
runtimeCount += 1;
|
||||||
|
lifecycle.push(`create:probe-${runtimeCount}`);
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "get_session_runtime_snapshot")
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: { models: [{ provider: "openai", id: "model" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (command === "close_quick_runtime") {
|
||||||
|
if (payload?.runtimeId === "probe-1") {
|
||||||
|
firstProbeCloseCount += 1;
|
||||||
|
lifecycle.push(`close:probe-1:${firstProbeCloseCount}`);
|
||||||
|
if (firstProbeCloseCount === 1)
|
||||||
|
return Promise.reject(new Error("transient close failure"));
|
||||||
|
} else {
|
||||||
|
lifecycle.push(`close:${payload?.runtimeId}`);
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
"Could not load model catalog: Error: transient close failure",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(runtimeCount).toBe(1);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
await waitFor(() => expect(runtimeCount).toBe(2));
|
||||||
|
await waitFor(() => expect(lifecycle).toContain("close:probe-2"));
|
||||||
|
expect(lifecycle.indexOf("close:probe-1:2")).toBeLessThan(
|
||||||
|
lifecycle.indexOf("create:probe-2"),
|
||||||
|
);
|
||||||
|
expect(firstProbeCloseCount).toBe(2);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||||
|
).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks new probes and save while retained probe cleanup keeps failing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "model",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "model",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let createCount = 0;
|
||||||
|
let closeCount = 0;
|
||||||
|
mocks.invoke.mockImplementation((command: string) => {
|
||||||
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||||
|
if (command === "create_quick_runtime") {
|
||||||
|
createCount += 1;
|
||||||
|
return Promise.resolve({ runtime: { runtimeId: "probe" } });
|
||||||
|
}
|
||||||
|
if (command === "get_session_runtime_snapshot")
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: { data: { models: [{ provider: "openai", id: "model" }] } },
|
||||||
|
});
|
||||||
|
if (command === "close_quick_runtime") {
|
||||||
|
closeCount += 1;
|
||||||
|
return Promise.reject(new Error("close failed"));
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
});
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Could not load model catalog: Error: close failed"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(createCount).toBe(1);
|
||||||
|
expect(closeCount).toBe(1);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
await waitFor(() => expect(closeCount).toBe(2));
|
||||||
|
expect(createCount).toBe(1);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||||
|
"save_ui_state",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves only when ready catalog workspace exactly matches settings workspace", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "model",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "model",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const nextSnapshot = deferred<unknown>();
|
||||||
|
let runtimeCount = 0;
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: { runtimeId?: string }) => {
|
||||||
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||||
|
if (command === "create_quick_runtime") {
|
||||||
|
runtimeCount += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (command === "get_session_runtime_snapshot") {
|
||||||
|
if (payload?.runtimeId === "probe-2") return nextSnapshot.promise;
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: {
|
||||||
|
data: {
|
||||||
|
models: [{ provider: "openai", id: "model" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(<App />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||||
|
).toBeEnabled(),
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText("Workspace"), {
|
||||||
|
target: { value: "/other" },
|
||||||
|
});
|
||||||
|
const save = screen.getByRole("button", { name: "Save Quick Pane settings" });
|
||||||
|
expect(save).toBeDisabled();
|
||||||
|
fireEvent.click(save);
|
||||||
|
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||||
|
"save_ui_state",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows restart progress until the replacement snapshot is restored", async () => {
|
it("shows restart progress until the replacement snapshot is restored", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const restart = deferred<unknown>();
|
const restart = deferred<unknown>();
|
||||||
const snapshot = deferred<boolean>();
|
const snapshot = deferred<boolean>();
|
||||||
mocks.invoke.mockImplementation((command: string) =>
|
mocks.invoke.mockImplementation((command: string) => {
|
||||||
command === "restart" ? restart.promise : Promise.resolve(undefined),
|
if (command === "restart") return restart.promise;
|
||||||
);
|
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||||
|
if (command === "create_quick_runtime")
|
||||||
|
return Promise.resolve({ runtime: { runtimeId: "settings-runtime" } });
|
||||||
|
if (command === "get_session_runtime_snapshot")
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
extensions: [],
|
||||||
|
models: { data: { models: [] } },
|
||||||
|
});
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
});
|
||||||
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
|
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
@@ -127,11 +933,8 @@ describe("App command progress and errors", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
|
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
|
||||||
await user.click(restarting);
|
await user.click(restarting);
|
||||||
expect(mocks.invoke).toHaveBeenCalledTimes(2);
|
expect(mocks.invoke).toHaveBeenCalledWith("restart", { agentId: "agent-a" });
|
||||||
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "load_ui_state");
|
expect(mocks.invoke).not.toHaveBeenCalledWith("get_model_catalog");
|
||||||
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "restart", {
|
|
||||||
agentId: "agent-a",
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => restart.resolve(undefined));
|
await act(async () => restart.resolve(undefined));
|
||||||
expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled();
|
||||||
|
|||||||
+327
-76
@@ -5,17 +5,25 @@ import { open } from "@tauri-apps/plugin-dialog";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { ConversationWorkspace } from "./components/ConversationWorkspace";
|
import { ConversationWorkspace } from "./components/ConversationWorkspace";
|
||||||
|
import { ModelSelector } from "./components/ModelSelector";
|
||||||
|
import { ThinkingSelector } from "./components/ThinkingSelector";
|
||||||
import { DirectorySidebar } from "./components/DirectorySidebar";
|
import { DirectorySidebar } from "./components/DirectorySidebar";
|
||||||
import { ExtensionDialog } from "./components/ExtensionDialog";
|
import { ExtensionDialog } from "./components/ExtensionDialog";
|
||||||
import { CommandFormDialog } from "./components/CommandFormDialog";
|
import { CommandFormDialog } from "./components/CommandFormDialog";
|
||||||
import type { RpcCommand } from "./commands/rpc";
|
import type { RpcCommand } from "./commands/rpc";
|
||||||
import { SessionPicker } from "./components/SessionPicker";
|
import { SessionPicker } from "./components/SessionPicker";
|
||||||
import { SessionTabs } from "./components/SessionTabs";
|
import { SessionTabs } from "./components/SessionTabs";
|
||||||
|
import {
|
||||||
|
modelIdentity,
|
||||||
|
reconcileModelIdentity,
|
||||||
|
reconcileThinkingLevel,
|
||||||
|
} from "./model-catalog";
|
||||||
import type {
|
import type {
|
||||||
DirectorySession,
|
DirectorySession,
|
||||||
DirectoryWorkspace,
|
DirectoryWorkspace,
|
||||||
Model,
|
Model,
|
||||||
QuickPaneSettings,
|
QuickPaneSettings,
|
||||||
|
RuntimeSnapshot,
|
||||||
RuntimeView,
|
RuntimeView,
|
||||||
UiState,
|
UiState,
|
||||||
} from "./workspace/types";
|
} from "./workspace/types";
|
||||||
@@ -32,9 +40,9 @@ const defaultQuickPaneSettings: QuickPaneSettings = {
|
|||||||
followUpProvider: "",
|
followUpProvider: "",
|
||||||
followUpModel: "",
|
followUpModel: "",
|
||||||
instructions:
|
instructions:
|
||||||
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.",
|
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or complex work.",
|
||||||
maximumSeconds: 60,
|
|
||||||
maximumToolCalls: 6,
|
maximumToolCalls: 6,
|
||||||
|
defaultThinkingLevel: "medium",
|
||||||
};
|
};
|
||||||
function validateQuickPaneSettings(settings: QuickPaneSettings) {
|
function validateQuickPaneSettings(settings: QuickPaneSettings) {
|
||||||
if (!settings.workspace.startsWith("/"))
|
if (!settings.workspace.startsWith("/"))
|
||||||
@@ -45,18 +53,14 @@ function validateQuickPaneSettings(settings: QuickPaneSettings) {
|
|||||||
return "Follow-up provider and model are required.";
|
return "Follow-up provider and model are required.";
|
||||||
if (!settings.instructions.trim())
|
if (!settings.instructions.trim())
|
||||||
return "Supplemental instructions are required.";
|
return "Supplemental instructions are required.";
|
||||||
if (
|
|
||||||
!Number.isInteger(settings.maximumSeconds) ||
|
|
||||||
settings.maximumSeconds < 1 ||
|
|
||||||
settings.maximumSeconds > 600
|
|
||||||
)
|
|
||||||
return "Seconds must be an integer from 1 to 600.";
|
|
||||||
if (
|
if (
|
||||||
!Number.isInteger(settings.maximumToolCalls) ||
|
!Number.isInteger(settings.maximumToolCalls) ||
|
||||||
settings.maximumToolCalls < 1 ||
|
settings.maximumToolCalls < 1 ||
|
||||||
settings.maximumToolCalls > 64
|
settings.maximumToolCalls > 64
|
||||||
)
|
)
|
||||||
return "Tool calls must be an integer from 1 to 64.";
|
return "Tool calls must be an integer from 1 to 64.";
|
||||||
|
if (!settings.defaultThinkingLevel)
|
||||||
|
return "Default thinking level is required.";
|
||||||
}
|
}
|
||||||
let submissionId = 0;
|
let submissionId = 0;
|
||||||
type LocalOperation = {
|
type LocalOperation = {
|
||||||
@@ -161,6 +165,182 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState("");
|
const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState("");
|
||||||
const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>();
|
const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>();
|
||||||
|
const [modelCatalog, setModelCatalog] = useState<Model[]>([]);
|
||||||
|
const [modelCatalogStatus, setModelCatalogStatus] = useState<
|
||||||
|
"idle" | "loading" | "ready" | "error"
|
||||||
|
>("idle");
|
||||||
|
const [modelCatalogError, setModelCatalogError] = useState<string>();
|
||||||
|
const [catalogWorkspace, setCatalogWorkspace] = useState<string>();
|
||||||
|
const modelCatalogRequest = useRef(0);
|
||||||
|
const quickPaneModel = useMemo(
|
||||||
|
() =>
|
||||||
|
modelCatalog.find(
|
||||||
|
(model) =>
|
||||||
|
model.provider === quickPaneSettings.provider &&
|
||||||
|
model.id === quickPaneSettings.model,
|
||||||
|
),
|
||||||
|
[modelCatalog, quickPaneSettings.model, quickPaneSettings.provider],
|
||||||
|
);
|
||||||
|
const catalogProbeRuntimeIds = useRef(new Set<string>());
|
||||||
|
const retainedCatalogProbeRuntimeIds = useRef(new Set<string>());
|
||||||
|
const retainedCatalogProbeErrors = useRef(new Map<string, unknown>());
|
||||||
|
const closingCatalogProbeRuntimes = useRef(new Map<string, Promise<void>>());
|
||||||
|
const quickPaneSettingsRef = useRef(quickPaneSettings);
|
||||||
|
quickPaneSettingsRef.current = quickPaneSettings;
|
||||||
|
const uiStateLoaded = uiState !== undefined;
|
||||||
|
const closeCatalogProbe = useCallback((runtimeId: string) => {
|
||||||
|
if (!catalogProbeRuntimeIds.current.has(runtimeId)) return Promise.resolve();
|
||||||
|
const pendingClose = closingCatalogProbeRuntimes.current.get(runtimeId);
|
||||||
|
if (pendingClose) return pendingClose;
|
||||||
|
const close = Promise.resolve()
|
||||||
|
.then(() => invoke("close_quick_runtime", { runtimeId }))
|
||||||
|
.then(() => {
|
||||||
|
catalogProbeRuntimeIds.current.delete(runtimeId);
|
||||||
|
retainedCatalogProbeRuntimeIds.current.delete(runtimeId);
|
||||||
|
retainedCatalogProbeErrors.current.delete(runtimeId);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
retainedCatalogProbeRuntimeIds.current.add(runtimeId);
|
||||||
|
retainedCatalogProbeErrors.current.set(runtimeId, error);
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
closingCatalogProbeRuntimes.current.delete(runtimeId);
|
||||||
|
});
|
||||||
|
closingCatalogProbeRuntimes.current.set(runtimeId, close);
|
||||||
|
return close;
|
||||||
|
}, []);
|
||||||
|
const drainRetainedCatalogProbes = useCallback(async () => {
|
||||||
|
const retainedIds = [...retainedCatalogProbeRuntimeIds.current];
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
retainedIds.map((runtimeId) => closeCatalogProbe(runtimeId)),
|
||||||
|
);
|
||||||
|
const failedClose = results.find(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||||
|
);
|
||||||
|
if (failedClose) throw failedClose.reason;
|
||||||
|
}, [closeCatalogProbe]);
|
||||||
|
const refreshModelCatalog = useCallback(
|
||||||
|
async (workspace: string) => {
|
||||||
|
const requestId = ++modelCatalogRequest.current;
|
||||||
|
setModelCatalog([]);
|
||||||
|
setCatalogWorkspace(undefined);
|
||||||
|
setModelCatalogStatus("loading");
|
||||||
|
setModelCatalogError(undefined);
|
||||||
|
setQuickPaneSettingsStatus("");
|
||||||
|
let temporaryRuntime: { runtimeId: string } | undefined;
|
||||||
|
let snapshot: RuntimeSnapshot | undefined;
|
||||||
|
let failure: unknown;
|
||||||
|
let cleanupFailure: unknown;
|
||||||
|
try {
|
||||||
|
await drainRetainedCatalogProbes();
|
||||||
|
if (
|
||||||
|
!mountedRef.current ||
|
||||||
|
requestId !== modelCatalogRequest.current ||
|
||||||
|
quickPaneSettingsRef.current.workspace !== workspace
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const result = await invoke<{ runtime: { runtimeId: string } }>(
|
||||||
|
"create_quick_runtime",
|
||||||
|
{ worktreePath: workspace },
|
||||||
|
);
|
||||||
|
temporaryRuntime = result.runtime;
|
||||||
|
catalogProbeRuntimeIds.current.add(temporaryRuntime.runtimeId);
|
||||||
|
if (
|
||||||
|
mountedRef.current &&
|
||||||
|
requestId === modelCatalogRequest.current &&
|
||||||
|
quickPaneSettingsRef.current.workspace === workspace
|
||||||
|
) {
|
||||||
|
snapshot = await invoke<RuntimeSnapshot>("get_session_runtime_snapshot", {
|
||||||
|
runtimeId: temporaryRuntime.runtimeId,
|
||||||
|
});
|
||||||
|
if (snapshot.models?.error) throw new Error(snapshot.models.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failure = error;
|
||||||
|
} finally {
|
||||||
|
if (temporaryRuntime) {
|
||||||
|
try {
|
||||||
|
await closeCatalogProbe(temporaryRuntime.runtimeId);
|
||||||
|
} catch (error) {
|
||||||
|
cleanupFailure = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
if (cleanupFailure || retainedCatalogProbeRuntimeIds.current.size > 0) {
|
||||||
|
setModelCatalog([]);
|
||||||
|
setCatalogWorkspace(undefined);
|
||||||
|
setModelCatalogStatus("error");
|
||||||
|
const retainedFailure = retainedCatalogProbeErrors.current
|
||||||
|
.values()
|
||||||
|
.next().value;
|
||||||
|
setModelCatalogError(
|
||||||
|
`Could not load model catalog: ${String(
|
||||||
|
cleanupFailure ?? retainedFailure ?? failure,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
requestId !== modelCatalogRequest.current ||
|
||||||
|
quickPaneSettingsRef.current.workspace !== workspace
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (failure) {
|
||||||
|
setModelCatalogStatus("error");
|
||||||
|
setModelCatalogError(`Could not load model catalog: ${String(failure)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const freshModels = snapshot?.models?.data?.models ?? [];
|
||||||
|
setModelCatalog(freshModels);
|
||||||
|
setCatalogWorkspace(workspace);
|
||||||
|
setModelCatalogStatus("ready");
|
||||||
|
if (freshModels.length === 0)
|
||||||
|
setQuickPaneSettingsStatus("No models are currently available.");
|
||||||
|
setQuickPaneSettings((current) => {
|
||||||
|
if (current.workspace !== workspace) return current;
|
||||||
|
const quickModel = reconcileModelIdentity(freshModels, {
|
||||||
|
provider: current.provider,
|
||||||
|
modelId: current.model,
|
||||||
|
});
|
||||||
|
const followUpModel = reconcileModelIdentity(freshModels, {
|
||||||
|
provider: current.followUpProvider,
|
||||||
|
modelId: current.followUpModel,
|
||||||
|
});
|
||||||
|
const selectedQuickModel = freshModels.find(
|
||||||
|
(model) =>
|
||||||
|
model.provider === quickModel?.provider &&
|
||||||
|
model.id === quickModel?.modelId,
|
||||||
|
);
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
provider: quickModel?.provider ?? "",
|
||||||
|
model: quickModel?.modelId ?? "",
|
||||||
|
followUpProvider: followUpModel?.provider ?? "",
|
||||||
|
followUpModel: followUpModel?.modelId ?? "",
|
||||||
|
defaultThinkingLevel: selectedQuickModel
|
||||||
|
? reconcileThinkingLevel(selectedQuickModel, current.defaultThinkingLevel)
|
||||||
|
: current.defaultThinkingLevel,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
next.provider === current.provider &&
|
||||||
|
next.model === current.model &&
|
||||||
|
next.followUpProvider === current.followUpProvider &&
|
||||||
|
next.followUpModel === current.followUpModel &&
|
||||||
|
next.defaultThinkingLevel === current.defaultThinkingLevel
|
||||||
|
)
|
||||||
|
return current;
|
||||||
|
setQuickPaneSettingsStatus(
|
||||||
|
freshModels.length > 0
|
||||||
|
? "Unavailable model selection replaced from current catalog."
|
||||||
|
: "No models are currently available.",
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[closeCatalogProbe, drainRetainedCatalogProbes],
|
||||||
|
);
|
||||||
const setRuntimeStatus = (runtimeId: string, detail: string) =>
|
const setRuntimeStatus = (runtimeId: string, detail: string) =>
|
||||||
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
|
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
|
||||||
const clearRuntimeStatus = (runtimeId: string) =>
|
const clearRuntimeStatus = (runtimeId: string) =>
|
||||||
@@ -200,8 +380,10 @@ export default function App() {
|
|||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
|
for (const runtimeId of catalogProbeRuntimeIds.current)
|
||||||
|
void closeCatalogProbe(runtimeId).catch(() => undefined);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [closeCatalogProbe]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -225,6 +407,23 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "settings" || !uiStateLoaded) return;
|
||||||
|
const workspace = quickPaneSettings.workspace;
|
||||||
|
if (!workspace.startsWith("/")) {
|
||||||
|
setModelCatalogStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const refreshTimer = window.setTimeout(
|
||||||
|
() => void refreshModelCatalog(workspace),
|
||||||
|
150,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(refreshTimer);
|
||||||
|
modelCatalogRequest.current += 1;
|
||||||
|
};
|
||||||
|
}, [quickPaneSettings.workspace, refreshModelCatalog, uiStateLoaded, view]);
|
||||||
|
|
||||||
const selectedRuntimeId = selected?.summary.runtimeId;
|
const selectedRuntimeId = selected?.summary.runtimeId;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedRuntimeId) return;
|
if (!selectedRuntimeId) return;
|
||||||
@@ -246,6 +445,14 @@ export default function App() {
|
|||||||
}, [state.runtimesById]);
|
}, [state.runtimesById]);
|
||||||
|
|
||||||
async function saveQuickPaneSettings() {
|
async function saveQuickPaneSettings() {
|
||||||
|
if (
|
||||||
|
modelCatalogStatus !== "ready" ||
|
||||||
|
catalogWorkspace !== quickPaneSettings.workspace ||
|
||||||
|
modelCatalog.length === 0
|
||||||
|
) {
|
||||||
|
setQuickPaneSettingsError("Model catalog is not ready.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const validationError = validateQuickPaneSettings(quickPaneSettings);
|
const validationError = validateQuickPaneSettings(quickPaneSettings);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
setQuickPaneSettingsError(validationError);
|
setQuickPaneSettingsError(validationError);
|
||||||
@@ -680,6 +887,25 @@ export default function App() {
|
|||||||
entry.kind === "opening" ||
|
entry.kind === "opening" ||
|
||||||
entry.kind === "listing"),
|
entry.kind === "listing"),
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.repeat ||
|
||||||
|
!event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.altKey ||
|
||||||
|
event.key.toLowerCase() !== "n" ||
|
||||||
|
sessionTabsBusy
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
event.preventDefault();
|
||||||
|
void createRuntime();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [createRuntime, sessionTabsBusy]);
|
||||||
|
|
||||||
const closingRuntimeIds = new Set(
|
const closingRuntimeIds = new Set(
|
||||||
activeOperations
|
activeOperations
|
||||||
.filter((entry) => entry.kind === "closing" && entry.runtimeId)
|
.filter((entry) => entry.kind === "closing" && entry.runtimeId)
|
||||||
@@ -865,77 +1091,90 @@ export default function App() {
|
|||||||
Workspace
|
Workspace
|
||||||
<input
|
<input
|
||||||
value={quickPaneSettings.workspace}
|
value={quickPaneSettings.workspace}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setQuickPaneSettings({
|
const workspace = event.currentTarget.value;
|
||||||
...quickPaneSettings,
|
modelCatalogRequest.current += 1;
|
||||||
workspace: event.currentTarget.value,
|
setModelCatalog([]);
|
||||||
})
|
setCatalogWorkspace(undefined);
|
||||||
}
|
setModelCatalogStatus("idle");
|
||||||
|
setModelCatalogError(undefined);
|
||||||
|
setQuickPaneSettingsStatus("");
|
||||||
|
setQuickPaneSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
workspace,
|
||||||
|
provider: "",
|
||||||
|
model: "",
|
||||||
|
followUpProvider: "",
|
||||||
|
followUpModel: "",
|
||||||
|
}));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<div className="quick-pane-model-setting">
|
||||||
Quick provider
|
<ModelSelector
|
||||||
<input
|
label="Quick model"
|
||||||
value={quickPaneSettings.provider}
|
placement="down"
|
||||||
onChange={(event) =>
|
models={modelCatalog}
|
||||||
setQuickPaneSettings({
|
current={{
|
||||||
...quickPaneSettings,
|
provider: quickPaneSettings.provider,
|
||||||
provider: event.currentTarget.value,
|
id: quickPaneSettings.model,
|
||||||
})
|
}}
|
||||||
|
disabled={modelCatalogStatus !== "ready"}
|
||||||
|
onSelect={async (model) => {
|
||||||
|
const identity = modelIdentity(model);
|
||||||
|
if (!identity) return false;
|
||||||
|
setQuickPaneSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
provider: identity.provider,
|
||||||
|
model: identity.modelId,
|
||||||
|
defaultThinkingLevel: reconcileThinkingLevel(
|
||||||
|
model,
|
||||||
|
current.defaultThinkingLevel,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small>Provider comes from selected catalog model.</small>
|
||||||
|
</div>
|
||||||
|
<div className="quick-pane-model-setting">
|
||||||
|
<ThinkingSelector
|
||||||
|
label="Default thinking"
|
||||||
|
model={quickPaneModel}
|
||||||
|
value={quickPaneSettings.defaultThinkingLevel}
|
||||||
|
disabled={modelCatalogStatus !== "ready" || !quickPaneModel}
|
||||||
|
onChange={(defaultThinkingLevel) =>
|
||||||
|
setQuickPaneSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
defaultThinkingLevel,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
<small>Used when a Quick Pane opens.</small>
|
||||||
<label>
|
</div>
|
||||||
Quick model
|
<div className="quick-pane-model-setting">
|
||||||
<input
|
<ModelSelector
|
||||||
value={quickPaneSettings.model}
|
label="Follow-up model"
|
||||||
onChange={(event) =>
|
placement="down"
|
||||||
setQuickPaneSettings({
|
models={modelCatalog}
|
||||||
...quickPaneSettings,
|
current={{
|
||||||
model: event.currentTarget.value,
|
provider: quickPaneSettings.followUpProvider,
|
||||||
})
|
id: quickPaneSettings.followUpModel,
|
||||||
}
|
}}
|
||||||
|
disabled={modelCatalogStatus !== "ready"}
|
||||||
|
onSelect={async (model) => {
|
||||||
|
const identity = modelIdentity(model);
|
||||||
|
if (!identity) return false;
|
||||||
|
setQuickPaneSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
followUpProvider: identity.provider,
|
||||||
|
followUpModel: identity.modelId,
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
<small>Provider comes from selected catalog model.</small>
|
||||||
<label>
|
</div>
|
||||||
Follow-up provider
|
|
||||||
<input
|
|
||||||
value={quickPaneSettings.followUpProvider}
|
|
||||||
onChange={(event) =>
|
|
||||||
setQuickPaneSettings({
|
|
||||||
...quickPaneSettings,
|
|
||||||
followUpProvider: event.currentTarget.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Follow-up model
|
|
||||||
<input
|
|
||||||
value={quickPaneSettings.followUpModel}
|
|
||||||
onChange={(event) =>
|
|
||||||
setQuickPaneSettings({
|
|
||||||
...quickPaneSettings,
|
|
||||||
followUpModel: event.currentTarget.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Maximum seconds
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="600"
|
|
||||||
value={quickPaneSettings.maximumSeconds}
|
|
||||||
onChange={(event) =>
|
|
||||||
setQuickPaneSettings({
|
|
||||||
...quickPaneSettings,
|
|
||||||
maximumSeconds: Number(event.currentTarget.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
Maximum tool calls
|
Maximum tool calls
|
||||||
<input
|
<input
|
||||||
@@ -963,12 +1202,24 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={!uiState}>
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
!uiState ||
|
||||||
|
modelCatalogStatus !== "ready" ||
|
||||||
|
catalogWorkspace !== quickPaneSettings.workspace ||
|
||||||
|
modelCatalog.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
Save Quick Pane settings
|
Save Quick Pane settings
|
||||||
</button>
|
</button>
|
||||||
|
{modelCatalogStatus === "loading" && (
|
||||||
|
<p role="status">Loading model catalog…</p>
|
||||||
|
)}
|
||||||
{quickPaneSettingsStatus && (
|
{quickPaneSettingsStatus && (
|
||||||
<p role="status">{quickPaneSettingsStatus}</p>
|
<p role="status">{quickPaneSettingsStatus}</p>
|
||||||
)}
|
)}
|
||||||
|
{modelCatalogError && <p role="alert">{modelCatalogError}</p>}
|
||||||
{quickPaneSettingsError && <p role="alert">{quickPaneSettingsError}</p>}
|
{quickPaneSettingsError && <p role="alert">{quickPaneSettingsError}</p>}
|
||||||
</form>
|
</form>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
|
|||||||
@@ -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,16 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { RpcCommand, RpcField } from "../commands/rpc";
|
import type { RpcCommand, RpcField } from "../commands/rpc";
|
||||||
|
|
||||||
const controls = (container: HTMLElement | null) =>
|
type RpcInputValue =
|
||||||
Array.from(
|
| string
|
||||||
container?.querySelectorAll<HTMLElement>("button, input, select, textarea") ??
|
| boolean
|
||||||
[],
|
| null
|
||||||
).filter((node) => !node.hasAttribute("disabled"));
|
| RpcInputValue[]
|
||||||
|
| { [key: string]: RpcInputValue }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
function valueFor(field: RpcField, value: string | boolean): unknown {
|
function valueFor(field: RpcField, value: string | boolean): RpcInputValue {
|
||||||
if (field.kind === "boolean") return value === true;
|
if (field.kind === "boolean") return value === true;
|
||||||
if (field.kind === "json")
|
if (field.kind === "json")
|
||||||
return value ? JSON.parse(value as string) : undefined;
|
return value ? (JSON.parse(value as string) as RpcInputValue) : undefined;
|
||||||
return value || undefined;
|
return value || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,19 +29,17 @@ export function CommandFormDialog({
|
|||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
commandError?: string;
|
commandError?: string;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLElement>(null);
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
const opener = useRef<HTMLElement | null>(null);
|
|
||||||
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
||||||
const [validationError, setValidationError] = useState<string>();
|
const [validationError, setValidationError] = useState<string>();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!command) return;
|
if (!command) return;
|
||||||
opener.current = document.activeElement as HTMLElement | null;
|
|
||||||
setValues({});
|
setValues({});
|
||||||
setValidationError(undefined);
|
setValidationError(undefined);
|
||||||
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
|
const dialog = ref.current;
|
||||||
|
dialog?.showModal();
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(frame);
|
if (dialog?.open) dialog.close();
|
||||||
opener.current?.focus();
|
|
||||||
};
|
};
|
||||||
}, [command]);
|
}, [command]);
|
||||||
if (!command) return null;
|
if (!command) return null;
|
||||||
@@ -63,32 +63,14 @@ export function CommandFormDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
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"
|
||||||
aria-busy={pending}
|
aria-busy={pending}
|
||||||
onKeyDown={(event) => {
|
onCancel={(event) => {
|
||||||
if (event.key === "Escape") {
|
|
||||||
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>
|
||||||
@@ -165,7 +147,6 @@ export function CommandFormDialog({
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</dialog>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { ModelSelector } from "./ModelSelector";
|
||||||
|
import { ThinkingSelector } from "./ThinkingSelector";
|
||||||
|
|
||||||
type SessionStatus = {
|
type SessionStatus = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -185,6 +186,11 @@ export function ConversationWorkspace({
|
|||||||
const activeTool = runtime.progress.tool
|
const activeTool = runtime.progress.tool
|
||||||
? plainStatusText(runtime.progress.tool)
|
? plainStatusText(runtime.progress.tool)
|
||||||
: undefined;
|
: 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 inputTokens = runtime.stats.tokens?.input;
|
||||||
const outputTokens = runtime.stats.tokens?.output;
|
const outputTokens = runtime.stats.tokens?.output;
|
||||||
const cacheReadTokens = runtime.stats.tokens?.cacheRead;
|
const cacheReadTokens = runtime.stats.tokens?.cacheRead;
|
||||||
@@ -330,20 +336,12 @@ export function ConversationWorkspace({
|
|||||||
onSelect={onModel}
|
onSelect={onModel}
|
||||||
pending={isModelUpdating || locked}
|
pending={isModelUpdating || locked}
|
||||||
/>
|
/>
|
||||||
<label>
|
<ThinkingSelector
|
||||||
Thinking
|
model={selectedThinkingModel ?? runtime.state.model}
|
||||||
<select
|
|
||||||
value={runtime.state.thinkingLevel ?? "medium"}
|
value={runtime.state.thinkingLevel ?? "medium"}
|
||||||
disabled={isModelUpdating || locked}
|
disabled={isModelUpdating || locked}
|
||||||
onChange={(event) => void onThinking(event.currentTarget.value)}
|
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={`session-status-strip ${presentedStatus.phase}`}
|
className={`session-status-strip ${presentedStatus.phase}`}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } 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 type { Model } from "../workspace/types";
|
import type { Model } from "../workspace/types";
|
||||||
@@ -76,6 +76,57 @@ describe("ModelSelector", () => {
|
|||||||
await waitFor(() => expect(trigger).toHaveFocus());
|
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 () => {
|
it("keeps the selector open and restores controls when an update fails", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSelect = vi.fn().mockResolvedValue(false);
|
const onSelect = vi.fn().mockResolvedValue(false);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
import type { AgentState, Model } from "../workspace/types";
|
import type { AgentState, Model } from "../workspace/types";
|
||||||
|
import "./ModelSelector.css";
|
||||||
|
|
||||||
type SelectableModel = Model & { provider: string; id: string };
|
type SelectableModel = Model & { provider: string; id: string };
|
||||||
type ModelGroup = { provider: string; models: SelectableModel[] };
|
type ModelGroup = { provider: string; models: SelectableModel[] };
|
||||||
@@ -87,18 +88,37 @@ export function ModelSelector({
|
|||||||
current,
|
current,
|
||||||
onSelect,
|
onSelect,
|
||||||
pending = false,
|
pending = false,
|
||||||
|
disabled = false,
|
||||||
|
label = "Model",
|
||||||
|
placement = "up",
|
||||||
|
className = "",
|
||||||
|
triggerClassName = "",
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
models: Model[];
|
models: Model[];
|
||||||
current?: AgentState["model"];
|
current?: AgentState["model"];
|
||||||
onSelect: (model: Model) => Promise<boolean>;
|
onSelect: (model: Model) => Promise<boolean>;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
placement?: "up" | "down";
|
||||||
|
className?: string;
|
||||||
|
triggerClassName?: string;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selecting, setSelecting] = useState(false);
|
const [selecting, setSelecting] = useState(false);
|
||||||
|
const openRef = useRef(false);
|
||||||
|
const onOpenRef = useRef(onOpen);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
onOpenRef.current = onOpen;
|
||||||
|
onCloseRef.current = onClose;
|
||||||
const labelId = useId();
|
const labelId = useId();
|
||||||
const valueId = useId();
|
const valueId = useId();
|
||||||
const menuId = useId();
|
const menuId = useId();
|
||||||
@@ -124,6 +144,29 @@ export function ModelSelector({
|
|||||||
? `${current.provider} · ${selectedName ?? "Current model"}`
|
? `${current.provider} · ${selectedName ?? "Current model"}`
|
||||||
: "Current model";
|
: "Current model";
|
||||||
const busy = pending || selecting;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -133,11 +176,10 @@ export function ModelSelector({
|
|||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setOpen(false);
|
closeMenu(true);
|
||||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
|
||||||
};
|
};
|
||||||
const closeOutside = (event: PointerEvent) => {
|
const closeOutside = (event: PointerEvent) => {
|
||||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
if (!rootRef.current?.contains(event.target as Node)) closeMenu();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", closeOnEscape);
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
window.addEventListener("pointerdown", closeOutside);
|
window.addEventListener("pointerdown", closeOutside);
|
||||||
@@ -153,29 +195,31 @@ export function ModelSelector({
|
|||||||
setSelecting(true);
|
setSelecting(true);
|
||||||
try {
|
try {
|
||||||
if (!(await onSelect(model))) return;
|
if (!(await onSelect(model))) return;
|
||||||
setOpen(false);
|
closeMenu(true);
|
||||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
|
||||||
} finally {
|
} finally {
|
||||||
setSelecting(false);
|
setSelecting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="model-control" aria-busy={busy}>
|
<div
|
||||||
<span id={labelId}>Model</span>
|
className={`model-control model-control-${placement} ${className}`.trim()}
|
||||||
|
aria-busy={busy}
|
||||||
|
>
|
||||||
|
<span id={labelId}>{label}</span>
|
||||||
<div className="model-selector" ref={rootRef}>
|
<div className="model-selector" ref={rootRef}>
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="model-trigger"
|
className={["model-trigger", triggerClassName].filter(Boolean).join(" ")}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
aria-controls={open ? menuId : undefined}
|
aria-controls={open ? menuId : undefined}
|
||||||
aria-labelledby={`${labelId} ${valueId}`}
|
aria-labelledby={`${labelId} ${valueId}`}
|
||||||
disabled={availableModels.length === 0 || busy}
|
disabled={availableModels.length === 0 || busy || disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQuery("");
|
if (openRef.current) closeMenu();
|
||||||
setOpen((value) => !value);
|
else openMenu();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span id={valueId}>{selectedLabel}</span>
|
<span id={valueId}>{selectedLabel}</span>
|
||||||
@@ -219,7 +263,7 @@ export function ModelSelector({
|
|||||||
current={current}
|
current={current}
|
||||||
headingId={`${menuId}-provider-${index}`}
|
headingId={`${menuId}-provider-${index}`}
|
||||||
onSelect={(model) => void chooseModel(model)}
|
onSelect={(model) => void chooseModel(model)}
|
||||||
disabled={busy}
|
disabled={busy || disabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -178,6 +178,34 @@ describe("multi-session navigation", () => {
|
|||||||
await user.click(screen.getByText("Tool result · read"));
|
await user.click(screen.getByText("Tool result · read"));
|
||||||
expect(details).toHaveAttribute("open");
|
expect(details).toHaveAttribute("open");
|
||||||
});
|
});
|
||||||
|
it("uses omitted-reasoning state model metadata when catalog lacks current model", async () => {
|
||||||
|
const runtime = runtimeView(summary("thinking"));
|
||||||
|
runtime.state = {
|
||||||
|
model: {
|
||||||
|
provider: "provider",
|
||||||
|
id: "current",
|
||||||
|
thinkingLevelMap: { high: null, xhigh: "xhigh" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<ConversationWorkspace
|
||||||
|
runtime={runtime}
|
||||||
|
onDraft={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onAbort={vi.fn()}
|
||||||
|
onScroll={vi.fn()}
|
||||||
|
onModel={vi.fn()}
|
||||||
|
onThinking={vi.fn()}
|
||||||
|
onCommand={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const thinking = screen.getByRole("combobox", { name: "Thinking" });
|
||||||
|
expect(
|
||||||
|
within(thinking)
|
||||||
|
.getAllByRole("option")
|
||||||
|
.map((option) => option.getAttribute("value")),
|
||||||
|
).toEqual(["off", "minimal", "low", "medium", "xhigh"]);
|
||||||
|
});
|
||||||
it("packs session status between model controls and the composer", () => {
|
it("packs session status between model controls and the composer", () => {
|
||||||
const runtime = runtimeView(summary("status", "streaming"));
|
const runtime = runtimeView(summary("status", "streaming"));
|
||||||
runtime.summary.queueCount = 2;
|
runtime.summary.queueCount = 2;
|
||||||
@@ -351,29 +379,16 @@ describe("multi-session navigation", () => {
|
|||||||
await user.type(search, "missing");
|
await user.type(search, "missing");
|
||||||
expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument();
|
expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
it("focuses search, traps focus, and restores the opener", async () => {
|
it("opens a native modal and handles cancellation", () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const opener = document.createElement("button");
|
|
||||||
document.body.append(opener);
|
|
||||||
opener.focus();
|
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const { unmount } = render(
|
render(<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />);
|
||||||
<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />,
|
const dialog = screen.getByRole("dialog", { name: "Saved sessions" });
|
||||||
);
|
expect(dialog).toHaveAttribute("open");
|
||||||
const search = screen.getByRole("searchbox", {
|
expect(
|
||||||
name: "Search saved sessions",
|
screen.getByRole("searchbox", { name: "Search saved sessions" }),
|
||||||
});
|
).toHaveFocus();
|
||||||
const close = screen.getByRole("button", { name: "Close" });
|
fireEvent(dialog, new Event("cancel", { cancelable: true }));
|
||||||
expect(search).toHaveFocus();
|
|
||||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
|
||||||
expect(close).toHaveFocus();
|
|
||||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
|
||||||
expect(search).toHaveFocus();
|
|
||||||
await user.keyboard("{Escape}");
|
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
unmount();
|
|
||||||
expect(opener).toHaveFocus();
|
|
||||||
document.body.removeChild(opener);
|
|
||||||
});
|
});
|
||||||
it("labels permission requests with the action and exact details", async () => {
|
it("labels permission requests with the action and exact details", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -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
+1291
-186
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,648 @@
|
|||||||
:root { color-scheme: dark; }
|
:root {
|
||||||
body { margin: 0; background: transparent; }
|
color-scheme: dark;
|
||||||
.quick-pane {
|
}
|
||||||
box-sizing: border-box;
|
|
||||||
min-height: 100vh;
|
html,
|
||||||
padding: 18px;
|
body,
|
||||||
color: #f4f2ee;
|
#root {
|
||||||
font: 14px/1.4 system-ui, sans-serif;
|
width: 100%;
|
||||||
background: radial-gradient(circle at top, #46494f, #23252a 62%);
|
height: 100%;
|
||||||
border: 1px solid rgba(255,255,255,.16);
|
overflow: hidden;
|
||||||
border-radius: 14px;
|
}
|
||||||
box-shadow: 0 22px 70px rgba(0,0,0,.55);
|
|
||||||
|
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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.quick-pane header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; color:#c9c7c2; font-size:12px; letter-spacing:.08em; text-transform:uppercase; }
|
|
||||||
.quick-pane button, .quick-pane select { border:0; border-radius:7px; padding:7px 10px; color:inherit; background:#45484e; }
|
|
||||||
.quick-pane textarea { box-sizing:border-box; width:100%; min-height:92px; padding:14px; border:0; border-radius:10px; outline:1px solid rgba(255,255,255,.14); color:#fff; background:rgba(0,0,0,.24); font:inherit; font-size:16px; resize:none; }
|
|
||||||
.quick-pane textarea:focus { outline:2px solid #8aa9d6; background:rgba(0,0,0,.38); }
|
|
||||||
.quick-pane > label { display:inline-flex; align-items:center; gap:7px; margin:10px 8px 10px 0; color:#c9c7c2; }
|
|
||||||
.quick-pane > pre, .quick-pane > p { margin:10px 0 0; padding:10px 12px; border-radius:9px; white-space:pre-wrap; background:rgba(0,0,0,.22); }
|
|
||||||
.quick-pane [role="alert"] { color:#ffb8bd; background:rgba(119,42,52,.42); }
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -194,6 +194,56 @@ describe("workspace reducer", () => {
|
|||||||
]);
|
]);
|
||||||
expect(state.runtimesById.a.modelError).toContain("RPC timed out");
|
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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -228,8 +238,8 @@ export type QuickPaneSettings = {
|
|||||||
followUpProvider: string;
|
followUpProvider: string;
|
||||||
followUpModel: string;
|
followUpModel: string;
|
||||||
instructions: string;
|
instructions: string;
|
||||||
maximumSeconds: number;
|
|
||||||
maximumToolCalls: number;
|
maximumToolCalls: number;
|
||||||
|
defaultThinkingLevel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UiState = {
|
export type UiState = {
|
||||||
|
|||||||
@@ -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,6 +27,9 @@ 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(
|
const loadSnapshot = useCallback(
|
||||||
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
|
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
|
||||||
@@ -150,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;
|
||||||
@@ -184,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 (
|
||||||
@@ -197,6 +245,7 @@ export function useWorkspace() {
|
|||||||
"runtime_recovered",
|
"runtime_recovered",
|
||||||
"runtime_renewed",
|
"runtime_renewed",
|
||||||
"runtime_restore_failed",
|
"runtime_restore_failed",
|
||||||
|
"runtime_promoted",
|
||||||
].includes(eventType ?? "")
|
].includes(eventType ?? "")
|
||||||
) {
|
) {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -215,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)
|
||||||
@@ -232,7 +288,6 @@ export function useWorkspace() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const appWindow = getCurrentWindow();
|
|
||||||
const visibility = () =>
|
const visibility = () =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "setWindowActive",
|
type: "setWindowActive",
|
||||||
@@ -243,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