Compare commits
33 Commits
bab754714e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 636ef5dcc2 | |||
| 56fe4e734d | |||
| 35719e0961 | |||
| 6385c20f91 | |||
| e181b2ab12 | |||
| 0949c6f0d7 | |||
| a4ee3eb7a8 | |||
| 087c177e8e | |||
| d448152e7f | |||
| 9345b67c9f | |||
| 35d5698f1a | |||
| ce8e2e1332 | |||
| a180ac9a2c | |||
| 5642d9be91 | |||
| 1ce03ef986 | |||
| 0eb163b6c4 | |||
| b44b1be4b7 | |||
| 0a318f5ab6 | |||
| 5e9074605d | |||
| e869a0bfcb | |||
| aa37a2f60e | |||
| 0da45b468e | |||
| 880cddf3cf | |||
| 60fb91f375 | |||
| 05874a0be1 | |||
| ad66067a31 | |||
| 4ec951bc05 | |||
| d34099771c | |||
| 0ec7d224a2 | |||
| 4fc9c60ade | |||
| 1b0b34fad8 | |||
| ab482d3daa | |||
| a204eea7da |
@@ -77,7 +77,7 @@ The request payload is the local protocol JSON. See [`DESIGN.md`](DESIGN.md) for
|
||||
node src/client/noctalia-relay-cli.js --socket "$PI_STATUS_BRIDGE_SOCKET"
|
||||
```
|
||||
|
||||
The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests. `--agent <agent-id>` retains the former agent-scoped relay only for external compatibility callers.
|
||||
The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests.
|
||||
|
||||
## Keep the bridge running in the background (recommended)
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
## 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
|
||||
|
||||
- Cold-launchable, independent Tauri window.
|
||||
- Compact request input, quick-model picker, and workspace picker.
|
||||
- Configured default workspace, quick model, follow-up model, supplemental prompt, time limit, and read-only tool limit.
|
||||
- Configured default workspace, quick model, follow-up model, supplemental prompt, and read-only tool-call guidance.
|
||||
- Ephemeral Quick Pane UI state and best-effort runtime cleanup.
|
||||
- Advisory read-only/no-subagent instructions, inline permission/clarification prompts, and explicit normal-session handoff.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
1. Pane chooses configured default workspace and quick model, with per-request overrides.
|
||||
2. It starts an isolated Pi RPC runtime in the chosen workspace.
|
||||
3. Immutable prompt text instructs one request, read-only inspection, zero subagents, configured wall-clock budget, and configured tool-call budget. Current Pi RPC cannot trusted-enforce those instructions; event-derived caps and cleanup are best effort.
|
||||
4. Supplemental user instructions append beneath those prompt guardrails.
|
||||
5. Pane streams answer, tool status, and inline extension requests.
|
||||
6. On success it displays final answer. On a cap it preserves partial answer, explains the cap, and offers handoff.
|
||||
3. Immutable prompt text instructs short requests, read-only inspection, zero subagents, and configured tool-call budget. Current Pi RPC cannot trusted-enforce those instructions.
|
||||
4. Supplemental user instructions append beneath those prompt guardrails. Pane maps each exact submitted raw prompt back to entered user text, so transcript rendering never parses delimiter-like text from instructions or requests. Unknown reflected messages with the Quick Pane advisory prefix are hidden.
|
||||
5. Pane streams the full ordered user/assistant transcript, tool status, and inline extension requests. Follow-ups reuse the same runtime until explicit or stale renewal. Starting each follow-up clears only prior turn's handoff answer state; transcript messages remain visible.
|
||||
6. After first completed assistant answer, it displays a prominent manual **Continue in full session** action. Escalation and runtime-error notices remain visible and also offer handoff. No answer triggers a full session automatically.
|
||||
|
||||
## Chat reset and layout
|
||||
|
||||
`Ctrl+N` starts a new chat: it aborts active work when needed, closes the old runtime with retryable cleanup, clears draft/transcript and submitted-prompt mappings, then prepares a fresh runtime and model catalog. Automatic stale renewal performs the same idle-chat replacement while preserving the current draft.
|
||||
|
||||
Quick Pane opens at 640×360 with a 520×360 minimum. Frontend content measurement requests native growth up to 520px tall after transcript updates; beyond that cap, transcript and model results scroll inside pane while outer document and pane remain clipped.
|
||||
|
||||
## Escalation
|
||||
|
||||
Mutations, subagents, multi-step work, and 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;
|
||||
- escalation note or partial findings;
|
||||
- transcript-derived current-chat context and escalation note or partial findings;
|
||||
- selected workspace; and
|
||||
- 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 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
|
||||
|
||||
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 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
|
||||
|
||||
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
|
||||
|
||||
- Add isolated state reducer/component for request, selected model/workspace, progress, final answer, partial answer, escalation, extension UI, and errors.
|
||||
- Keep pane transcript in component memory only and request best-effort runtime/session cleanup on close.
|
||||
- Add isolated state/component logic for request, selected model/workspace, progress, full ordered transcript, current-turn partial answer, escalation, extension UI, and errors. Clear prior answer/handoff state before every follow-up while retaining transcript messages.
|
||||
- Keep pane transcript and exact raw-prompt-to-visible-request mappings in component memory only. Hide unknown reflected Quick Pane advisory prompts rather than delimiter-parsing them.
|
||||
- Make `Esc` and **Close** native hide actions. Preserve idle runtime, draft, catalog, and transcript when reopened within three minutes; renew stale idle chats on focus.
|
||||
- Add `Ctrl+N` reset semantics: abort active work, close with cleanup retry, clear draft/transcript/prompt mappings, and prepare a fresh catalog/runtime.
|
||||
- Open native window at 640×360 with a 520×360 minimum; use ResizeObserver/RAF content measurement to request native growth only up to 520px tall, then use internal scrolling.
|
||||
- Add visible Retry recovery for settings-startup and model-catalog preparation failures. Finish failed-runtime cleanup before replacement preparation, and keep model/Send disabled until ready.
|
||||
- Add Settings controls and copyable WM launch command.
|
||||
|
||||
## 4. Add constrained bridge runtime mode
|
||||
|
||||
- Extend runtime creation with Quick Pane metadata and ephemeral session lifecycle.
|
||||
- Build immutable advisory quick-agent instructions around supplemental custom prompt.
|
||||
- Use observed tool events and wall-clock cancellation for best-effort caps; document that current Pi RPC cannot trusted-enforce read-only/no-subagent policy or memory-only runtime sessions.
|
||||
- Instruct preflight escalation for mutation, subagent, multi-step, or likely-over-budget requests.
|
||||
- Use advisory tool-call guidance only; document that current Pi RPC cannot trusted-enforce read-only/no-subagent policy or memory-only runtime sessions.
|
||||
- Instruct preflight escalation for mutation, subagent, multi-step, or complex requests.
|
||||
|
||||
## 5. Route interaction and handoff
|
||||
|
||||
- Reuse existing extension request/response protocol for inline Quick Pane permission and clarification dialogs.
|
||||
- On explicit continuation, create normal runtime using workspace and follow-up model.
|
||||
- Prefill it with original request plus escalation note/partial findings.
|
||||
- After first completed assistant answer, show manual continuation with valid handoff configuration; never open a session automatically.
|
||||
- Prefill normal session with transcript-derived current-chat context, workspace, and follow-up model.
|
||||
|
||||
## 6. Test and document
|
||||
|
||||
- Add unit tests for settings validation/persistence and guardrail classification.
|
||||
- Add component tests for pane states, picker behavior, inline prompts, error/retry, and cleanup.
|
||||
- Add Rust/bridge tests for window command routing, budget cancellation, ephemeral cleanup, and handoff payload.
|
||||
- Add component tests for pane states, full transcript and exact prompt reflection, picker behavior, inline prompts, hide/renew/reset behavior, runtime and extension errors, stale-created cleanup retry, and handoff.
|
||||
- Add Rust/bridge tests for window command routing, bounded content resizing, ephemeral cleanup, and handoff payload.
|
||||
- Add manual WM hotkey/cold-launch instructions and smoke checklist.
|
||||
|
||||
## Sequencing
|
||||
|
||||
@@ -6,36 +6,43 @@
|
||||
|
||||
- Valid defaults load when no Quick Pane configuration exists.
|
||||
- Valid configuration round-trips through native storage.
|
||||
- Invalid workspace, model, prompt, time, and tool values show field errors and retain prior saved state.
|
||||
- Invalid workspace, model, prompt, and tool-call values show field errors and retain prior saved state.
|
||||
- Quick model picker affects quick request only; follow-up model remains configured default.
|
||||
|
||||
### Runtime guardrails
|
||||
|
||||
- Quick runtime receives immutable advisory read-only/no-subagent prompt text plus supplemental prompt.
|
||||
- Mutation, subagent, multi-step, and likely-over-budget requests are instructed to preflight-escalate.
|
||||
- Runtime applies best-effort configured 60-second/6-tool caps from observed events and exposes reason.
|
||||
- Partial result survives a cap; pane transcript is removed and runtime cleanup requested after pane close.
|
||||
- Mutation, subagent, multi-step, and complex requests are instructed to preflight-escalate.
|
||||
- Quick Pane has no time-based abort; configured tool-call guidance remains prompt-only.
|
||||
- Full ordered user/assistant transcript remains visible for follow-ups. Each follow-up clears prior answer/partial-handoff state before model, thinking, or prompt submission.
|
||||
- Reflected user messages are resolved by exact submitted raw-prompt mapping. Supplemental instructions and entered requests containing `\n\nUser request:` render only the exact entered multiline request; unknown Quick Pane advisory prompts are hidden and ordinary user text remains visible.
|
||||
- Tests/documentation state current limitations: Pi RPC cannot trusted-enforce prompt policy or guarantee memory-only temporary sessions.
|
||||
|
||||
### UI and bridge
|
||||
|
||||
- Quick Pane opens/focuses through dedicated native command without changing normal `--new` or `--toggle` behavior.
|
||||
- Workspace and quick-model picker values reach runtime creation.
|
||||
- Permission and clarification extension requests render inline and send correct response payloads.
|
||||
- Retry, model change, and normal-session actions appear for startup/model/crash errors.
|
||||
- Handoff creates normal session with original request, escalation note, workspace, and follow-up model.
|
||||
- Permission and clarification extension requests remain visible inline and send correct response payloads; runtime errors do not erase partial transcript or pending extensions.
|
||||
- `Esc` and **Close** hide without cleanup. Reopening an idle chat at or under three minutes preserves runtime, draft/catalog, and transcript; reopening after more than three minutes renews it.
|
||||
- `Ctrl+N` prevents browser default, aborts active work when needed, retries queued cleanup, clears draft/transcript/raw-prompt mappings, and prepares a fresh runtime/catalog. Idle reset does not send an abort.
|
||||
- Stale runtimes created by superseded preparation are queued after cleanup failure and retried before later stale-chat renewal.
|
||||
- Quick Pane opens at 640×360 with a 520×360 native minimum. Content-driven native growth stops at 520px tall; transcript and model results scroll internally after that.
|
||||
- Settings-startup and model-catalog errors show **Retry**. Catalog retry cleans up failed runtime before replacement preparation; model selection and Send stay disabled until ready.
|
||||
- **Continue in full session** appears after first completed assistant answer with valid handoff settings, plus preserved request/runtime escalation notices. It never starts automatically. Handoff creates normal session with transcript-derived current-chat context, workspace, and follow-up model. Current answer extraction starts only after exact mapped submitted prompt in snapshot transcript; failed follow-ups with no current assistant message never reuse prior answer as partial findings.
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
1. Configure default workspace, models, prompt, and 60-second/6-tool limits.
|
||||
1. Configure default workspace, models, prompt, and tool-call guidance.
|
||||
2. Add desktop/WM hotkey using Settings launch command.
|
||||
3. From no running GUI process, invoke hotkey and verify Quick Pane opens/focuses.
|
||||
4. Submit 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.
|
||||
6. Submit mutation or multi-step request; verify reason and **Continue in full session**.
|
||||
7. Continue; verify normal session receives handoff and follow-up model.
|
||||
8. Close Quick Pane; reopen and verify quick transcript is absent.
|
||||
9. Select unavailable model or induce startup failure; verify inline recovery options.
|
||||
8. Close Quick Pane and reopen within three minutes; verify idle runtime, draft/catalog, and full transcript remain. Repeat after more than three idle minutes; verify fresh chat/runtime appears.
|
||||
9. Press `Ctrl+N` while idle and active; verify fresh empty chat in both cases and active work aborts before replacement.
|
||||
10. Verify pane opens at 640×360, grows only to 520px tall as transcript grows, then keeps transcript/model results scrolling internally.
|
||||
11. Induce settings-startup or model-catalog failure; verify visible **Retry**, disabled model/Send controls until recovery, and successful retry. Verify empty catalog stays blocked without unsupported actions.
|
||||
|
||||
## Exit criteria
|
||||
|
||||
|
||||
+314
-34
@@ -5,8 +5,11 @@ import {
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
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) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
@@ -245,6 +311,7 @@ export function createAgentRegistry({
|
||||
homeWorktree,
|
||||
sessionRoot,
|
||||
startAdapter = startPiRpcAdapter,
|
||||
workspaceStoreFactory = createWorkspaceStore,
|
||||
eventLimit = DEFAULT_EVENT_LIMIT,
|
||||
maxRecoveryAttempts,
|
||||
recoveryDelayForAttempt,
|
||||
@@ -257,9 +324,11 @@ export function createAgentRegistry({
|
||||
throw new TypeError("sessionRoot must be an absolute path");
|
||||
if (typeof startAdapter !== "function")
|
||||
throw new TypeError("startAdapter must be a function");
|
||||
if (typeof workspaceStoreFactory !== "function")
|
||||
throw new TypeError("workspaceStoreFactory must be a function");
|
||||
|
||||
const bridgeInstanceId = randomUUID();
|
||||
const workspaceStore = createWorkspaceStore(sessionRoot);
|
||||
const workspaceStore = workspaceStoreFactory(sessionRoot);
|
||||
const runtimesByRuntimeId = new Map();
|
||||
const runtimesByAgentId = new Map();
|
||||
const runtimesByWorktreePath = new Map();
|
||||
@@ -272,10 +341,13 @@ export function createAgentRegistry({
|
||||
const workspaceEvents = [];
|
||||
let workspaceSequence = 0;
|
||||
let manifestIssue;
|
||||
let manifestWriteQueue = Promise.resolve();
|
||||
let canonicalHomeWorktree;
|
||||
let lastSuccessfulModelCatalog;
|
||||
let stopping = false;
|
||||
|
||||
function publishWorkspace(type, runtime, data = {}) {
|
||||
if (runtime?.ephemeral) return undefined;
|
||||
const event = {
|
||||
version: "v1",
|
||||
bridgeInstanceId,
|
||||
@@ -307,19 +379,25 @@ export function createAgentRegistry({
|
||||
runtimes: [...runtimesByRuntimeId.values()]
|
||||
.filter((runtime) => !runtime.ephemeral)
|
||||
.map((runtime) => ({
|
||||
runtimeId: runtime.runtimeId,
|
||||
worktreePath: runtime.worktreePath,
|
||||
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
||||
...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
|
||||
...(runtime.legacyDefault ? { legacyDefault: true } : {}),
|
||||
openedAt: runtime.openedAt,
|
||||
})),
|
||||
runtimeId: runtime.runtimeId,
|
||||
worktreePath: runtime.worktreePath,
|
||||
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
||||
...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
|
||||
...(runtime.legacyDefault ? { legacyDefault: true } : {}),
|
||||
openedAt: runtime.openedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function persistWorkspace() {
|
||||
await workspaceStore.save(manifest());
|
||||
manifestIssue = undefined;
|
||||
function persistWorkspace() {
|
||||
const operation = manifestWriteQueue
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
await workspaceStore.save(manifest());
|
||||
manifestIssue = undefined;
|
||||
});
|
||||
manifestWriteQueue = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
function addWorktreeRuntime(runtime) {
|
||||
@@ -429,10 +507,23 @@ export function createAgentRegistry({
|
||||
return resolvedSessionPath;
|
||||
}
|
||||
|
||||
async function projectQuickSessionPath(quickDir, sessionDir, sessionPath) {
|
||||
const activePath = await ownedSessionPath(quickDir, sessionPath);
|
||||
const relativePath = path.relative(await realpath(quickDir), activePath);
|
||||
return ownedSessionPath(sessionDir, path.join(sessionDir, relativePath), {
|
||||
allowMissing: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateRuntimeIdentity(
|
||||
runtime,
|
||||
stateResponse,
|
||||
{ expectedSessionPath, adapter = runtime.adapter } = {},
|
||||
{
|
||||
expectedSessionPath,
|
||||
adapter = runtime.adapter,
|
||||
persist = true,
|
||||
indexSession = true,
|
||||
} = {},
|
||||
) {
|
||||
if (
|
||||
runtime.stopped ||
|
||||
@@ -475,16 +566,25 @@ export function createAgentRegistry({
|
||||
if (typeof state.sessionName === "string")
|
||||
runtime.sessionName = state.sessionName;
|
||||
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
|
||||
if (resolved && !runtime.sessionName && !runtime.firstMessage) {
|
||||
const summary = await readSessionSummary(resolved, resolved).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (summary?.name) runtime.sessionName = summary.name;
|
||||
if (summary?.firstMessage) runtime.firstMessage = summary.firstMessage;
|
||||
}
|
||||
if (resolved) {
|
||||
if (
|
||||
runtime.sessionPath &&
|
||||
runtime.sessionPath !== resolved &&
|
||||
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
||||
)
|
||||
runtimesBySessionPath.delete(runtime.sessionPath);
|
||||
if (indexSession) {
|
||||
if (
|
||||
runtime.sessionPath &&
|
||||
runtime.sessionPath !== resolved &&
|
||||
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
||||
)
|
||||
runtimesBySessionPath.delete(runtime.sessionPath);
|
||||
runtimesBySessionPath.set(resolved, runtime);
|
||||
releaseSessionReservation(resolved, runtime);
|
||||
}
|
||||
runtime.sessionPath = resolved;
|
||||
runtimesBySessionPath.set(resolved, runtime);
|
||||
releaseSessionReservation(resolved, runtime);
|
||||
if (runtime.legacyDefault)
|
||||
await persistSessionReference(
|
||||
runtime.sessionDir,
|
||||
@@ -492,7 +592,16 @@ export function createAgentRegistry({
|
||||
runtime.worktreePath,
|
||||
);
|
||||
}
|
||||
await persistWorkspace();
|
||||
if (persist) await persistWorkspace();
|
||||
}
|
||||
|
||||
async function restoreSessionReference(sessionDir, previous) {
|
||||
const referencePath = path.join(sessionDir, SESSION_REFERENCE_FILE);
|
||||
if (previous === undefined) {
|
||||
await rm(referencePath, { force: true });
|
||||
return;
|
||||
}
|
||||
await writeFile(referencePath, previous, { encoding: "utf8", mode: 0o600 });
|
||||
}
|
||||
|
||||
function publishAgent(runtime, type, data) {
|
||||
@@ -586,6 +695,8 @@ export function createAgentRegistry({
|
||||
adapter: undefined,
|
||||
supervisor: undefined,
|
||||
stopped: false,
|
||||
promoting: false,
|
||||
promotionPromise: undefined,
|
||||
recoveryPromise: undefined,
|
||||
};
|
||||
}
|
||||
@@ -986,7 +1097,9 @@ export function createAgentRegistry({
|
||||
}
|
||||
if (stopConfirmed) releaseClosedSessionLease(runtime);
|
||||
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";
|
||||
publishWorkspace("runtime_closed", runtime, {
|
||||
runtimeId: runtime.runtimeId,
|
||||
@@ -997,6 +1110,8 @@ export function createAgentRegistry({
|
||||
|
||||
function closeRuntime(runtimeId) {
|
||||
const runtime = getRuntime(runtimeId);
|
||||
if (runtime.promoting)
|
||||
return runtime.promotionPromise.then(() => closeRuntime(runtimeId));
|
||||
if (runtime.state === "closing" || runtime.state === "stopped")
|
||||
return Promise.reject(new Error("session runtime is closing"));
|
||||
const wasWorkingAtRequest =
|
||||
@@ -1015,6 +1130,136 @@ export function createAgentRegistry({
|
||||
return result;
|
||||
}
|
||||
|
||||
function promoteQuickRuntime(runtimeId) {
|
||||
const runtime = getRuntime(runtimeId);
|
||||
if (!runtime.ephemeral) return Promise.resolve(publicRuntime(runtime));
|
||||
if (runtime.state === "closing" || runtime.state === "stopped")
|
||||
return Promise.reject(new Error("session runtime is closing"));
|
||||
if (runtime.promotionPromise) return runtime.promotionPromise;
|
||||
|
||||
runtime.promoting = true;
|
||||
const promotion = runtime.operationQueue.then(async () => {
|
||||
if (!runtime.ephemeral) return publicRuntime(runtime);
|
||||
if (runtime.state === "closing" || runtime.state === "stopped")
|
||||
throw new Error("session runtime is closing");
|
||||
const adapter = runtime.adapter;
|
||||
if (!adapter) throw new Error("Pi is not currently running");
|
||||
const quickDir = runtime.sessionDir;
|
||||
const sessionDir = sessionDirectoryFor(sessionRoot, runtime.worktreePath);
|
||||
const previous = {
|
||||
sessionDir: runtime.sessionDir,
|
||||
sessionPath: runtime.sessionPath,
|
||||
sessionId: runtime.sessionId,
|
||||
sessionName: runtime.sessionName,
|
||||
sessionLease: runtime.sessionPath
|
||||
? runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
||||
: false,
|
||||
managed: managedWorktrees.has(runtime.worktreePath),
|
||||
};
|
||||
let directoryPromotion;
|
||||
let previousReference;
|
||||
let reservedSessionPath;
|
||||
let manifestStateChanged = false;
|
||||
try {
|
||||
await mkdir(sessionDir, {
|
||||
recursive: true,
|
||||
mode: SESSION_DIRECTORY_MODE,
|
||||
});
|
||||
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
|
||||
if (runtime.sessionPath) {
|
||||
reservedSessionPath = await projectQuickSessionPath(
|
||||
quickDir,
|
||||
sessionDir,
|
||||
runtime.sessionPath,
|
||||
);
|
||||
reserveSessionPath(reservedSessionPath, runtime);
|
||||
}
|
||||
directoryPromotion = await promoteQuickSessionDirectory(
|
||||
quickDir,
|
||||
sessionDir,
|
||||
);
|
||||
runtime.sessionDir = sessionDir;
|
||||
if (reservedSessionPath) runtime.sessionPath = reservedSessionPath;
|
||||
const state = await adapter.send({ type: "get_state" });
|
||||
await updateRuntimeIdentity(runtime, state, {
|
||||
adapter,
|
||||
persist: false,
|
||||
indexSession: false,
|
||||
});
|
||||
// A changed identity still needs a lease through manifest commit.
|
||||
if (runtime.sessionPath && runtime.sessionPath !== reservedSessionPath)
|
||||
reserveSessionPath(runtime.sessionPath, runtime);
|
||||
if (
|
||||
previous.sessionPath &&
|
||||
previous.sessionPath !== runtime.sessionPath &&
|
||||
runtimesBySessionPath.get(previous.sessionPath) === runtime
|
||||
)
|
||||
runtimesBySessionPath.delete(previous.sessionPath);
|
||||
runtime.ephemeral = false;
|
||||
managedWorktrees.add(runtime.worktreePath);
|
||||
manifestStateChanged = true;
|
||||
try {
|
||||
previousReference = await readFile(
|
||||
path.join(sessionDir, SESSION_REFERENCE_FILE),
|
||||
"utf8",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
await persistSessionReference(
|
||||
sessionDir,
|
||||
runtime.sessionPath,
|
||||
runtime.worktreePath,
|
||||
);
|
||||
await persistWorkspace();
|
||||
if (runtime.sessionPath)
|
||||
runtimesBySessionPath.set(runtime.sessionPath, runtime);
|
||||
if (reservedSessionPath)
|
||||
releaseSessionReservation(reservedSessionPath, runtime);
|
||||
if (runtime.sessionPath !== reservedSessionPath)
|
||||
releaseSessionReservation(runtime.sessionPath, runtime);
|
||||
await directoryPromotion.finalize();
|
||||
publishWorkspace("runtime_promoted", runtime, {
|
||||
runtime: publicRuntime(runtime),
|
||||
});
|
||||
return publicRuntime(runtime);
|
||||
} catch (error) {
|
||||
if (directoryPromotion)
|
||||
await restoreSessionReference(sessionDir, previousReference).catch(
|
||||
() => {},
|
||||
);
|
||||
if (reservedSessionPath)
|
||||
releaseSessionReservation(reservedSessionPath, runtime);
|
||||
if (runtime.sessionPath !== reservedSessionPath)
|
||||
releaseSessionReservation(runtime.sessionPath, runtime);
|
||||
if (
|
||||
runtime.sessionPath &&
|
||||
runtimesBySessionPath.get(runtime.sessionPath) === runtime
|
||||
)
|
||||
runtimesBySessionPath.delete(runtime.sessionPath);
|
||||
runtime.sessionDir = previous.sessionDir;
|
||||
runtime.sessionPath = previous.sessionPath;
|
||||
runtime.sessionId = previous.sessionId;
|
||||
runtime.sessionName = previous.sessionName;
|
||||
runtime.ephemeral = true;
|
||||
if (previous.sessionLease && previous.sessionPath)
|
||||
runtimesBySessionPath.set(previous.sessionPath, runtime);
|
||||
if (previous.managed) managedWorktrees.add(runtime.worktreePath);
|
||||
else managedWorktrees.delete(runtime.worktreePath);
|
||||
if (manifestStateChanged) await persistWorkspace().catch(() => {});
|
||||
if (directoryPromotion) await directoryPromotion.rollback();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const tracked = promotion.finally(() => {
|
||||
runtime.promoting = false;
|
||||
runtime.promotionPromise = undefined;
|
||||
});
|
||||
runtime.operationQueue = tracked.catch(() => {});
|
||||
runtime.promotionPromise = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
async function refreshAfterLegacySessionChange(
|
||||
runtime,
|
||||
response,
|
||||
@@ -1085,6 +1330,7 @@ export function createAgentRegistry({
|
||||
[...managedWorktrees].map((worktreePath) => [worktreePath, []]),
|
||||
);
|
||||
for (const runtime of runtimesByRuntimeId.values()) {
|
||||
if (runtime.ephemeral) continue;
|
||||
let group = grouped.get(runtime.worktreePath);
|
||||
if (!group) {
|
||||
group = [];
|
||||
@@ -1160,6 +1406,15 @@ export function createAgentRegistry({
|
||||
});
|
||||
await restoreRecords(records);
|
||||
}
|
||||
if (
|
||||
!loaded.issue &&
|
||||
![...runtimesByAgentId.values()].some((runtime) => runtime.adapter)
|
||||
) {
|
||||
await openRuntime({
|
||||
worktreePath: canonicalHomeWorktree,
|
||||
legacyDefault: true,
|
||||
});
|
||||
}
|
||||
return this.listAgents()[0];
|
||||
},
|
||||
async selectWorktree(worktreePath) {
|
||||
@@ -1196,24 +1451,47 @@ export function createAgentRegistry({
|
||||
async createQuickRuntime(worktreePath) {
|
||||
return openRuntime({ worktreePath, ephemeral: true });
|
||||
},
|
||||
promoteQuickRuntime,
|
||||
async openSessionRuntime(worktreePath, sessionPath) {
|
||||
return openRuntime({ worktreePath, sessionPath });
|
||||
},
|
||||
closeSessionRuntime: closeRuntime,
|
||||
async closeQuickRuntime(runtimeId) {
|
||||
const runtime = getRuntime(runtimeId);
|
||||
if (!runtime.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);
|
||||
},
|
||||
async getModelCatalog() {
|
||||
const runtime = [...runtimesByAgentId.values()].find(
|
||||
(candidate) => candidate.adapter,
|
||||
const candidates = [...runtimesByAgentId.values()].filter(
|
||||
(runtime) =>
|
||||
runtime.adapter &&
|
||||
!runtime.stopped &&
|
||||
!["closing", "stopped", "failed"].includes(runtime.state),
|
||||
);
|
||||
if (!runtime?.adapter) return [];
|
||||
const response = await enqueue(runtime, () =>
|
||||
runtime.adapter.send({ type: "get_available_models" }),
|
||||
);
|
||||
return response?.data?.models ?? [];
|
||||
let lastError;
|
||||
for (const runtime of candidates) {
|
||||
const adapter = runtime.adapter;
|
||||
try {
|
||||
const response = await enqueue(runtime, () =>
|
||||
adapter.send({ type: "get_available_models" }),
|
||||
);
|
||||
if (response?.success !== true || !Array.isArray(response?.data?.models)) {
|
||||
continue;
|
||||
}
|
||||
lastSuccessfulModelCatalog = response.data.models;
|
||||
return response.data.models;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (lastSuccessfulModelCatalog) return lastSuccessfulModelCatalog;
|
||||
if (lastError) throw lastError;
|
||||
return [];
|
||||
},
|
||||
listAgents() {
|
||||
return [...runtimesByAgentId.values()]
|
||||
@@ -1293,7 +1571,9 @@ export function createAgentRegistry({
|
||||
},
|
||||
getWorkspace: workspace,
|
||||
getWorkspaceSummary() {
|
||||
const runtimes = [...runtimesByRuntimeId.values()];
|
||||
const runtimes = [...runtimesByRuntimeId.values()].filter(
|
||||
(runtime) => !runtime.ephemeral,
|
||||
);
|
||||
return {
|
||||
bridgeInstanceId,
|
||||
latestSeq: workspaceSequence,
|
||||
@@ -1449,7 +1729,8 @@ export function createAgentRegistry({
|
||||
},
|
||||
async route(agentId, operation, payload = {}) {
|
||||
const runtime = getAgent(agentId);
|
||||
if (operation === "abort") return runtime.adapter.send({ type: "abort" });
|
||||
if (operation === "abort" && !runtime.promoting)
|
||||
return runtime.adapter.send({ type: "abort" });
|
||||
return enqueue(runtime, async () => {
|
||||
if (operation === "switch_session") {
|
||||
if (runtime.state === "streaming")
|
||||
@@ -1525,7 +1806,7 @@ export function createAgentRegistry({
|
||||
async stop() {
|
||||
stopping = true;
|
||||
while (pendingOpenOperations.size > 0)
|
||||
await Promise.allSettled([...pendingOpenOperations]);
|
||||
await Promise.allSettled(pendingOpenOperations);
|
||||
const runtimes = [...runtimesByRuntimeId.values()];
|
||||
await Promise.all(runtimes.map((runtime) => runtime.operationQueue));
|
||||
await Promise.all(
|
||||
@@ -1541,5 +1822,4 @@ export function createAgentRegistry({
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionDirectoryMode = SESSION_DIRECTORY_MODE;
|
||||
export const sessionDirectoryPath = sessionDirectoryFor;
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { startBridgeService } from "./service.js";
|
||||
|
||||
export async function startBridgeDaemon(options) {
|
||||
return startBridgeService(options);
|
||||
}
|
||||
|
||||
export async function runBridgeDaemon(options) {
|
||||
const daemon = await startBridgeDaemon(options);
|
||||
const daemon = await startBridgeService(options);
|
||||
let stopping = false;
|
||||
const stop = async () => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
await daemon.close();
|
||||
};
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ socketPath: daemon.socketPath })}\n`,
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify({ socketPath: daemon.socketPath })}\n`);
|
||||
await new Promise((resolve) => {
|
||||
const shutdown = async () => {
|
||||
await stop();
|
||||
|
||||
@@ -67,7 +67,8 @@ function normalizePiEvent(event) {
|
||||
case "extension_ui_request":
|
||||
return { type: "extension_ui_request", data: { event } };
|
||||
case "extension_error":
|
||||
// Extension errors are diagnostics; the Pi process can still be healthy.
|
||||
case "thinking_level_changed":
|
||||
// Informational Pi notifications do not change runtime health.
|
||||
return { type: "transcript", data: { event } };
|
||||
default:
|
||||
return undefined;
|
||||
@@ -209,6 +210,7 @@ export function startPiRpcAdapter({
|
||||
"unsupported_event",
|
||||
`Pi RPC emitted unsupported event: ${event.type}`,
|
||||
),
|
||||
{ terminal: false },
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -229,7 +231,6 @@ export function startPiRpcAdapter({
|
||||
"frame_too_large",
|
||||
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
|
||||
),
|
||||
{ terminal: false },
|
||||
);
|
||||
const consumeStdout = (text) => {
|
||||
let remaining = text;
|
||||
@@ -303,10 +304,7 @@ export function startPiRpcAdapter({
|
||||
send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) {
|
||||
if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
|
||||
return Promise.reject(
|
||||
new PiRpcError(
|
||||
"invalid_command",
|
||||
"Pi RPC command must include a type",
|
||||
),
|
||||
new PiRpcError("invalid_command", "Pi RPC command must include a type"),
|
||||
);
|
||||
}
|
||||
if (Object.hasOwn(commandInput, "id")) {
|
||||
@@ -382,10 +380,7 @@ export function startPiRpcAdapter({
|
||||
if (closed) return exited;
|
||||
intentionalStop = true;
|
||||
rejectPending(
|
||||
new PiRpcError(
|
||||
"child_stopped",
|
||||
"Pi RPC child was stopped by the bridge",
|
||||
),
|
||||
new PiRpcError("child_stopped", "Pi RPC child was stopped by the bridge"),
|
||||
);
|
||||
child.kill("SIGTERM");
|
||||
return exited;
|
||||
|
||||
@@ -66,5 +66,3 @@ export async function startBridgeServer({
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const runtimeDirectoryMode = RUNTIME_DIRECTORY_MODE;
|
||||
|
||||
@@ -54,6 +54,10 @@ export async function startBridgeService({
|
||||
request.payload.worktreePath,
|
||||
),
|
||||
};
|
||||
case "promote_quick_runtime":
|
||||
return {
|
||||
runtime: await registry.promoteQuickRuntime(request.payload.runtimeId),
|
||||
};
|
||||
case "open_session_runtime":
|
||||
return {
|
||||
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 &&
|
||||
(!Array.isArray(value.directories) ||
|
||||
!value.directories.every(
|
||||
(directory) =>
|
||||
typeof directory === "string" && path.isAbsolute(directory),
|
||||
(directory) => typeof directory === "string" && path.isAbsolute(directory),
|
||||
)))
|
||||
)
|
||||
throw new Error("workspace manifest has an unsupported or invalid shape");
|
||||
@@ -54,9 +53,7 @@ function normalizeManifest(value) {
|
||||
for (const runtime of value.runtimes) directories.add(runtime.worktreePath);
|
||||
for (const runtime of value.runtimes) {
|
||||
if (runtimeIds.has(runtime.runtimeId))
|
||||
throw new Error(
|
||||
`workspace manifest repeats runtime ${runtime.runtimeId}`,
|
||||
);
|
||||
throw new Error(`workspace manifest repeats runtime ${runtime.runtimeId}`);
|
||||
runtimeIds.add(runtime.runtimeId);
|
||||
if (runtime.sessionPath) {
|
||||
if (sessionPaths.has(runtime.sessionPath))
|
||||
@@ -85,9 +82,7 @@ export function createWorkspaceStore(sessionRoot) {
|
||||
await chmod(sessionRoot, DIRECTORY_MODE);
|
||||
try {
|
||||
return {
|
||||
manifest: normalizeManifest(
|
||||
JSON.parse(await readFile(filePath, "utf8")),
|
||||
),
|
||||
manifest: normalizeManifest(JSON.parse(await readFile(filePath, "utf8"))),
|
||||
migrating: false,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -148,6 +143,3 @@ export function createWorkspaceStore(sessionRoot) {
|
||||
|
||||
return { filePath, load, save };
|
||||
}
|
||||
|
||||
export const workspaceManifestVersion = WORKSPACE_VERSION;
|
||||
export const workspaceManifestFile = WORKSPACE_FILE;
|
||||
|
||||
@@ -10,23 +10,17 @@ function flag(args, name) {
|
||||
|
||||
async function run() {
|
||||
const args = process.argv.slice(2);
|
||||
const socketPath = flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
|
||||
const agentId = flag(args, "--agent");
|
||||
const socketPath =
|
||||
flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
|
||||
if (!socketPath)
|
||||
throw new Error("Usage: pi-status-bridge-noctalia-relay --socket <path> [--agent <id>]");
|
||||
throw new Error("Usage: pi-status-bridge-noctalia-relay --socket <path>");
|
||||
const client = await connectLocalClient({ socketPath });
|
||||
let agent;
|
||||
if (agentId) {
|
||||
const agents = await client.request("list_agents");
|
||||
agent = agents.agents.find((candidate) => candidate.id === agentId);
|
||||
if (!agent) throw new Error(`unknown agent: ${agentId}`);
|
||||
}
|
||||
const relay = createNoctaliaStateRelay({
|
||||
client,
|
||||
...(agent ? { agent } : {}),
|
||||
onState: (state) => void publishNoctaliaState(state).catch((error) =>
|
||||
process.stderr.write(`${error.message}\n`),
|
||||
),
|
||||
onState: (state) =>
|
||||
void publishNoctaliaState(state).catch((error) =>
|
||||
process.stderr.write(`${error.message}\n`),
|
||||
),
|
||||
});
|
||||
await relay.start();
|
||||
await new Promise((resolve) => {
|
||||
@@ -39,6 +33,8 @@ async function run() {
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`);
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import path from "node:path";
|
||||
|
||||
function detailForSummary(summary) {
|
||||
return [
|
||||
`${summary.openCount} open`,
|
||||
@@ -30,75 +28,30 @@ function summaryState(summary) {
|
||||
};
|
||||
}
|
||||
|
||||
function legacyDetail(state) {
|
||||
switch (state) {
|
||||
case "streaming": return "Streaming";
|
||||
case "recovering": return "Recovering Pi session";
|
||||
case "failed": return "Recovery failed";
|
||||
case "error": return "Bridge error";
|
||||
default: return "Idle";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays workspace-wide presentation state to Noctalia. Passing `agent` keeps
|
||||
* the former agent-scoped behavior for external compatibility callers only.
|
||||
*/
|
||||
export function createNoctaliaStateRelay({ client, agent, onState, pollMs = 2_000 }) {
|
||||
export function createNoctaliaStateRelay({ client, onState, pollMs = 2_000 }) {
|
||||
if (!client || typeof client.request !== "function")
|
||||
throw new TypeError("client must support request");
|
||||
if (agent && (!agent.id || !agent.worktreePath))
|
||||
throw new TypeError("agent id and worktreePath are required");
|
||||
if (!agent && (!Number.isSafeInteger(pollMs) || pollMs < 250))
|
||||
if (!Number.isSafeInteger(pollMs) || pollMs < 250)
|
||||
throw new TypeError("pollMs must be an integer of at least 250ms");
|
||||
if (typeof onState !== "function") throw new TypeError("onState must be a function");
|
||||
if (typeof onState !== "function")
|
||||
throw new TypeError("onState must be a function");
|
||||
|
||||
let unsubscribe = () => {};
|
||||
let timer;
|
||||
let stopped = false;
|
||||
const publishLegacy = (state, attentionCount) => onState({
|
||||
state,
|
||||
projectLabel: path.basename(agent.worktreePath) || agent.worktreePath,
|
||||
attentionCount,
|
||||
detail: legacyDetail(state),
|
||||
openCount: 1,
|
||||
workingCount: state === "streaming" ? 1 : 0,
|
||||
recoveringCount: state === "recovering" ? 1 : 0,
|
||||
errorCount: ["error", "failed"].includes(state) ? 1 : 0,
|
||||
});
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const summary = await client.request("get_workspace_summary");
|
||||
if (!stopped) onState(summaryState(summary));
|
||||
} finally {
|
||||
if (!stopped) timer = setTimeout(() => void refresh(), pollMs);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async start() {
|
||||
if (agent) {
|
||||
if (typeof client.subscribe !== "function")
|
||||
throw new TypeError("legacy agent relay requires client.subscribe");
|
||||
const response = await client.request("get_state", { agentId: agent.id });
|
||||
let state = response?.data?.isStreaming ? "streaming" : "idle";
|
||||
let attentionCount = 0;
|
||||
publishLegacy(state, attentionCount);
|
||||
unsubscribe = await client.subscribe(agent.id, 0, (event) => {
|
||||
if (event.type === "agent_state" && typeof event.data?.state === "string") state = event.data.state;
|
||||
if (event.type === "queue") attentionCount = (event.data?.event?.steering?.length ?? 0) + (event.data?.event?.followUp?.length ?? 0);
|
||||
if (event.type === "extension_ui_request") attentionCount = Math.max(attentionCount, 1);
|
||||
publishLegacy(state, attentionCount);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const summary = await client.request("get_workspace_summary");
|
||||
if (!stopped) onState(summaryState(summary));
|
||||
} finally {
|
||||
if (!stopped) timer = setTimeout(() => void refresh(), pollMs);
|
||||
}
|
||||
};
|
||||
await refresh();
|
||||
},
|
||||
start: refresh,
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
unsubscribe = () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const requestOperations = new Map([
|
||||
["get_model_catalog", { agent: false, payload: "none" }],
|
||||
["create_session_runtime", { agent: false, payload: "worktree" }],
|
||||
["create_quick_runtime", { agent: false, payload: "worktree" }],
|
||||
["promote_quick_runtime", { agent: false, payload: "runtime" }],
|
||||
["open_session_runtime", { agent: false, payload: "worktreeSession" }],
|
||||
["close_session_runtime", { agent: false, payload: "runtime" }],
|
||||
["close_quick_runtime", { agent: false, payload: "runtime" }],
|
||||
|
||||
@@ -78,6 +78,60 @@ test("starts only the home agent and creates other worktree agents on explicit s
|
||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||
});
|
||||
|
||||
test("model catalog retries unsuccessful responses and falls back to last success", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
await registry.selectWorktree(worktrees.feature);
|
||||
const expected = [{ provider: "provider", id: "family/model" }];
|
||||
const rejected = [{ provider: "wrong", id: "must-not-be-cached" }];
|
||||
const sends = fixture.calls.map(({ adapter }) => adapter.send.bind(adapter));
|
||||
let homeCatalogCalls = 0;
|
||||
let featureCatalogCalls = 0;
|
||||
fixture.calls[0].adapter.send = async (command, options) => {
|
||||
if (command.type === "get_available_models") {
|
||||
homeCatalogCalls += 1;
|
||||
return {
|
||||
type: "response",
|
||||
success: false,
|
||||
data: { models: rejected },
|
||||
};
|
||||
}
|
||||
return sends[0](command, options);
|
||||
};
|
||||
fixture.calls[1].adapter.send = async (command, options) => {
|
||||
if (command.type === "get_available_models") {
|
||||
featureCatalogCalls += 1;
|
||||
return { type: "response", success: true, data: { models: expected } };
|
||||
}
|
||||
return sends[1](command, options);
|
||||
};
|
||||
|
||||
assert.deepEqual(await registry.getModelCatalog(), expected);
|
||||
assert.equal(homeCatalogCalls, 1);
|
||||
assert.equal(featureCatalogCalls, 1);
|
||||
fixture.calls[1].adapter.send = async (command, options) => {
|
||||
if (command.type === "get_available_models") {
|
||||
featureCatalogCalls += 1;
|
||||
return {
|
||||
type: "response",
|
||||
success: true,
|
||||
data: { models: "malformed" },
|
||||
};
|
||||
}
|
||||
return sends[1](command, options);
|
||||
};
|
||||
assert.deepEqual(await registry.getModelCatalog(), expected);
|
||||
assert.equal(homeCatalogCalls, 2);
|
||||
assert.equal(featureCatalogCalls, 2);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("sends abort without waiting behind a blocked prompt", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
@@ -108,7 +162,7 @@ test("sends abort without waiting behind a blocked prompt", async () => {
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("keeps a runtime healthy after a non-terminal adapter diagnostic", async () => {
|
||||
test("keeps a runtime healthy after an unknown non-terminal Pi notification", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
@@ -119,7 +173,10 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async ()
|
||||
await registry.start();
|
||||
|
||||
fixture.calls[0].options.onError(
|
||||
{ code: "frame_too_large", message: "Pi RPC frame exceeds 32 bytes" },
|
||||
{
|
||||
code: "unsupported_event",
|
||||
message: "Pi RPC emitted unsupported event: future_pi_notification",
|
||||
},
|
||||
{ terminal: false },
|
||||
);
|
||||
|
||||
@@ -130,6 +187,31 @@ test("keeps a runtime healthy after a non-terminal adapter diagnostic", async ()
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("keeps explicit transport failures terminal", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
|
||||
fixture.calls[0].options.onError({
|
||||
code: "invalid_json",
|
||||
message: "Pi RPC emitted invalid JSON",
|
||||
});
|
||||
|
||||
const runtime = registry.getWorkspace().directories[0].runtimes[0];
|
||||
assert.equal(runtime.state, "error");
|
||||
assert.equal(runtime.attention, true);
|
||||
assert.deepEqual(runtime.error, {
|
||||
code: "invalid_json",
|
||||
message: "Pi RPC emitted invalid JSON",
|
||||
});
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("stops agents created during shutdown and rejects new selections", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { startBridgeDaemon } from "../src/bridge/daemon.js";
|
||||
import { createNoctaliaStateRelay } from "../src/client/noctalia-relay.js";
|
||||
|
||||
test("starts a bridge daemon around the local bridge service", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-daemon-"));
|
||||
const home = join(root, "home");
|
||||
await mkdir(home);
|
||||
const daemon = await startBridgeDaemon({ homeWorktree: home, runtimeDir: join(root, "runtime"), sessionRoot: join(root, "sessions"), startAdapter: () => ({ send: async () => ({ type: "response", success: true }), respondToExtension: () => {}, stop: async () => {} }) });
|
||||
try { assert.match(daemon.socketPath, /bridge\.sock$/); assert.equal(daemon.listAgents()[0].worktreePath, home); }
|
||||
finally { await daemon.close(); }
|
||||
});
|
||||
|
||||
test("relays aggregate workspace summary as compact Noctalia state", async () => {
|
||||
const updates = [];
|
||||
const relay = createNoctaliaStateRelay({
|
||||
client: { request: async (operation) => { assert.equal(operation, "get_workspace_summary"); return { openCount: 3, workingCount: 1, attentionCount: 2, recoveringCount: 0, errorCount: 1 }; } },
|
||||
onState: (state) => updates.push(state), pollMs: 250,
|
||||
});
|
||||
await relay.start(); relay.stop();
|
||||
assert.deepEqual(updates, [{ state: "error", projectLabel: "3 sessions", attentionCount: 2, detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1 }]);
|
||||
});
|
||||
|
||||
test("retains agent-scoped relay behavior only when explicitly requested", async () => {
|
||||
let listener; const updates = [];
|
||||
const relay = createNoctaliaStateRelay({ client: { request: async () => ({ data: { isStreaming: false } }), subscribe: async (_agentId, _cursor, callback) => { listener = callback; return () => {}; } }, agent: { id: "agent-1", worktreePath: "/worktrees/feature" }, onState: (state) => updates.push(state) });
|
||||
await relay.start(); listener({ type: "agent_state", data: { state: "streaming" } });
|
||||
assert.equal(updates.at(-1).state, "streaming"); assert.equal(updates.at(-1).projectLabel, "feature");
|
||||
});
|
||||
@@ -177,9 +177,16 @@ test("closing removes desired-open intent, preserves history, and refuses direct
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("restores durable runtime IDs with two-at-a-time concurrency and preserves command isolation", async () => {
|
||||
test("restores durable runtime IDs and history labels with two-at-a-time concurrency", async () => {
|
||||
const paths = await fixture();
|
||||
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one");
|
||||
await writeFile(
|
||||
firstPath,
|
||||
`${await readFile(firstPath, "utf8")}${JSON.stringify({
|
||||
type: "message",
|
||||
message: { role: "user", content: "Hydrate this session label" },
|
||||
})}\n`,
|
||||
);
|
||||
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "two");
|
||||
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "three");
|
||||
const firstAdapters = adapterFactory({
|
||||
@@ -195,6 +202,7 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
|
||||
if (!initial.sessionPath)
|
||||
await firstRegistry.closeSessionRuntime(initial.runtimeId);
|
||||
const one = await firstRegistry.openSessionRuntime(paths.home, firstPath);
|
||||
assert.equal(one.label, "Hydrate this session label");
|
||||
const two = await firstRegistry.openSessionRuntime(paths.home, secondPath);
|
||||
const three = await firstRegistry.openSessionRuntime(paths.home, thirdPath);
|
||||
await firstRegistry.stop();
|
||||
@@ -241,6 +249,14 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
|
||||
new Set([one.runtimeId, two.runtimeId, three.runtimeId]),
|
||||
);
|
||||
assert.equal(maxActiveStarts, 2);
|
||||
assert.equal(
|
||||
restored
|
||||
.getWorkspace()
|
||||
.directories[0].runtimes.find(
|
||||
(runtime) => runtime.runtimeId === one.runtimeId,
|
||||
)?.label,
|
||||
"Hydrate this session label",
|
||||
);
|
||||
assert.ok(
|
||||
restored
|
||||
.listAgents()
|
||||
@@ -987,7 +1003,7 @@ test("closing retains a saved-session lease until the child stops", async () =>
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("first migration retains a missing legacy session as failed dormant intent", async () => {
|
||||
test("first migration retains missing intent and starts an idle home runtime", async () => {
|
||||
const paths = await fixture();
|
||||
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
|
||||
const missing = join(sessionDir, "missing.jsonl");
|
||||
@@ -1003,14 +1019,18 @@ test("first migration retains a missing legacy session as failed dormant intent"
|
||||
startAdapter: adapters.startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
const runtime = registry.getWorkspace().directories[0].runtimes[0];
|
||||
assert.equal(runtime.state, "failed");
|
||||
assert.equal(runtime.sessionPath, missing);
|
||||
assert.equal(adapters.calls.length, 0);
|
||||
const runtimes = registry.getWorkspace().directories[0].runtimes;
|
||||
const failed = runtimes.find((runtime) => runtime.state === "failed");
|
||||
const live = runtimes.find((runtime) => runtime.state === "idle");
|
||||
assert.equal(failed.sessionPath, missing);
|
||||
assert.equal(live.worktreePath, paths.home);
|
||||
assert.equal(adapters.calls.length, 1);
|
||||
const manifest = await readJson(
|
||||
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
||||
);
|
||||
assert.equal(manifest.runtimes[0].sessionPath, missing);
|
||||
assert.ok(
|
||||
manifest.runtimes.some((runtime) => runtime.sessionPath === missing),
|
||||
);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
@@ -1198,7 +1218,7 @@ test("snapshot rejects results from an adapter replaced during recovery", async
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("closing the last runtime persists a zero-runtime restart", async () => {
|
||||
test("restoration with no live adapter starts one idle home runtime", async () => {
|
||||
const paths = await fixture();
|
||||
const firstAdapters = adapterFactory();
|
||||
const first = createAgentRegistry({
|
||||
@@ -1216,8 +1236,10 @@ test("closing the last runtime persists a zero-runtime restart", async () => {
|
||||
startAdapter: restoredAdapters.startAdapter,
|
||||
});
|
||||
await restored.start();
|
||||
assert.equal(restored.getWorkspaceSummary().openCount, 0);
|
||||
assert.equal(restoredAdapters.calls.length, 0);
|
||||
assert.equal(restored.getWorkspaceSummary().openCount, 1);
|
||||
assert.equal(restoredAdapters.calls.length, 1);
|
||||
assert.equal(restored.listAgents()[0].worktreePath, paths.home);
|
||||
assert.equal(restored.listAgents()[0].state, "idle");
|
||||
await restored.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
id: initialCommand.id,
|
||||
});
|
||||
await assert.rejects(
|
||||
adapter.send({ type: "get_session_stats" }),
|
||||
/timed out/,
|
||||
);
|
||||
await assert.rejects(adapter.send({ type: "get_session_stats" }), /timed out/);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
@@ -208,6 +205,49 @@ test("forwards extension responses without replacing Pi's request ID", async ()
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("normalizes thinking changes and treats every unknown Pi notification as non-terminal", async () => {
|
||||
const fixture = createFixture();
|
||||
const events = [];
|
||||
const errors = [];
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
onEvent: (event) => events.push(event),
|
||||
onError: (error, metadata) => errors.push({ error, metadata }),
|
||||
});
|
||||
const thinkingChange = {
|
||||
type: "thinking_level_changed",
|
||||
level: "high",
|
||||
};
|
||||
fixture.child.stdout.write(`${JSON.stringify(thinkingChange)}\n`);
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "future_pi_notification", detail: "new" })}\n`,
|
||||
);
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "fatal_pi_notification", terminal: true })}\n`,
|
||||
);
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
seq: 1,
|
||||
type: "transcript",
|
||||
data: { event: thinkingChange },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
errors.map(({ error, metadata }) => ({
|
||||
code: error.code,
|
||||
terminal: metadata.terminal,
|
||||
})),
|
||||
[
|
||||
{ code: "unsupported_event", terminal: false },
|
||||
{ code: "unsupported_event", terminal: false },
|
||||
],
|
||||
);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("discards a fragmented oversized frame before processing the next frame", async () => {
|
||||
const fixture = createFixture();
|
||||
const errors = [];
|
||||
@@ -217,7 +257,7 @@ test("discards a fragmented oversized frame before processing the next frame", a
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
maxFrameBytes: 32,
|
||||
onError: (error) => errors.push(error),
|
||||
onError: (error, metadata) => errors.push({ error, metadata }),
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
const oversized = JSON.stringify({
|
||||
@@ -230,8 +270,11 @@ test("discards a fragmented oversized frame before processing the next frame", a
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
errors.map((error) => error.code),
|
||||
["frame_too_large"],
|
||||
errors.map(({ error, metadata }) => ({
|
||||
code: error.code,
|
||||
terminal: metadata?.terminal !== false,
|
||||
})),
|
||||
[{ code: "frame_too_large", terminal: true }],
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
|
||||
@@ -125,6 +125,7 @@ test("accepts additive multi-session runtime operations and numeric workspace cu
|
||||
{ op: "get_workspace_summary" },
|
||||
{ op: "create_session_runtime", payload: { worktreePath } },
|
||||
{ op: "create_quick_runtime", payload: { worktreePath } },
|
||||
{ op: "promote_quick_runtime", payload: { runtimeId: "runtime-1" } },
|
||||
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
|
||||
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
|
||||
{ op: "close_quick_runtime", payload: { runtimeId: "runtime-1" } },
|
||||
|
||||
+365
-12
@@ -2,46 +2,209 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("Quick Pane polls snapshots, renders answers, and gates handoff", async () => {
|
||||
test("Quick Pane polls snapshots, renders answers, and promotes runtime without a handoff", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /get_session_runtime_snapshot/);
|
||||
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
|
||||
assert.match(source, /<ReactMarkdown remarkPlugins=\{\[remarkGfm\]\}>/);
|
||||
assert.match(source, /className="quick-pane-notice escalation"/);
|
||||
assert.match(source, /className="quick-pane-assistant-footer"/);
|
||||
assert.match(source, /Continue in full session/);
|
||||
const toolbar = source.slice(
|
||||
source.indexOf('<header className="quick-pane-toolbar">'),
|
||||
source.indexOf(
|
||||
"</header>",
|
||||
source.indexOf('<header className="quick-pane-toolbar">'),
|
||||
),
|
||||
);
|
||||
assert.doesNotMatch(toolbar, /continueFull|Continue/);
|
||||
assert.match(
|
||||
source,
|
||||
/<pre style=\{\{ whiteSpace: "pre-wrap" \}\}>\{answer\}<\/pre>/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/\{\(escalation \|\| error\) && \(\s*<button onClick=\{continueFull\}>Continue in full session<\/button>\s*\)\}/,
|
||||
/await invoke<\{ runtime: Runtime \}>\("promote_quick_runtime", \{\s+runtimeId: current\.runtimeId,/,
|
||||
);
|
||||
assert.doesNotMatch(source, /Quick Pane transcript:/);
|
||||
assert.doesNotMatch(source, /create_session_runtime/);
|
||||
assert.doesNotMatch(source, /maximumSeconds|Quick time budget/);
|
||||
});
|
||||
|
||||
test("main Settings owns Quick Pane configuration while pane keeps request controls", async () => {
|
||||
const [pane, app, persistence] = await Promise.all([
|
||||
test("main Settings uses shared catalog selectors with provider derived from models", async () => {
|
||||
const [pane, app, catalog, persistence] = await Promise.all([
|
||||
readFile(
|
||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(new URL("../ui/src/App.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../ui/src/model-catalog.ts", import.meta.url), "utf8"),
|
||||
readFile(
|
||||
new URL("../ui/src/workspace/persistence.ts", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
assert.match(app, /load_ui_state/);
|
||||
assert.match(app, /save_ui_state/);
|
||||
assert.doesNotMatch(catalog, /get_model_catalog/);
|
||||
assert.match(app, /create_quick_runtime/);
|
||||
assert.match(app, /get_session_runtime_snapshot/);
|
||||
assert.match(app, /close_quick_runtime/);
|
||||
assert.match(app, /label="Quick model"/);
|
||||
assert.match(app, /label="Follow-up model"/);
|
||||
assert.match(app, /<ModelSelector/);
|
||||
assert.doesNotMatch(app, /Quick provider\s*<input/);
|
||||
assert.doesNotMatch(app, /Follow-up provider\s*<input/);
|
||||
assert.match(app, /provider: identity\.provider/);
|
||||
assert.match(app, /followUpProvider: identity\.provider/);
|
||||
assert.match(app, /Save Quick Pane settings/);
|
||||
assert.match(pane, /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, /Loading models…/);
|
||||
assert.match(pane, /catalogStatus !== "ready"/);
|
||||
assert.doesNotMatch(pane, /showSettings/);
|
||||
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/);
|
||||
});
|
||||
|
||||
test("Quick Pane keeps slash-containing model IDs atomic and renews only on pane show", async () => {
|
||||
const [pane, catalog] = await Promise.all([
|
||||
readFile(
|
||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(new URL("../ui/src/model-catalog.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(pane, /useState<ModelIdentity>/);
|
||||
assert.match(pane, /provider: submittedModel\.provider/);
|
||||
assert.match(pane, /modelId: submittedModel\.modelId/);
|
||||
assert.doesNotMatch(pane, /selectedModel\.split/);
|
||||
assert.match(
|
||||
pane,
|
||||
/listen\("quick-pane-shown"[\s\S]*?void renewChatOnShow\(\)/,
|
||||
);
|
||||
assert.match(
|
||||
pane,
|
||||
/onFocusChanged[\s\S]*?if \(event\.payload\) focusInput\(\);/,
|
||||
);
|
||||
assert.match(pane, /const CHAT_RENEWAL_MS = 180_000/);
|
||||
assert.match(pane, /Date\.now\(\) - lastActivity > CHAT_RENEWAL_MS/);
|
||||
assert.match(pane, /event\.ctrlKey && event\.key\.toLowerCase\(\) === "n"/);
|
||||
assert.match(pane, /event\.preventDefault\(\)/);
|
||||
assert.doesNotMatch(
|
||||
pane,
|
||||
/setInterval[^\n]*CHAT_RENEWAL_MS|setTimeout[^\n]*CHAT_RENEWAL_MS/,
|
||||
);
|
||||
assert.match(
|
||||
catalog,
|
||||
/JSON\.stringify\(\[identity\.provider, identity\.modelId\]\)/,
|
||||
);
|
||||
assert.match(catalog, /JSON\.parse\(value\)/);
|
||||
});
|
||||
|
||||
test("Quick Pane dismiss and mutually exclusive visibility use native window state", async () => {
|
||||
const [pane, nativeSource] = await Promise.all([
|
||||
readFile(
|
||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(pane, /invoke\("dismiss_quick_pane"\)/);
|
||||
assert.doesNotMatch(pane, /getCurrentWindow\(\)\.hide\(\)/);
|
||||
const dismissPane = pane.slice(
|
||||
pane.indexOf("const dismissPane"),
|
||||
pane.indexOf("const clearChatState"),
|
||||
);
|
||||
assert.doesNotMatch(dismissPane, /closeRuntime|clearRuntime|prepareRuntime/);
|
||||
assert.match(nativeSource, /fn dismiss_quick_pane\(/);
|
||||
assert.match(
|
||||
nativeSource,
|
||||
/dismiss_quick_pane,[\s\S]*create_session_runtime/,
|
||||
);
|
||||
assert.doesNotMatch(nativeSource, /ManagedWindowVisibility|WindowVisibility/);
|
||||
assert.match(nativeSource, /window\.is_visible\(\)/);
|
||||
|
||||
const mainShow = nativeSource.slice(
|
||||
nativeSource.indexOf("fn apply_main_window_action"),
|
||||
nativeSource.indexOf("fn get_or_create_quick_pane"),
|
||||
);
|
||||
const mainHideQuick = mainShow.indexOf("quick_pane.hide()");
|
||||
const showMain = mainShow.indexOf("window.show()");
|
||||
assert.ok(mainShow.indexOf('get_webview_window("quick-pane")') >= 0);
|
||||
assert.ok(mainHideQuick >= 0 && mainHideQuick < showMain);
|
||||
|
||||
const quickShow = nativeSource.slice(
|
||||
nativeSource.indexOf("fn toggle_quick_pane"),
|
||||
nativeSource.indexOf("fn requested_new_worktree_argument"),
|
||||
);
|
||||
const quickHideMain = quickShow.indexOf("main.hide()");
|
||||
const showQuick = quickShow.indexOf("window.show()");
|
||||
assert.ok(quickShow.indexOf('get_webview_window("main")') >= 0);
|
||||
assert.ok(quickHideMain >= 0 && quickHideMain < showQuick);
|
||||
});
|
||||
|
||||
test("native promotion owns visibility and Main consumes pending runtime after startup refresh", async () => {
|
||||
const [nativeSource, workspaceSource] = await Promise.all([
|
||||
readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
|
||||
readFile(
|
||||
new URL("../ui/src/workspace/useWorkspace.ts", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
const promotion = nativeSource.slice(
|
||||
nativeSource.indexOf("fn show_main_for_promoted_runtime"),
|
||||
nativeSource.indexOf("fn toggle_quick_pane"),
|
||||
);
|
||||
assert.match(promotion, /get_or_create_main_with_visibility\(app, false\)/);
|
||||
assert.ok(
|
||||
promotion.indexOf("quick_pane\n .hide()") <
|
||||
promotion.indexOf("window\n .show()"),
|
||||
);
|
||||
assert.match(promotion, /\*pending = Some\(runtime_id\)/);
|
||||
assert.match(nativeSource, /fn get_pending_promoted_runtime/);
|
||||
assert.match(nativeSource, /fn consume_pending_promoted_runtime/);
|
||||
assert.match(workspaceSource, /await promotedListenerReady/);
|
||||
assert.match(workspaceSource, /await refresh\(loaded\)/);
|
||||
assert.match(workspaceSource, /get_pending_promoted_runtime/);
|
||||
assert.match(workspaceSource, /consume_pending_promoted_runtime/);
|
||||
});
|
||||
|
||||
test("Quick Pane routes extension responses through existing bridge command", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||
@@ -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, /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(conversation, /event\.key === "Enter" && !event\.shiftKey/);
|
||||
assert.match(conversation, /className="todos-pane"/);
|
||||
assert.match(extension, /role="dialog"/);
|
||||
assert.match(extension, /aria-modal="true"/);
|
||||
assert.match(extension, /<dialog/);
|
||||
assert.match(extension, /showModal\(\)/);
|
||||
assert.match(extension, /onCancel=/);
|
||||
});
|
||||
|
||||
test("configures native window behavior and folder dialog capabilities", async () => {
|
||||
|
||||
+1
-2
@@ -2,9 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
<title>Pi Status UI</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
Generated
+1461
-17
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -21,9 +21,10 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
|
||||
@@ -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_json::{json, Value};
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
@@ -228,6 +227,17 @@ pub async fn create_quick_runtime(
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn promote_quick_runtime(
|
||||
socket_path: &str,
|
||||
runtime_id: &str,
|
||||
) -> Result<RuntimeResult, String> {
|
||||
typed_request(
|
||||
socket_path,
|
||||
"promote_quick_runtime",
|
||||
Some(json!({ "runtimeId": runtime_id })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn open_session_runtime(
|
||||
socket_path: &str,
|
||||
worktree_path: &str,
|
||||
@@ -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
|
||||
}
|
||||
|
||||
fn fallback_transcript(state: &Value) -> Value {
|
||||
let Some(path) = state.pointer("/data/sessionFile").and_then(Value::as_str) else {
|
||||
return json!({ "data": { "messages": [] } });
|
||||
};
|
||||
let path = Path::new(path);
|
||||
if !path.is_absolute()
|
||||
|| path.extension().and_then(|extension| extension.to_str()) != Some("jsonl")
|
||||
{
|
||||
return json!({ "data": { "messages": [] } });
|
||||
}
|
||||
let messages = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|content| {
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("message"))
|
||||
.filter_map(|entry| entry.get("message").cloned())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({ "data": { "messages": messages } })
|
||||
}
|
||||
|
||||
fn compact_commands(value: Value) -> Value {
|
||||
let commands = value
|
||||
.pointer("/data/commands")
|
||||
.and_then(Value::as_array)
|
||||
.map(|commands| {
|
||||
commands
|
||||
.iter()
|
||||
.map(|command| {
|
||||
let mut compact = serde_json::Map::new();
|
||||
for field in ["name", "description"] {
|
||||
if let Some(value) = command.get(field) {
|
||||
compact.insert(field.to_owned(), value.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(compact)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({ "data": { "commands": commands } })
|
||||
}
|
||||
|
||||
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
||||
// Older bridge daemons can still serve the conversation while they await a restart.
|
||||
let stats = request(socket_path, "get_session_stats", Some(agent_id), None)
|
||||
.await
|
||||
.unwrap_or_else(|_| json!({ "data": {} }));
|
||||
let (state, commands, models) = tokio::try_join!(
|
||||
request(socket_path, "get_state", Some(agent_id), None),
|
||||
request(socket_path, "get_commands", Some(agent_id), None),
|
||||
request(socket_path, "get_available_models", Some(agent_id), None),
|
||||
)?;
|
||||
Ok(json!({
|
||||
"state": state,
|
||||
"stats": stats,
|
||||
"transcript": fallback_transcript(&state),
|
||||
"commands": compact_commands(commands),
|
||||
"models": models
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) -> Result<Value, String> {
|
||||
pub async fn submit_prompt(
|
||||
socket_path: &str,
|
||||
agent_id: &str,
|
||||
message: &str,
|
||||
) -> Result<Value, String> {
|
||||
request(
|
||||
socket_path,
|
||||
"submit_prompt",
|
||||
@@ -885,91 +834,6 @@ mod tests {
|
||||
std::fs::remove_file(path).expect("socket cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_messages_from_the_saved_session_file_when_transcript_rpc_is_unavailable() {
|
||||
let path = format!(
|
||||
"{}/pi-status-ui-history-{}.jsonl",
|
||||
env::temp_dir().display(),
|
||||
std::process::id()
|
||||
);
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-1\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("session fixture");
|
||||
let transcript = fallback_transcript(&json!({
|
||||
"data": { "sessionFile": path }
|
||||
}));
|
||||
assert_eq!(
|
||||
transcript["data"]["messages"],
|
||||
json!([
|
||||
{ "role": "user", "content": "Hello" },
|
||||
{ "role": "assistant", "content": "Hi" }
|
||||
])
|
||||
);
|
||||
std::fs::remove_file(path).expect("session cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_state_history_commands_and_models_as_one_snapshot() {
|
||||
let path = format!(
|
||||
"{}/pi-status-ui-load-{}.sock",
|
||||
env::temp_dir().display(),
|
||||
std::process::id()
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = UnixListener::bind(&path).expect("listener");
|
||||
let server = tokio::spawn(async move {
|
||||
for _ in 0..4 {
|
||||
let (stream, _) = listener.accept().await.expect("connection");
|
||||
tokio::spawn(async move {
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(stream);
|
||||
reader.read_line(&mut line).await.expect("request");
|
||||
let request: Value = serde_json::from_str(&line).expect("JSON request");
|
||||
assert_eq!(request["agentId"], "agent-1");
|
||||
let result = match request["op"].as_str() {
|
||||
Some("get_state") => json!({ "data": { "isStreaming": false } }),
|
||||
Some("get_session_stats") => json!({
|
||||
"data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } }
|
||||
}),
|
||||
Some("get_commands") => json!({ "data": { "commands": [{
|
||||
"name": "resume",
|
||||
"description": "Resume a saved session",
|
||||
"sourceInfo": { "path": "/very/large/extension/metadata" }
|
||||
}] } }),
|
||||
Some("get_available_models") => json!({ "data": { "models": [] } }),
|
||||
other => panic!("unexpected operation: {other:?}"),
|
||||
};
|
||||
let mut response = serde_json::to_vec(&json!({
|
||||
"id": "tauri-ui", "ok": true, "result": result
|
||||
}))
|
||||
.expect("response JSON");
|
||||
response.push(b'\n');
|
||||
reader
|
||||
.get_mut()
|
||||
.write_all(&response)
|
||||
.await
|
||||
.expect("response");
|
||||
});
|
||||
}
|
||||
});
|
||||
let snapshot = load_agent(&path, "agent-1").await.expect("snapshot");
|
||||
assert_eq!(snapshot["state"]["data"]["isStreaming"], false);
|
||||
assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000);
|
||||
assert_eq!(snapshot["transcript"]["data"]["messages"], json!([]));
|
||||
assert_eq!(
|
||||
snapshot["commands"]["data"]["commands"],
|
||||
json!([{ "name": "resume", "description": "Resume a saved session" }])
|
||||
);
|
||||
server.await.expect("server succeeds");
|
||||
std::fs::remove_file(path).expect("socket cleanup");
|
||||
}
|
||||
|
||||
fn runtime(id: &str) -> Value {
|
||||
json!({
|
||||
"runtimeId": id,
|
||||
|
||||
+380
-52
@@ -2,10 +2,19 @@ mod bridge;
|
||||
mod ui_state;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::{env, path::Path, sync::{Arc, Mutex}, time::Duration};
|
||||
use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
|
||||
use std::{
|
||||
env,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{
|
||||
async_runtime::JoinHandle, AppHandle, Emitter, LogicalSize, Manager, State, WebviewWindow,
|
||||
WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
|
||||
struct LegacySubscription(Mutex<Option<JoinHandle<()>>>);
|
||||
struct PendingPromotedRuntime(Mutex<Option<String>>);
|
||||
struct WorkspaceSubscription {
|
||||
generation: Arc<bridge::SubscriptionGeneration>,
|
||||
task: Mutex<Option<JoinHandle<()>>>,
|
||||
@@ -49,14 +58,141 @@ fn requested_quick_pane(args: &[String]) -> bool {
|
||||
args.iter().any(|arg| arg == "--quick")
|
||||
}
|
||||
|
||||
fn show_quick_pane(app: &AppHandle) {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.hide();
|
||||
fn quick_pane_action(is_visible: bool) -> WindowAction {
|
||||
if is_visible {
|
||||
WindowAction::Hide
|
||||
} else {
|
||||
WindowAction::ShowAndFocus
|
||||
}
|
||||
}
|
||||
|
||||
fn should_hide_close_request(label: &str) -> bool {
|
||||
label == "main" || label == "quick-pane"
|
||||
}
|
||||
|
||||
fn get_or_create_main_with_visibility(
|
||||
app: &AppHandle,
|
||||
visible_when_created: bool,
|
||||
) -> Result<(WebviewWindow, bool), String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
return Ok((window, false));
|
||||
}
|
||||
let config = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.label == "main")
|
||||
.ok_or_else(|| "Main window configuration is missing".to_owned())?;
|
||||
let window = WebviewWindowBuilder::from_config(app, config)
|
||||
.map_err(|error| error.to_string())?
|
||||
.visible(visible_when_created)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((window, true))
|
||||
}
|
||||
|
||||
fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
|
||||
get_or_create_main_with_visibility(app, true)
|
||||
}
|
||||
|
||||
fn apply_main_window_action(app: &AppHandle, args: &[String]) {
|
||||
let (window, created) = match get_or_create_main(app) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
eprintln!("Could not create main window: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let visible = !created && window.is_visible().unwrap_or(false);
|
||||
match window_action(args, visible) {
|
||||
WindowAction::Hide => {
|
||||
let _ = window.hide();
|
||||
}
|
||||
WindowAction::ShowAndFocus => {
|
||||
if let Some(quick_pane) = app.get_webview_window("quick-pane") {
|
||||
if let Err(error) = quick_pane.hide() {
|
||||
eprintln!("Could not hide Quick Pane before showing main window: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if window.show().is_ok() {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_or_create_quick_pane(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
|
||||
if let Some(window) = app.get_webview_window("quick-pane") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
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", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,30 +220,52 @@ fn requested_new_worktree(app: &AppHandle, args: &[String]) -> Option<String> {
|
||||
}
|
||||
|
||||
fn launch_new_session(app: AppHandle, args: &[String]) {
|
||||
let Some(worktree_path) = requested_new_worktree(&app, args) else { return };
|
||||
let Some(worktree_path) = requested_new_worktree(&app, args) else {
|
||||
return;
|
||||
};
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Give a newly-created webview time to register its frontend listeners.
|
||||
tokio::time::sleep(Duration::from_millis(350)).await;
|
||||
let _ = app.emit("workspace-new-session", json!({
|
||||
"phase": "starting",
|
||||
"detail": "Starting a new Pi session…"
|
||||
}));
|
||||
let _ = app.emit(
|
||||
"workspace-new-session",
|
||||
json!({
|
||||
"phase": "starting",
|
||||
"detail": "Starting a new Pi session…"
|
||||
}),
|
||||
);
|
||||
match socket_path().map(|socket| (socket, worktree_path)) {
|
||||
Ok((socket, worktree_path)) => match bridge::create_session_runtime(&socket, &worktree_path).await {
|
||||
Ok(result) => { let _ = app.emit("workspace-new-session", json!({
|
||||
"phase": "ready",
|
||||
"runtimeId": result.runtime.runtime_id,
|
||||
"detail": "New session ready"
|
||||
})); }
|
||||
Err(error) => { let _ = app.emit("workspace-new-session", json!({
|
||||
"phase": "error",
|
||||
"detail": format!("Could not start session: {error}")
|
||||
})); }
|
||||
},
|
||||
Err(error) => { let _ = app.emit("workspace-new-session", json!({
|
||||
"phase": "error",
|
||||
"detail": error
|
||||
})); }
|
||||
Ok((socket, worktree_path)) => {
|
||||
match bridge::create_session_runtime(&socket, &worktree_path).await {
|
||||
Ok(result) => {
|
||||
let _ = app.emit(
|
||||
"workspace-new-session",
|
||||
json!({
|
||||
"phase": "ready",
|
||||
"runtimeId": result.runtime.runtime_id,
|
||||
"detail": "New session ready"
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = app.emit(
|
||||
"workspace-new-session",
|
||||
json!({
|
||||
"phase": "error",
|
||||
"detail": format!("Could not start session: {error}")
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = app.emit(
|
||||
"workspace-new-session",
|
||||
json!({
|
||||
"phase": "error",
|
||||
"detail": error
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -127,6 +285,39 @@ async fn get_model_catalog() -> Result<Value, String> {
|
||||
bridge::get_model_catalog(&socket_path()?).await
|
||||
}
|
||||
|
||||
fn quick_pane_height(height: f64) -> Result<f64, String> {
|
||||
if !height.is_finite() {
|
||||
return Err("Quick Pane height must be finite".to_owned());
|
||||
}
|
||||
Ok(height.clamp(360.0, 520.0))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> {
|
||||
let height = quick_pane_height(height)?;
|
||||
let quick_pane = app
|
||||
.get_webview_window("quick-pane")
|
||||
.ok_or_else(|| "Quick Pane window is unavailable".to_owned())?;
|
||||
let scale_factor = quick_pane
|
||||
.scale_factor()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let current_size = quick_pane.inner_size().map_err(|error| error.to_string())?;
|
||||
quick_pane
|
||||
.set_size(LogicalSize::new(
|
||||
f64::from(current_size.width) / scale_factor,
|
||||
height,
|
||||
))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn dismiss_quick_pane(app: AppHandle) -> Result<(), String> {
|
||||
app.get_webview_window("quick-pane")
|
||||
.ok_or_else(|| "Quick Pane window is unavailable".to_owned())?
|
||||
.hide()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
|
||||
bridge::create_session_runtime(&socket_path()?, &worktree_path).await
|
||||
@@ -137,6 +328,48 @@ async fn create_quick_runtime(worktree_path: String) -> Result<bridge::RuntimeRe
|
||||
bridge::create_quick_runtime(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn promote_quick_runtime(
|
||||
app: AppHandle,
|
||||
runtime_id: String,
|
||||
) -> Result<bridge::RuntimeResult, String> {
|
||||
let result = bridge::promote_quick_runtime(&socket_path()?, &runtime_id).await?;
|
||||
show_main_for_promoted_runtime(&app, result.runtime.runtime_id.clone())?;
|
||||
let _ = app.emit(
|
||||
"workspace-promoted-runtime",
|
||||
json!({ "runtimeId": result.runtime.runtime_id }),
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_pending_promoted_runtime(
|
||||
pending: State<'_, PendingPromotedRuntime>,
|
||||
) -> Result<Option<String>, String> {
|
||||
pending
|
||||
.0
|
||||
.lock()
|
||||
.map(|pending| pending.clone())
|
||||
.map_err(|_| "Could not read promoted runtime".to_owned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn consume_pending_promoted_runtime(
|
||||
runtime_id: String,
|
||||
pending: State<'_, PendingPromotedRuntime>,
|
||||
) -> Result<bool, String> {
|
||||
let mut pending = pending
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Could not consume promoted runtime".to_owned())?;
|
||||
if pending.as_deref() == Some(runtime_id.as_str()) {
|
||||
*pending = None;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_session_runtime(
|
||||
worktree_path: String,
|
||||
@@ -207,11 +440,6 @@ async fn forget_directory(worktree_path: String) -> Result<Value, String> {
|
||||
bridge::forget_directory(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_agent(agent_id: String) -> Result<Value, String> {
|
||||
bridge::load_agent(&socket_path()?, &agent_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_sessions(agent_id: String) -> Result<Value, String> {
|
||||
bridge::list_sessions(&socket_path()?, &agent_id).await
|
||||
@@ -274,7 +502,8 @@ async fn pi_rpc_command(agent_id: String, command: String, input: Value) -> Resu
|
||||
"pi_rpc_command",
|
||||
Some(&agent_id),
|
||||
Some(serde_json::json!({ "command": command, "input": input })),
|
||||
).await
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -382,31 +611,30 @@ pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(LegacySubscription(Mutex::new(None)))
|
||||
.manage(PendingPromotedRuntime(Mutex::new(None)))
|
||||
.manage(WorkspaceSubscription {
|
||||
generation: Arc::new(bridge::SubscriptionGeneration::default()),
|
||||
task: Mutex::new(None),
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
if requested_quick_pane(&args) {
|
||||
show_quick_pane(app);
|
||||
toggle_quick_pane(app);
|
||||
return;
|
||||
}
|
||||
launch_new_session(app.clone(), &args);
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match window_action(&args, window.is_visible().unwrap_or(false)) {
|
||||
WindowAction::Hide => {
|
||||
let _ = window.hide();
|
||||
}
|
||||
WindowAction::ShowAndFocus => {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
apply_main_window_action(app, &args);
|
||||
}))
|
||||
.on_window_event(|window, event| {
|
||||
if should_hide_close_request(window.label()) {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
.setup(move |app| {
|
||||
if requested_quick_pane(&launch_args) {
|
||||
show_quick_pane(app.handle());
|
||||
toggle_quick_pane(app.handle());
|
||||
} else {
|
||||
launch_new_session(app.handle().clone(), &launch_args);
|
||||
}
|
||||
@@ -416,8 +644,13 @@ pub fn run() {
|
||||
get_workspace,
|
||||
get_workspace_summary,
|
||||
get_model_catalog,
|
||||
resize_quick_pane,
|
||||
dismiss_quick_pane,
|
||||
create_session_runtime,
|
||||
create_quick_runtime,
|
||||
promote_quick_runtime,
|
||||
get_pending_promoted_runtime,
|
||||
consume_pending_promoted_runtime,
|
||||
open_session_runtime,
|
||||
close_session_runtime,
|
||||
close_quick_runtime,
|
||||
@@ -431,7 +664,6 @@ pub fn run() {
|
||||
list_directories,
|
||||
select_worktree,
|
||||
forget_directory,
|
||||
load_agent,
|
||||
list_sessions,
|
||||
switch_session,
|
||||
new_session,
|
||||
@@ -475,16 +707,112 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn new_session_uses_an_explicit_absolute_worktree() {
|
||||
let args = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "/workspace".into()];
|
||||
assert_eq!(requested_new_worktree_argument(&args), Some("/workspace".into()));
|
||||
let relative = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "workspace".into()];
|
||||
assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None);
|
||||
let args = vec![
|
||||
"pi-status-ui".into(),
|
||||
"--new".into(),
|
||||
"--worktree".into(),
|
||||
"/workspace".into(),
|
||||
];
|
||||
assert_eq!(
|
||||
requested_new_worktree_argument(&args),
|
||||
Some("/workspace".into())
|
||||
);
|
||||
let relative = vec![
|
||||
"pi-status-ui".into(),
|
||||
"--new".into(),
|
||||
"--worktree".into(),
|
||||
"workspace".into(),
|
||||
];
|
||||
assert_eq!(
|
||||
requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_flag_is_detected_without_affecting_toggle() {
|
||||
fn quick_flag_is_detected_and_toggles_its_own_window() {
|
||||
assert!(requested_quick_pane(&["--quick".to_owned()]));
|
||||
assert!(!requested_quick_pane(&["--toggle".to_owned()]));
|
||||
assert_eq!(quick_pane_action(false), WindowAction::ShowAndFocus);
|
||||
assert_eq!(quick_pane_action(true), WindowAction::Hide);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_close_requests_hide_managed_windows() {
|
||||
assert!(should_hide_close_request("main"));
|
||||
assert!(should_hide_close_request("quick-pane"));
|
||||
assert!(!should_hide_close_request("other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promoted_runtime_pending_id_clears_only_after_matching_acknowledgement() {
|
||||
let pending = PendingPromotedRuntime(Mutex::new(Some("runtime-a".to_owned())));
|
||||
let mut value = pending.0.lock().unwrap();
|
||||
assert_ne!(value.as_deref(), Some("runtime-b"));
|
||||
assert_eq!(value.as_deref(), Some("runtime-a"));
|
||||
*value = None;
|
||||
assert_eq!(value.as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_recreation_uses_the_complete_configured_window() {
|
||||
let context: tauri::Context<tauri::Wry> = tauri::generate_context!();
|
||||
let main = context
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.label == "main")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(main.width, 1100.0);
|
||||
assert_eq!(main.height, 840.0);
|
||||
assert_eq!(main.url.to_string(), "index.html");
|
||||
assert!(main.visible);
|
||||
assert!(main.transparent);
|
||||
assert!(main.always_on_top);
|
||||
assert!(main.skip_taskbar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_pane_recreation_uses_the_complete_configured_window() {
|
||||
let context: tauri::Context<tauri::Wry> = tauri::generate_context!();
|
||||
let quick_pane = context
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.label == "quick-pane")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(quick_pane.width, 640.0);
|
||||
assert_eq!(quick_pane.height, 360.0);
|
||||
assert_eq!(quick_pane.min_width, Some(520.0));
|
||||
assert_eq!(quick_pane.min_height, Some(360.0));
|
||||
assert_eq!(quick_pane.url.to_string(), "index.html");
|
||||
assert!(!quick_pane.visible);
|
||||
assert!(quick_pane.transparent);
|
||||
assert!(quick_pane.always_on_top);
|
||||
assert!(quick_pane.skip_taskbar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_pane_height_stays_within_content_limit() {
|
||||
assert_eq!(quick_pane_height(200.0), Ok(360.0));
|
||||
assert_eq!(quick_pane_height(420.0), Ok(420.0));
|
||||
assert_eq!(quick_pane_height(900.0), Ok(520.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_pane_resize_rejects_non_finite_height() {
|
||||
assert_eq!(
|
||||
quick_pane_height(f64::NAN),
|
||||
Err("Quick Pane height must be finite".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
quick_pane_height(f64::INFINITY),
|
||||
Err("Quick Pane height must be finite".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,6 +12,7 @@ const MAX_DRAFT_BYTES: usize = 128 * 1024;
|
||||
const MAX_TOTAL_DRAFT_BYTES: usize = 1024 * 1024;
|
||||
const MAX_TOTAL_STRING_BYTES: usize = 1536 * 1024;
|
||||
const MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
|
||||
const DEFAULT_THINKING_LEVEL: &str = "medium";
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
@@ -46,8 +47,13 @@ pub struct QuickPaneSettings {
|
||||
pub follow_up_provider: String,
|
||||
pub follow_up_model: String,
|
||||
pub instructions: String,
|
||||
pub maximum_seconds: 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 {
|
||||
@@ -58,9 +64,9 @@ impl Default for QuickPaneSettings {
|
||||
model: String::new(),
|
||||
follow_up_provider: String::new(),
|
||||
follow_up_model: String::new(),
|
||||
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.".to_owned(),
|
||||
maximum_seconds: 60,
|
||||
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or complex work.".to_owned(),
|
||||
maximum_tool_calls: 6,
|
||||
default_thinking_level: default_thinking_level(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,9 +238,14 @@ pub fn validate(state: &UiStateV1) -> Result<(), String> {
|
||||
if quick.instructions.is_empty() || quick.instructions.len() > 4096 {
|
||||
return Err("Quick-pane instructions must contain 1 to 4096 bytes".to_owned());
|
||||
}
|
||||
if !(1..=600).contains(&quick.maximum_seconds) || !(1..=64).contains(&quick.maximum_tool_calls)
|
||||
{
|
||||
return Err("Quick-pane limits are outside the supported range".to_owned());
|
||||
if !(1..=64).contains(&quick.maximum_tool_calls) {
|
||||
return Err("Quick-pane tool-call limit is outside the supported range".to_owned());
|
||||
}
|
||||
if !matches!(
|
||||
quick.default_thinking_level.as_str(),
|
||||
"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
|
||||
) {
|
||||
return Err("Quick-pane default thinking level is invalid".to_owned());
|
||||
}
|
||||
total_string_bytes = total_string_bytes
|
||||
.saturating_add(quick.workspace.len())
|
||||
@@ -281,7 +292,16 @@ pub fn load_from(app_data_dir: &Path) -> Result<UiStateV1, String> {
|
||||
if content.len() > MAX_FILE_BYTES {
|
||||
return Err("UI state file exceeds the size limit".to_owned());
|
||||
}
|
||||
let state: UiStateV1 = serde_json::from_slice(&content)
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&content)
|
||||
.map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?;
|
||||
// Read legacy persisted cap once, but never retain or serialize it again.
|
||||
if let Some(quick_pane) = value
|
||||
.get_mut("quickPane")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
quick_pane.remove("maximumSeconds");
|
||||
}
|
||||
let state: UiStateV1 = serde_json::from_value(value)
|
||||
.map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?;
|
||||
validate(&state)?;
|
||||
Ok(state)
|
||||
@@ -443,8 +463,33 @@ mod tests {
|
||||
state.quick_pane.follow_up_provider = "provider".into();
|
||||
state.quick_pane.follow_up_model = "follow-up-model".into();
|
||||
assert!(validate(&state).is_ok());
|
||||
state.quick_pane.maximum_seconds = 0;
|
||||
state.quick_pane.default_thinking_level = "high".into();
|
||||
save_to(&root, &state).unwrap();
|
||||
assert_eq!(load_from(&root).unwrap().quick_pane.default_thinking_level, "high");
|
||||
state.quick_pane.default_thinking_level = "unsupported".into();
|
||||
assert!(validate(&state).is_err());
|
||||
state.quick_pane.default_thinking_level = default_thinking_level();
|
||||
state.quick_pane.maximum_tool_calls = 0;
|
||||
assert!(validate(&state).is_err());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_maximum_seconds_loads_but_is_not_serialized() {
|
||||
let root = temp_dir("quick-pane-legacy-seconds");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let path = state_path(&root);
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"version":1,"quickPane":{"workspace":"","provider":"","model":"","followUpProvider":"","followUpModel":"","instructions":"valid","maximumSeconds":60,"maximumToolCalls":6}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let state = load_from(&root).unwrap();
|
||||
assert_eq!(state.quick_pane.default_thinking_level, DEFAULT_THINKING_LEVEL);
|
||||
save_to(&root, &state).unwrap();
|
||||
let saved = fs::read_to_string(&path).unwrap();
|
||||
assert!(!saved.contains("maximumSeconds"));
|
||||
assert!(saved.contains("\"defaultThinkingLevel\": \"medium\""));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,13 +28,14 @@
|
||||
{
|
||||
"label": "quick-pane",
|
||||
"title": "Pi Quick Pane",
|
||||
"width": 520,
|
||||
"width": 640,
|
||||
"height": 360,
|
||||
"minWidth": 400,
|
||||
"minHeight": 260,
|
||||
"minWidth": 520,
|
||||
"minHeight": 360,
|
||||
"center": true,
|
||||
"visible": false,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"shadow": true
|
||||
|
||||
+184
-223
@@ -224,46 +224,92 @@ select:focus-visible {
|
||||
gap: 5px;
|
||||
}
|
||||
.session-strip {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.session-tab-bar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 28px auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid #55585d;
|
||||
padding: 4px;
|
||||
border: 1px solid #484b50;
|
||||
border-radius: 9px;
|
||||
background: #242528;
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
.session-scroll-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 1px solid #4b4e53;
|
||||
border-radius: 7px;
|
||||
background: #303237;
|
||||
color: #c8ccd3;
|
||||
font-size: 21px;
|
||||
line-height: 1;
|
||||
}
|
||||
.session-scroll-button:hover:not(:disabled) {
|
||||
border-color: #8a6339;
|
||||
background: #3d372f;
|
||||
color: #ffc170;
|
||||
}
|
||||
.session-scroll-button:disabled {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.session-tabs {
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
gap: 4px;
|
||||
padding-left: 4px;
|
||||
overflow-y: hidden;
|
||||
padding: 2px;
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-inline: 2px;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-color: #696057 transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.session-tabs::-webkit-scrollbar {
|
||||
height: 5px;
|
||||
}
|
||||
.session-tabs::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: #696057;
|
||||
}
|
||||
.session-tab {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 28px 28px;
|
||||
align-items: stretch;
|
||||
flex: 0 0 auto;
|
||||
grid-template-columns: minmax(0, 1fr) 27px 27px;
|
||||
width: 188px;
|
||||
height: 44px;
|
||||
flex: 0 0 188px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #555;
|
||||
border-bottom: 0;
|
||||
border-radius: 7px 7px 0 0;
|
||||
background: #303136;
|
||||
scroll-snap-align: start;
|
||||
border: 1px solid #4d5055;
|
||||
border-radius: 7px;
|
||||
background: #303237;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.session-tab:hover,
|
||||
.session-tab:focus-within {
|
||||
border-color: #696d73;
|
||||
background: #35373c;
|
||||
}
|
||||
.session-tab.selected {
|
||||
border-color: #d88735;
|
||||
background: #3b3935;
|
||||
box-shadow: inset 0 2px #f0a347;
|
||||
background: #3c362f;
|
||||
box-shadow:
|
||||
inset 3px 0 #f0a347,
|
||||
0 0 0 1px rgba(240, 163, 71, 0.12);
|
||||
}
|
||||
.session-tab > button[role="tab"] {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-width: 112px;
|
||||
max-width: 176px;
|
||||
padding: 6px 9px 5px;
|
||||
min-width: 0;
|
||||
align-content: center;
|
||||
gap: 3px;
|
||||
padding: 5px 7px 5px 10px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
@@ -271,44 +317,69 @@ select:focus-visible {
|
||||
text-align: left;
|
||||
}
|
||||
.session-tab > button[role="tab"]:hover {
|
||||
background: #383a40;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
.session-tab.selected > button[role="tab"] {
|
||||
color: #fff5e7;
|
||||
}
|
||||
.session-tab > button span {
|
||||
.session-tab-label {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-tab small {
|
||||
grid-row: 2;
|
||||
color: #788077;
|
||||
font-size: 10px;
|
||||
.session-tab-meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.session-tab small.streaming,
|
||||
.session-tab small.recovering {
|
||||
color: #ffc170;
|
||||
.session-tab-meta small {
|
||||
overflow: hidden;
|
||||
color: #9299a2;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.035em;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-state-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #737a75;
|
||||
}
|
||||
.session-state-dot.streaming,
|
||||
.session-state-dot.working,
|
||||
.session-state-dot.recovering {
|
||||
background: #f0a347;
|
||||
box-shadow: 0 0 6px rgba(240, 163, 71, 0.7);
|
||||
}
|
||||
.session-state-dot.error,
|
||||
.session-state-dot.failed {
|
||||
background: #e47682;
|
||||
}
|
||||
.renew-session,
|
||||
.close-session {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
width: 27px;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
border: 0;
|
||||
border-left: 1px solid #45484d;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #b8bbc1;
|
||||
background: rgba(18, 19, 21, 0.2);
|
||||
color: #aeb3bb;
|
||||
}
|
||||
.renew-session {
|
||||
color: #a9f2df;
|
||||
color: #9bcfbe;
|
||||
}
|
||||
.renew-session-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
@@ -328,65 +399,67 @@ select:focus-visible {
|
||||
}
|
||||
.session-tab.selected .renew-session,
|
||||
.session-tab.selected .close-session {
|
||||
color: #fff5e7;
|
||||
border-left-color: #594d40;
|
||||
color: #f3deca;
|
||||
}
|
||||
.renew-session:hover {
|
||||
.renew-session:hover:not(:disabled) {
|
||||
background: #294139;
|
||||
color: #c8ffef;
|
||||
}
|
||||
.close-session:hover {
|
||||
.close-session:hover:not(:disabled) {
|
||||
background: #4a3131;
|
||||
color: #ffd7db;
|
||||
}
|
||||
.new-session-tab {
|
||||
align-self: stretch;
|
||||
flex: 0 0 32px;
|
||||
margin-left: 4px;
|
||||
padding: 0;
|
||||
border: 1px solid #d88735;
|
||||
border-bottom: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: #4a3827;
|
||||
color: #ffc170;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.new-session-tab:hover {
|
||||
background: #5a432d;
|
||||
color: #ffe0b6;
|
||||
}
|
||||
.unread-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #f0a347;
|
||||
}
|
||||
.attention-dot {
|
||||
color: #ffcb6b;
|
||||
font-size: 10px;
|
||||
}
|
||||
.session-tab-actions {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-left: auto;
|
||||
padding: 0 0 2px 8px;
|
||||
align-self: stretch;
|
||||
gap: 4px;
|
||||
padding-left: 2px;
|
||||
border-left: 1px solid #424449;
|
||||
}
|
||||
.session-tab-actions button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid #55585d;
|
||||
border-bottom: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
padding: 5px 8px;
|
||||
background: #383a40;
|
||||
color: #e2e4e8;
|
||||
border: 1px solid #51545a;
|
||||
border-radius: 7px;
|
||||
padding: 0 9px;
|
||||
background: #303237;
|
||||
color: #dde0e5;
|
||||
}
|
||||
.session-tab-actions button:hover {
|
||||
background: #4a4540;
|
||||
.session-tab-actions button:hover:not(:disabled) {
|
||||
border-color: #8a6339;
|
||||
background: #3d372f;
|
||||
color: #ffc170;
|
||||
}
|
||||
.session-tab-actions span {
|
||||
margin-right: 5px;
|
||||
font-size: 15px;
|
||||
line-height: 0;
|
||||
.session-tab-actions .new-session-tab {
|
||||
border-color: #9a6938;
|
||||
background: #443526;
|
||||
color: #ffc170;
|
||||
}
|
||||
.session-tab-actions button > span:first-child {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.session-action-label {
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.directory-add {
|
||||
display: flex;
|
||||
@@ -717,140 +790,6 @@ select:focus-visible {
|
||||
border-radius: 5px;
|
||||
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 {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -1055,13 +994,25 @@ select:focus-visible {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.quick-pane-settings label {
|
||||
.quick-pane-settings label,
|
||||
.quick-pane-model-setting {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #ddd8d0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.quick-pane-model-setting .model-control {
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
font-size: 12px;
|
||||
}
|
||||
.quick-pane-model-setting .model-menu {
|
||||
width: min(400px, calc(100vw - 80px));
|
||||
}
|
||||
.quick-pane-model-setting small {
|
||||
color: #b9afa4;
|
||||
font-weight: 400;
|
||||
}
|
||||
.quick-pane-settings input,
|
||||
.quick-pane-settings textarea {
|
||||
width: 100%;
|
||||
@@ -1129,17 +1080,14 @@ select:focus-visible {
|
||||
flex: 0 0 auto;
|
||||
transform: scale(1.35);
|
||||
}
|
||||
.modal-backdrop,
|
||||
.extension-backdrop {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
dialog.session-picker::backdrop,
|
||||
dialog.extension::backdrop {
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
dialog.session-picker:not([open]),
|
||||
dialog.extension:not([open]) {
|
||||
display: none;
|
||||
}
|
||||
.session-picker,
|
||||
.extension {
|
||||
width: min(680px, 90vw);
|
||||
@@ -1147,10 +1095,12 @@ select:focus-visible {
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: auto;
|
||||
padding: 15px;
|
||||
border: 1px solid #b76f26;
|
||||
border-radius: 10px;
|
||||
background: #2d2e31;
|
||||
color: #f1f3f4;
|
||||
}
|
||||
.session-picker {
|
||||
overflow: hidden;
|
||||
@@ -1310,11 +1260,22 @@ select:focus-visible {
|
||||
.workflow-main {
|
||||
grid-template-columns: minmax(0, 1fr) 165px;
|
||||
}
|
||||
.session-strip-actions {
|
||||
flex-direction: column;
|
||||
.session-tab-bar {
|
||||
grid-template-columns: 26px minmax(0, 1fr) 26px auto;
|
||||
}
|
||||
.session-tab > button[role="tab"] {
|
||||
min-width: 125px;
|
||||
.session-scroll-button {
|
||||
width: 26px;
|
||||
}
|
||||
.session-tab {
|
||||
width: 164px;
|
||||
flex-basis: 164px;
|
||||
}
|
||||
.session-tab-actions button {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
}
|
||||
.session-action-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
+911
-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 { 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 {
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
workspaceReducer,
|
||||
} from "./workspace/reducer";
|
||||
import { modelIdentityFromValue, modelIdentityValue } from "./model-catalog";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
loadSnapshot: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
emitTo: vi.fn(),
|
||||
useWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
||||
vi.mock("@tauri-apps/api/event", () => ({ emitTo: mocks.emitTo }));
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: () => ({ startDragging: vi.fn() }),
|
||||
}));
|
||||
@@ -87,10 +101,23 @@ function deferred<T>() {
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.invoke.mockResolvedValue(uiState);
|
||||
mocks.emitTo.mockResolvedValue(undefined);
|
||||
mocks.refresh.mockResolvedValue(undefined);
|
||||
mocks.loadSnapshot.mockResolvedValue(true);
|
||||
mocks.useWorkspace.mockReturnValue({
|
||||
@@ -108,13 +135,890 @@ describe("App command progress and errors", () => {
|
||||
expect(status).toHaveTextContent("Ready for your next prompt");
|
||||
});
|
||||
|
||||
it("focuses the prompt when the GUI becomes active", async () => {
|
||||
let state = workspaceState();
|
||||
state.windowActive = false;
|
||||
mocks.useWorkspace.mockImplementation(() => ({
|
||||
state,
|
||||
dispatch: mocks.dispatch,
|
||||
refresh: mocks.refresh,
|
||||
loadSnapshot: mocks.loadSnapshot,
|
||||
}));
|
||||
const rendered = render(<App />);
|
||||
const prompt = screen.getByLabelText("Prompt");
|
||||
const sessionTab = screen.getByRole("tab", { name: /Session A/ });
|
||||
sessionTab.focus();
|
||||
expect(sessionTab).toHaveFocus();
|
||||
|
||||
state = { ...state, windowActive: true };
|
||||
rendered.rerender(<App />);
|
||||
|
||||
await waitFor(() => expect(prompt).toHaveFocus());
|
||||
});
|
||||
|
||||
it("starts a session in selected directory with Ctrl+N and focuses the prompt", 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 prompt = screen.getByLabelText("Prompt");
|
||||
const newSession = screen.getByRole("button", { name: "New session" });
|
||||
newSession.focus();
|
||||
expect(newSession).toHaveFocus();
|
||||
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",
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(prompt).toHaveFocus());
|
||||
});
|
||||
|
||||
it("cycles sessions with Ctrl+Tab and Ctrl+Shift+Tab", () => {
|
||||
const state = workspaceState();
|
||||
state.runtimesById["runtime-c"] = {
|
||||
...state.runtimesById["runtime-b"],
|
||||
summary: {
|
||||
...otherSummary,
|
||||
runtimeId: "runtime-c",
|
||||
agentId: "agent-c",
|
||||
label: "Session C",
|
||||
},
|
||||
};
|
||||
state.runtimeOrderByDirectory["/repo"] = [
|
||||
"runtime-a",
|
||||
"runtime-b",
|
||||
"runtime-c",
|
||||
];
|
||||
mocks.useWorkspace.mockReturnValue({
|
||||
state,
|
||||
dispatch: mocks.dispatch,
|
||||
refresh: mocks.refresh,
|
||||
loadSnapshot: mocks.loadSnapshot,
|
||||
});
|
||||
render(<App />);
|
||||
mocks.dispatch.mockClear();
|
||||
|
||||
const next = new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: true,
|
||||
key: "Tab",
|
||||
});
|
||||
window.dispatchEvent(next);
|
||||
expect(next.defaultPrevented).toBe(true);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: "selectRuntime",
|
||||
runtimeId: "runtime-b",
|
||||
});
|
||||
|
||||
mocks.dispatch.mockClear();
|
||||
const previous = new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
key: "Tab",
|
||||
});
|
||||
window.dispatchEvent(previous);
|
||||
expect(previous.defaultPrevented).toBe(true);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: "selectRuntime",
|
||||
runtimeId: "runtime-c",
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the selected session with Ctrl+W", async () => {
|
||||
render(<App />);
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: true,
|
||||
key: "w",
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_session_runtime", {
|
||||
runtimeId: "runtime-a",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const restart = deferred<unknown>();
|
||||
const snapshot = deferred<boolean>();
|
||||
mocks.invoke.mockImplementation((command: string) =>
|
||||
command === "restart" ? restart.promise : Promise.resolve(undefined),
|
||||
);
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
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);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
@@ -127,11 +1031,8 @@ describe("App command progress and errors", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
|
||||
await user.click(restarting);
|
||||
expect(mocks.invoke).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "load_ui_state");
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "restart", {
|
||||
agentId: "agent-a",
|
||||
});
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("restart", { agentId: "agent-a" });
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("get_model_catalog");
|
||||
|
||||
await act(async () => restart.resolve(undefined));
|
||||
expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled();
|
||||
|
||||
+379
-77
@@ -5,17 +5,25 @@ import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import "./App.css";
|
||||
import { ConversationWorkspace } from "./components/ConversationWorkspace";
|
||||
import { ModelSelector } from "./components/ModelSelector";
|
||||
import { ThinkingSelector } from "./components/ThinkingSelector";
|
||||
import { DirectorySidebar } from "./components/DirectorySidebar";
|
||||
import { ExtensionDialog } from "./components/ExtensionDialog";
|
||||
import { CommandFormDialog } from "./components/CommandFormDialog";
|
||||
import type { RpcCommand } from "./commands/rpc";
|
||||
import { SessionPicker } from "./components/SessionPicker";
|
||||
import { SessionTabs } from "./components/SessionTabs";
|
||||
import {
|
||||
modelIdentity,
|
||||
reconcileModelIdentity,
|
||||
reconcileThinkingLevel,
|
||||
} from "./model-catalog";
|
||||
import type {
|
||||
DirectorySession,
|
||||
DirectoryWorkspace,
|
||||
Model,
|
||||
QuickPaneSettings,
|
||||
RuntimeSnapshot,
|
||||
RuntimeView,
|
||||
UiState,
|
||||
} from "./workspace/types";
|
||||
@@ -32,9 +40,9 @@ const defaultQuickPaneSettings: QuickPaneSettings = {
|
||||
followUpProvider: "",
|
||||
followUpModel: "",
|
||||
instructions:
|
||||
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.",
|
||||
maximumSeconds: 60,
|
||||
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or complex work.",
|
||||
maximumToolCalls: 6,
|
||||
defaultThinkingLevel: "medium",
|
||||
};
|
||||
function validateQuickPaneSettings(settings: QuickPaneSettings) {
|
||||
if (!settings.workspace.startsWith("/"))
|
||||
@@ -45,18 +53,14 @@ function validateQuickPaneSettings(settings: QuickPaneSettings) {
|
||||
return "Follow-up provider and model are required.";
|
||||
if (!settings.instructions.trim())
|
||||
return "Supplemental instructions are required.";
|
||||
if (
|
||||
!Number.isInteger(settings.maximumSeconds) ||
|
||||
settings.maximumSeconds < 1 ||
|
||||
settings.maximumSeconds > 600
|
||||
)
|
||||
return "Seconds must be an integer from 1 to 600.";
|
||||
if (
|
||||
!Number.isInteger(settings.maximumToolCalls) ||
|
||||
settings.maximumToolCalls < 1 ||
|
||||
settings.maximumToolCalls > 64
|
||||
)
|
||||
return "Tool calls must be an integer from 1 to 64.";
|
||||
if (!settings.defaultThinkingLevel)
|
||||
return "Default thinking level is required.";
|
||||
}
|
||||
let submissionId = 0;
|
||||
type LocalOperation = {
|
||||
@@ -155,12 +159,189 @@ export default function App() {
|
||||
() => new Set(),
|
||||
);
|
||||
const [restoreTabFocus, setRestoreTabFocus] = useState(false);
|
||||
const [promptFocusRequest, setPromptFocusRequest] = useState(0);
|
||||
const [uiState, setUiState] = useState<UiState>();
|
||||
const [quickPaneSettings, setQuickPaneSettings] = useState<QuickPaneSettings>(
|
||||
defaultQuickPaneSettings,
|
||||
);
|
||||
const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState("");
|
||||
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) =>
|
||||
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
|
||||
const clearRuntimeStatus = (runtimeId: string) =>
|
||||
@@ -200,8 +381,10 @@ export default function App() {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
for (const runtimeId of catalogProbeRuntimeIds.current)
|
||||
void closeCatalogProbe(runtimeId).catch(() => undefined);
|
||||
};
|
||||
}, []);
|
||||
}, [closeCatalogProbe]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -225,6 +408,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;
|
||||
useEffect(() => {
|
||||
if (!selectedRuntimeId) return;
|
||||
@@ -232,6 +432,9 @@ export default function App() {
|
||||
dispatch({ type: "markRead", runtimeId: selectedRuntimeId }),
|
||||
);
|
||||
}, [selectedRuntimeId, dispatch]);
|
||||
useEffect(() => {
|
||||
if (state.windowActive) setPromptFocusRequest((current) => current + 1);
|
||||
}, [state.windowActive]);
|
||||
|
||||
useEffect(() => {
|
||||
setAbortingRuntimeIds((current) => {
|
||||
@@ -246,6 +449,14 @@ export default function App() {
|
||||
}, [state.runtimesById]);
|
||||
|
||||
async function saveQuickPaneSettings() {
|
||||
if (
|
||||
modelCatalogStatus !== "ready" ||
|
||||
catalogWorkspace !== quickPaneSettings.workspace ||
|
||||
modelCatalog.length === 0
|
||||
) {
|
||||
setQuickPaneSettingsError("Model catalog is not ready.");
|
||||
return;
|
||||
}
|
||||
const validationError = validateQuickPaneSettings(quickPaneSettings);
|
||||
if (validationError) {
|
||||
setQuickPaneSettingsError(validationError);
|
||||
@@ -284,7 +495,10 @@ export default function App() {
|
||||
if (!mountedRef.current) return;
|
||||
dispatch({ type: "selectRuntime", runtimeId: result.runtime.runtimeId });
|
||||
await loadSnapshot(result.runtime.runtimeId);
|
||||
if (mountedRef.current) setStatus("New session ready");
|
||||
if (mountedRef.current) {
|
||||
setStatus("New session ready");
|
||||
setPromptFocusRequest((current) => current + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mountedRef.current)
|
||||
setStatus(`Could not start session: ${String(error)}`);
|
||||
@@ -680,6 +894,64 @@ export default function App() {
|
||||
entry.kind === "opening" ||
|
||||
entry.kind === "listing"),
|
||||
);
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.repeat ||
|
||||
!event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "n") {
|
||||
if (sessionTabsBusy) return;
|
||||
event.preventDefault();
|
||||
void createRuntime();
|
||||
return;
|
||||
}
|
||||
if (key === "w") {
|
||||
event.preventDefault();
|
||||
if (
|
||||
!selected ||
|
||||
activeOperations.some(
|
||||
(entry) =>
|
||||
entry.runtimeId === selected.summary.runtimeId &&
|
||||
["closing", "renewing", "restarting"].includes(entry.kind),
|
||||
)
|
||||
)
|
||||
return;
|
||||
void closeRuntime(selected);
|
||||
return;
|
||||
}
|
||||
if (key !== "tab") return;
|
||||
event.preventDefault();
|
||||
if (runtimes.length < 2) return;
|
||||
const current = runtimes.findIndex(
|
||||
(runtime) => runtime.summary.runtimeId === selectedRuntimeId,
|
||||
);
|
||||
const direction = event.shiftKey ? -1 : 1;
|
||||
const next =
|
||||
current < 0 ? 0 : (current + direction + runtimes.length) % runtimes.length;
|
||||
dispatch({
|
||||
type: "selectRuntime",
|
||||
runtimeId: runtimes[next].summary.runtimeId,
|
||||
});
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [
|
||||
activeOperations,
|
||||
closeRuntime,
|
||||
createRuntime,
|
||||
dispatch,
|
||||
runtimes,
|
||||
selected,
|
||||
selectedRuntimeId,
|
||||
sessionTabsBusy,
|
||||
]);
|
||||
|
||||
const closingRuntimeIds = new Set(
|
||||
activeOperations
|
||||
.filter((entry) => entry.kind === "closing" && entry.runtimeId)
|
||||
@@ -865,77 +1137,90 @@ export default function App() {
|
||||
Workspace
|
||||
<input
|
||||
value={quickPaneSettings.workspace}
|
||||
onChange={(event) =>
|
||||
setQuickPaneSettings({
|
||||
...quickPaneSettings,
|
||||
workspace: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
onChange={(event) => {
|
||||
const workspace = event.currentTarget.value;
|
||||
modelCatalogRequest.current += 1;
|
||||
setModelCatalog([]);
|
||||
setCatalogWorkspace(undefined);
|
||||
setModelCatalogStatus("idle");
|
||||
setModelCatalogError(undefined);
|
||||
setQuickPaneSettingsStatus("");
|
||||
setQuickPaneSettings((current) => ({
|
||||
...current,
|
||||
workspace,
|
||||
provider: "",
|
||||
model: "",
|
||||
followUpProvider: "",
|
||||
followUpModel: "",
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Quick provider
|
||||
<input
|
||||
value={quickPaneSettings.provider}
|
||||
onChange={(event) =>
|
||||
setQuickPaneSettings({
|
||||
...quickPaneSettings,
|
||||
provider: event.currentTarget.value,
|
||||
})
|
||||
<div className="quick-pane-model-setting">
|
||||
<ModelSelector
|
||||
label="Quick model"
|
||||
placement="down"
|
||||
models={modelCatalog}
|
||||
current={{
|
||||
provider: quickPaneSettings.provider,
|
||||
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>
|
||||
<label>
|
||||
Quick model
|
||||
<input
|
||||
value={quickPaneSettings.model}
|
||||
onChange={(event) =>
|
||||
setQuickPaneSettings({
|
||||
...quickPaneSettings,
|
||||
model: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
<small>Used when a Quick Pane opens.</small>
|
||||
</div>
|
||||
<div className="quick-pane-model-setting">
|
||||
<ModelSelector
|
||||
label="Follow-up model"
|
||||
placement="down"
|
||||
models={modelCatalog}
|
||||
current={{
|
||||
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>
|
||||
<label>
|
||||
Follow-up provider
|
||||
<input
|
||||
value={quickPaneSettings.followUpProvider}
|
||||
onChange={(event) =>
|
||||
setQuickPaneSettings({
|
||||
...quickPaneSettings,
|
||||
followUpProvider: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Follow-up model
|
||||
<input
|
||||
value={quickPaneSettings.followUpModel}
|
||||
onChange={(event) =>
|
||||
setQuickPaneSettings({
|
||||
...quickPaneSettings,
|
||||
followUpModel: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Maximum seconds
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="600"
|
||||
value={quickPaneSettings.maximumSeconds}
|
||||
onChange={(event) =>
|
||||
setQuickPaneSettings({
|
||||
...quickPaneSettings,
|
||||
maximumSeconds: Number(event.currentTarget.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<small>Provider comes from selected catalog model.</small>
|
||||
</div>
|
||||
<label>
|
||||
Maximum tool calls
|
||||
<input
|
||||
@@ -963,12 +1248,24 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={!uiState}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
!uiState ||
|
||||
modelCatalogStatus !== "ready" ||
|
||||
catalogWorkspace !== quickPaneSettings.workspace ||
|
||||
modelCatalog.length === 0
|
||||
}
|
||||
>
|
||||
Save Quick Pane settings
|
||||
</button>
|
||||
{modelCatalogStatus === "loading" && (
|
||||
<p role="status">Loading model catalog…</p>
|
||||
)}
|
||||
{quickPaneSettingsStatus && (
|
||||
<p role="status">{quickPaneSettingsStatus}</p>
|
||||
)}
|
||||
{modelCatalogError && <p role="alert">{modelCatalogError}</p>}
|
||||
{quickPaneSettingsError && <p role="alert">{quickPaneSettingsError}</p>}
|
||||
</form>
|
||||
{selected ? (
|
||||
@@ -1026,6 +1323,11 @@ export default function App() {
|
||||
) : selected ? (
|
||||
<ConversationWorkspace
|
||||
runtime={selected}
|
||||
focusRequest={
|
||||
adding || sessions || visibleCommandForm || selected.extensions.length
|
||||
? 0
|
||||
: promptFocusRequest
|
||||
}
|
||||
onDraft={(draft) =>
|
||||
dispatch({
|
||||
type: "draftChanged",
|
||||
|
||||
@@ -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 type { RpcCommand, RpcField } from "../commands/rpc";
|
||||
|
||||
const controls = (container: HTMLElement | null) =>
|
||||
Array.from(
|
||||
container?.querySelectorAll<HTMLElement>("button, input, select, textarea") ??
|
||||
[],
|
||||
).filter((node) => !node.hasAttribute("disabled"));
|
||||
type RpcInputValue =
|
||||
| string
|
||||
| boolean
|
||||
| null
|
||||
| RpcInputValue[]
|
||||
| { [key: string]: RpcInputValue }
|
||||
| undefined;
|
||||
|
||||
function valueFor(field: RpcField, value: string | boolean): unknown {
|
||||
function valueFor(field: RpcField, value: string | boolean): RpcInputValue {
|
||||
if (field.kind === "boolean") return value === true;
|
||||
if (field.kind === "json")
|
||||
return value ? JSON.parse(value as string) : undefined;
|
||||
return value ? (JSON.parse(value as string) as RpcInputValue) : undefined;
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
@@ -27,19 +29,17 @@ export function CommandFormDialog({
|
||||
pending?: boolean;
|
||||
commandError?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
||||
const [validationError, setValidationError] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (!command) return;
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setValues({});
|
||||
setValidationError(undefined);
|
||||
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
|
||||
const dialog = ref.current;
|
||||
dialog?.showModal();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
opener.current?.focus();
|
||||
if (dialog?.open) dialog.close();
|
||||
};
|
||||
}, [command]);
|
||||
if (!command) return null;
|
||||
@@ -63,109 +63,90 @@ export function CommandFormDialog({
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="extension-backdrop">
|
||||
<section
|
||||
ref={ref}
|
||||
className="extension"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="command-form-title"
|
||||
aria-busy={pending}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
const items = controls(ref.current),
|
||||
first = items[0],
|
||||
last = items[items.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="extension-eyebrow">Pi RPC command</p>
|
||||
<h2 id="command-form-title">/{command.command}</h2>
|
||||
<p>{command.description}</p>
|
||||
{fields.map((field) => (
|
||||
<label key={field.name}>
|
||||
{field.label}
|
||||
{field.kind === "boolean" ? (
|
||||
<input
|
||||
aria-label={field.label}
|
||||
type="checkbox"
|
||||
checked={values[field.name] === true}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : field.kind === "enum" ? (
|
||||
<select
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{field.options?.map((option) => (
|
||||
<option key={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.kind === "json" ? (
|
||||
<textarea
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{(validationError || commandError) && (
|
||||
<p role="alert">{validationError || commandError}</p>
|
||||
)}
|
||||
<div className="extension-options">
|
||||
<button disabled={pending} onClick={submit}>
|
||||
{pending ? "Running…" : "Run command"}
|
||||
</button>
|
||||
<button className="quiet" disabled={pending} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<dialog
|
||||
ref={ref}
|
||||
className="extension"
|
||||
aria-labelledby="command-form-title"
|
||||
aria-busy={pending}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<p className="extension-eyebrow">Pi RPC command</p>
|
||||
<h2 id="command-form-title">/{command.command}</h2>
|
||||
<p>{command.description}</p>
|
||||
{fields.map((field) => (
|
||||
<label key={field.name}>
|
||||
{field.label}
|
||||
{field.kind === "boolean" ? (
|
||||
<input
|
||||
aria-label={field.label}
|
||||
type="checkbox"
|
||||
checked={values[field.name] === true}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : field.kind === "enum" ? (
|
||||
<select
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{field.options?.map((option) => (
|
||||
<option key={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.kind === "json" ? (
|
||||
<textarea
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{(validationError || commandError) && (
|
||||
<p role="alert">{validationError || commandError}</p>
|
||||
)}
|
||||
<div className="extension-options">
|
||||
<button disabled={pending} onClick={submit}>
|
||||
{pending ? "Running…" : "Run command"}
|
||||
</button>
|
||||
<button className="quiet" disabled={pending} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Model, RuntimeView, TodoTask } from "../workspace/types";
|
||||
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
import { ThinkingSelector } from "./ThinkingSelector";
|
||||
|
||||
type SessionStatus = {
|
||||
label: string;
|
||||
@@ -140,6 +141,7 @@ export function ConversationWorkspace({
|
||||
onScroll,
|
||||
onModel,
|
||||
onThinking,
|
||||
focusRequest = 0,
|
||||
isSubmitting = false,
|
||||
isAborting = false,
|
||||
isModelUpdating = false,
|
||||
@@ -155,6 +157,7 @@ export function ConversationWorkspace({
|
||||
onScroll: (atBottom: boolean, scrollTop: number) => void;
|
||||
onModel: (model: Model) => Promise<boolean>;
|
||||
onThinking: (level: string) => Promise<boolean>;
|
||||
focusRequest?: number;
|
||||
isSubmitting?: boolean;
|
||||
isAborting?: boolean;
|
||||
isModelUpdating?: boolean;
|
||||
@@ -164,6 +167,7 @@ export function ConversationWorkspace({
|
||||
onCommand: (command: RpcCommand) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
|
||||
const runtimeIsWorking =
|
||||
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
|
||||
@@ -185,6 +189,11 @@ export function ConversationWorkspace({
|
||||
const activeTool = runtime.progress.tool
|
||||
? plainStatusText(runtime.progress.tool)
|
||||
: undefined;
|
||||
const selectedThinkingModel = runtime.models.find(
|
||||
(model) =>
|
||||
model.provider === runtime.state.model?.provider &&
|
||||
model.id === runtime.state.model?.id,
|
||||
);
|
||||
const inputTokens = runtime.stats.tokens?.input;
|
||||
const outputTokens = runtime.stats.tokens?.output;
|
||||
const cacheReadTokens = runtime.stats.tokens?.cacheRead;
|
||||
@@ -209,7 +218,17 @@ export function ConversationWorkspace({
|
||||
useEffect(() => {
|
||||
if (runtime.scroll.atBottom && ref.current)
|
||||
ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
|
||||
}, [
|
||||
runtime.messages,
|
||||
runtime.pendingSubmissions,
|
||||
runtime.scroll.atBottom,
|
||||
isWorking,
|
||||
runtime.progress.detail,
|
||||
runtime.progress.tool,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (focusRequest > 0 && !isSubmitting && !locked) promptRef.current?.focus();
|
||||
}, [focusRequest, isSubmitting, locked]);
|
||||
const scrollToLatest = () => {
|
||||
const transcript = ref.current;
|
||||
if (!transcript) return;
|
||||
@@ -240,22 +259,6 @@ export function ConversationWorkspace({
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isWorking && (
|
||||
<div className="agent-working" role="status" aria-live="polite">
|
||||
<span className="working-bars" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<div>
|
||||
<strong>Pi is working</strong>
|
||||
<span>
|
||||
{isSubmitting ? "Sending your prompt…" : runtime.progress.detail}
|
||||
</span>
|
||||
{runtime.progress.tool && <small>Using {runtime.progress.tool}</small>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{runtime.loading && !runtime.messages.length && (
|
||||
<p className="muted">Loading session…</p>
|
||||
)}
|
||||
@@ -276,6 +279,22 @@ export function ConversationWorkspace({
|
||||
<pre>{pending.text}</pre>
|
||||
</article>
|
||||
))}
|
||||
{isWorking && (
|
||||
<div className="agent-working" role="status" aria-live="polite">
|
||||
<span className="working-bars" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<div>
|
||||
<strong>Pi is working</strong>
|
||||
<span>
|
||||
{isSubmitting ? "Sending your prompt…" : runtime.progress.detail}
|
||||
</span>
|
||||
{runtime.progress.tool && <small>Using {runtime.progress.tool}</small>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{!runtime.scroll.atBottom && (
|
||||
<button
|
||||
@@ -330,20 +349,12 @@ export function ConversationWorkspace({
|
||||
onSelect={onModel}
|
||||
pending={isModelUpdating || locked}
|
||||
/>
|
||||
<label>
|
||||
Thinking
|
||||
<select
|
||||
value={runtime.state.thinkingLevel ?? "medium"}
|
||||
disabled={isModelUpdating || locked}
|
||||
onChange={(event) => void onThinking(event.currentTarget.value)}
|
||||
>
|
||||
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
|
||||
(level) => (
|
||||
<option key={level}>{level}</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<ThinkingSelector
|
||||
model={selectedThinkingModel ?? runtime.state.model}
|
||||
value={runtime.state.thinkingLevel ?? "medium"}
|
||||
disabled={isModelUpdating || locked}
|
||||
onChange={(level) => void onThinking(level)}
|
||||
/>
|
||||
</section>
|
||||
<div
|
||||
className={`session-status-strip ${presentedStatus.phase}`}
|
||||
@@ -418,6 +429,7 @@ export function ConversationWorkspace({
|
||||
/>
|
||||
<textarea
|
||||
aria-label="Prompt"
|
||||
ref={promptRef}
|
||||
value={runtime.draft}
|
||||
disabled={isSubmitting || locked}
|
||||
onChange={(event) => onDraft(event.currentTarget.value)}
|
||||
|
||||
@@ -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,137 +1,99 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Extension } from "../workspace/types";
|
||||
|
||||
function controls(container: HTMLElement | null) {
|
||||
return [
|
||||
...(container?.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||
) ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
export function ExtensionDialog({
|
||||
extension,
|
||||
onRespond,
|
||||
extension,
|
||||
onRespond,
|
||||
}: {
|
||||
extension?: Extension;
|
||||
onRespond: (response: Record<string, unknown>) => void;
|
||||
extension?: Extension;
|
||||
onRespond: (response: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const respond = useRef(onRespond);
|
||||
const [value, setValue] = useState("");
|
||||
respond.current = onRespond;
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!extension) return;
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setValue(
|
||||
extension.prefill ?? extension.initialValue ?? extension.text ?? "",
|
||||
);
|
||||
const frame = requestAnimationFrame(() =>
|
||||
controls(ref.current)[0]?.focus(),
|
||||
);
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
respond.current({ cancelled: true });
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls(ref.current);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", keydown);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("keydown", keydown);
|
||||
opener.current?.focus();
|
||||
};
|
||||
}, [extension]);
|
||||
useEffect(() => {
|
||||
if (!extension) return;
|
||||
setValue(
|
||||
extension.prefill ?? extension.initialValue ?? extension.text ?? "",
|
||||
);
|
||||
const dialog = ref.current;
|
||||
dialog?.showModal();
|
||||
return () => {
|
||||
if (dialog?.open) dialog.close();
|
||||
};
|
||||
}, [extension]);
|
||||
|
||||
if (!extension) return null;
|
||||
return (
|
||||
<div className="extension-backdrop">
|
||||
<section
|
||||
ref={ref}
|
||||
className="extension"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="extension-title"
|
||||
>
|
||||
<p className="extension-eyebrow">Pi agent request</p>
|
||||
<h2 id="extension-title">Review request</h2>
|
||||
<div className="extension-request">
|
||||
<p className="extension-request-label">Requested action</p>
|
||||
<p className="extension-request-action">
|
||||
{extension.title ?? "Pi needs input"}
|
||||
</p>
|
||||
{extension.message && (
|
||||
<>
|
||||
<p className="extension-request-label">Request details</p>
|
||||
<p className="extension-request-details">{extension.message}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{extension.method === "select" && (
|
||||
<div className="extension-options">
|
||||
{extension.options?.map((option) => (
|
||||
<button key={option} onClick={() => onRespond({ value: option })}>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{extension.method === "confirm" && (
|
||||
<div className="extension-options">
|
||||
<button onClick={() => onRespond({ confirmed: true })}>
|
||||
Allow request
|
||||
</button>
|
||||
<button
|
||||
className="quiet"
|
||||
onClick={() => onRespond({ confirmed: false })}
|
||||
>
|
||||
Decline request
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{extension.method === "input" && (
|
||||
<>
|
||||
<input
|
||||
aria-label="Extension value"
|
||||
value={value}
|
||||
placeholder={extension.placeholder}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
<button onClick={() => onRespond({ value })}>Submit</button>
|
||||
</>
|
||||
)}
|
||||
{extension.method === "editor" && (
|
||||
<>
|
||||
<textarea
|
||||
aria-label="Extension value"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
<button onClick={() => onRespond({ value })}>Submit</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="quiet"
|
||||
onClick={() => onRespond({ cancelled: true })}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
if (!extension) return null;
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className="extension"
|
||||
aria-labelledby="extension-title"
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onRespond({ cancelled: true });
|
||||
}}
|
||||
>
|
||||
<p className="extension-eyebrow">Pi agent request</p>
|
||||
<h2 id="extension-title">Review request</h2>
|
||||
<div className="extension-request">
|
||||
<p className="extension-request-label">Requested action</p>
|
||||
<p className="extension-request-action">
|
||||
{extension.title ?? "Pi needs input"}
|
||||
</p>
|
||||
{extension.message && (
|
||||
<>
|
||||
<p className="extension-request-label">Request details</p>
|
||||
<p className="extension-request-details">{extension.message}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{extension.method === "select" && (
|
||||
<div className="extension-options">
|
||||
{extension.options?.map((option) => (
|
||||
<button key={option} onClick={() => onRespond({ value: option })}>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{extension.method === "confirm" && (
|
||||
<div className="extension-options">
|
||||
<button onClick={() => onRespond({ confirmed: true })}>
|
||||
Allow request
|
||||
</button>
|
||||
<button
|
||||
className="quiet"
|
||||
onClick={() => onRespond({ confirmed: false })}
|
||||
>
|
||||
Decline request
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{extension.method === "input" && (
|
||||
<>
|
||||
<input
|
||||
aria-label="Extension value"
|
||||
value={value}
|
||||
placeholder={extension.placeholder}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
<button onClick={() => onRespond({ value })}>Submit</button>
|
||||
</>
|
||||
)}
|
||||
{extension.method === "editor" && (
|
||||
<>
|
||||
<textarea
|
||||
aria-label="Extension value"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
<button onClick={() => onRespond({ value })}>Submit</button>
|
||||
</>
|
||||
)}
|
||||
<button className="quiet" onClick={() => onRespond({ cancelled: true })}>
|
||||
Cancel
|
||||
</button>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { describe, expect, it, vi } from "vitest";
|
||||
import type { Model } from "../workspace/types";
|
||||
@@ -76,6 +76,57 @@ describe("ModelSelector", () => {
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it("notifies menu open and every close path", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpen = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(
|
||||
<ModelSelector
|
||||
models={models}
|
||||
onSelect={vi.fn().mockResolvedValue(true)}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
const trigger = screen.getByRole("button", { name: /Current model/i });
|
||||
|
||||
await user.click(trigger);
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(trigger);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onOpen).toHaveBeenCalledTimes(2);
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: /Claude Sonnet/ }));
|
||||
expect(onOpen).toHaveBeenCalledTimes(3);
|
||||
expect(onClose).toHaveBeenCalledTimes(3);
|
||||
|
||||
await user.click(trigger);
|
||||
unmount();
|
||||
expect(onOpen).toHaveBeenCalledTimes(4);
|
||||
expect(onClose).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("supports labeled downward placement for toolbar and Settings reuse", () => {
|
||||
const { container } = render(
|
||||
<ModelSelector
|
||||
models={models}
|
||||
current={{ provider: "openai", id: "organization/gpt-5" }}
|
||||
label="Quick model"
|
||||
placement="down"
|
||||
onSelect={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Quick model openai · GPT 5/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelector(".model-control-down")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the selector open and restores controls when an update fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn().mockResolvedValue(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { AgentState, Model } from "../workspace/types";
|
||||
import "./ModelSelector.css";
|
||||
|
||||
type SelectableModel = Model & { provider: string; id: string };
|
||||
type ModelGroup = { provider: string; models: SelectableModel[] };
|
||||
@@ -87,18 +88,37 @@ export function ModelSelector({
|
||||
current,
|
||||
onSelect,
|
||||
pending = false,
|
||||
disabled = false,
|
||||
label = "Model",
|
||||
placement = "up",
|
||||
className = "",
|
||||
triggerClassName = "",
|
||||
onOpen,
|
||||
onClose,
|
||||
}: {
|
||||
models: Model[];
|
||||
current?: AgentState["model"];
|
||||
onSelect: (model: Model) => Promise<boolean>;
|
||||
pending?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placement?: "up" | "down";
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const openRef = useRef(false);
|
||||
const onOpenRef = useRef(onOpen);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
onOpenRef.current = onOpen;
|
||||
onCloseRef.current = onClose;
|
||||
const labelId = useId();
|
||||
const valueId = useId();
|
||||
const menuId = useId();
|
||||
@@ -124,6 +144,29 @@ export function ModelSelector({
|
||||
? `${current.provider} · ${selectedName ?? "Current model"}`
|
||||
: "Current model";
|
||||
const busy = pending || selecting;
|
||||
const openMenu = () => {
|
||||
if (openRef.current) return;
|
||||
openRef.current = true;
|
||||
setQuery("");
|
||||
setOpen(true);
|
||||
onOpenRef.current?.();
|
||||
};
|
||||
const closeMenu = (restoreFocus = false) => {
|
||||
if (!openRef.current) return;
|
||||
openRef.current = false;
|
||||
setOpen(false);
|
||||
onCloseRef.current?.();
|
||||
if (restoreFocus)
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!openRef.current) return;
|
||||
openRef.current = false;
|
||||
onCloseRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -133,11 +176,10 @@ export function ModelSelector({
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
closeMenu(true);
|
||||
};
|
||||
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("pointerdown", closeOutside);
|
||||
@@ -153,29 +195,31 @@ export function ModelSelector({
|
||||
setSelecting(true);
|
||||
try {
|
||||
if (!(await onSelect(model))) return;
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
closeMenu(true);
|
||||
} finally {
|
||||
setSelecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="model-control" aria-busy={busy}>
|
||||
<span id={labelId}>Model</span>
|
||||
<div
|
||||
className={`model-control model-control-${placement} ${className}`.trim()}
|
||||
aria-busy={busy}
|
||||
>
|
||||
<span id={labelId}>{label}</span>
|
||||
<div className="model-selector" ref={rootRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="model-trigger"
|
||||
className={["model-trigger", triggerClassName].filter(Boolean).join(" ")}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-labelledby={`${labelId} ${valueId}`}
|
||||
disabled={availableModels.length === 0 || busy}
|
||||
disabled={availableModels.length === 0 || busy || disabled}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setOpen((value) => !value);
|
||||
if (openRef.current) closeMenu();
|
||||
else openMenu();
|
||||
}}
|
||||
>
|
||||
<span id={valueId}>{selectedLabel}</span>
|
||||
@@ -219,7 +263,7 @@ export function ModelSelector({
|
||||
current={current}
|
||||
headingId={`${menuId}-provider-${index}`}
|
||||
onSelect={(model) => void chooseModel(model)}
|
||||
disabled={busy}
|
||||
disabled={busy || disabled}
|
||||
/>
|
||||
))}
|
||||
</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";
|
||||
|
||||
const focusableSelector =
|
||||
'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function sessionTitle(session: DirectorySession) {
|
||||
return session.name ?? session.firstMessage ?? "Untitled session";
|
||||
}
|
||||
@@ -26,9 +23,7 @@ function SessionRow({
|
||||
onOpen: (session: DirectorySession) => void;
|
||||
}) {
|
||||
const showPreview = Boolean(
|
||||
session.name &&
|
||||
session.firstMessage &&
|
||||
session.firstMessage !== session.name,
|
||||
session.name && session.firstMessage && session.firstMessage !== session.name,
|
||||
);
|
||||
return (
|
||||
<li>
|
||||
@@ -71,47 +66,6 @@ function SessionResults({
|
||||
);
|
||||
}
|
||||
|
||||
function useDialogFocus(
|
||||
dialogRef: RefObject<HTMLElement | null>,
|
||||
searchRef: RefObject<HTMLInputElement | null>,
|
||||
onClose: () => void,
|
||||
) {
|
||||
const openerRef = useRef<HTMLElement | null>(
|
||||
document.activeElement as HTMLElement | null,
|
||||
);
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
const controls = () => [
|
||||
...(dialog?.querySelectorAll<HTMLElement>(focusableSelector) ?? []),
|
||||
];
|
||||
searchRef.current?.focus();
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls();
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", keydown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", keydown);
|
||||
openerRef.current?.focus();
|
||||
};
|
||||
}, [dialogRef, onClose, searchRef]);
|
||||
}
|
||||
|
||||
export function SessionPicker({
|
||||
sessions,
|
||||
onOpen,
|
||||
@@ -122,55 +76,56 @@ export function SessionPicker({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const dialogRef = useRef<HTMLElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const filteredSessions = useMemo(
|
||||
() => sessions.filter((session) => matchesQuery(session, normalizedQuery)),
|
||||
[normalizedQuery, sessions],
|
||||
);
|
||||
useDialogFocus(dialogRef, searchRef, onClose);
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
dialog?.showModal();
|
||||
return () => {
|
||||
if (dialog?.open) dialog.close();
|
||||
};
|
||||
}, []);
|
||||
const resultLabel = `${filteredSessions.length} saved session${filteredSessions.length === 1 ? "" : "s"}`;
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="session-picker"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="session-picker-title"
|
||||
>
|
||||
<div className="picker-heading">
|
||||
<h2 id="session-picker-title">Saved sessions</h2>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
<div className="session-search">
|
||||
<label className="sr-only" htmlFor="session-search">
|
||||
Search saved sessions
|
||||
</label>
|
||||
<input
|
||||
ref={searchRef}
|
||||
id="session-search"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search by name, message, or ID"
|
||||
aria-controls="session-results"
|
||||
/>
|
||||
</div>
|
||||
<p className="session-result-count" role="status" aria-live="polite">
|
||||
{sessions.length ? resultLabel : "No saved sessions"}
|
||||
</p>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="muted">No saved sessions for this directory.</p>
|
||||
) : (
|
||||
<SessionResults
|
||||
sessions={filteredSessions}
|
||||
query={query}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="session-picker"
|
||||
aria-labelledby="session-picker-title"
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="picker-heading">
|
||||
<h2 id="session-picker-title">Saved sessions</h2>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
<div className="session-search">
|
||||
<label className="sr-only" htmlFor="session-search">
|
||||
Search saved sessions
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
id="session-search"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search by name, message, or ID"
|
||||
aria-controls="session-results"
|
||||
/>
|
||||
</div>
|
||||
<p className="session-result-count" role="status" aria-live="polite">
|
||||
{sessions.length ? resultLabel : "No saved sessions"}
|
||||
</p>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="muted">No saved sessions for this directory.</p>
|
||||
) : (
|
||||
<SessionResults sessions={filteredSessions} query={query} onOpen={onOpen} />
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import type { RuntimeView } from "../workspace/types";
|
||||
|
||||
type Props = {
|
||||
@@ -15,6 +21,7 @@ type Props = {
|
||||
restoreFocus?: boolean;
|
||||
onFocusRestored?: () => void;
|
||||
};
|
||||
|
||||
export function SessionTabs({
|
||||
runtimes,
|
||||
selectedId,
|
||||
@@ -30,12 +37,63 @@ export function SessionTabs({
|
||||
onFocusRestored,
|
||||
}: Props) {
|
||||
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
const newSessionRef = useRef<HTMLButtonElement>(null);
|
||||
const [scrollPosition, setScrollPosition] = useState({
|
||||
left: false,
|
||||
right: false,
|
||||
});
|
||||
const syncScrollPosition = useCallback(() => {
|
||||
const tabs = tabsRef.current;
|
||||
if (!tabs) return;
|
||||
const next = {
|
||||
left: tabs.scrollLeft > 1,
|
||||
right: tabs.scrollLeft + tabs.clientWidth < tabs.scrollWidth - 1,
|
||||
};
|
||||
setScrollPosition((current) =>
|
||||
current.left === next.left && current.right === next.right ? current : next,
|
||||
);
|
||||
}, []);
|
||||
const reveal = useCallback((runtimeId: string) => {
|
||||
refs.current[runtimeId]?.scrollIntoView?.({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (selectedId) reveal(selectedId);
|
||||
}, [selectedId, reveal]);
|
||||
useEffect(() => {
|
||||
const tabs = tabsRef.current;
|
||||
if (!tabs) return;
|
||||
syncScrollPosition();
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? undefined
|
||||
: new ResizeObserver(syncScrollPosition);
|
||||
observer?.observe(tabs);
|
||||
window.addEventListener("resize", syncScrollPosition);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", syncScrollPosition);
|
||||
};
|
||||
}, [runtimes.length, syncScrollPosition]);
|
||||
useEffect(() => {
|
||||
if (!restoreFocus) return;
|
||||
(selectedId ? refs.current[selectedId] : newSessionRef.current)?.focus();
|
||||
const target = selectedId ? refs.current[selectedId] : newSessionRef.current;
|
||||
target?.focus();
|
||||
if (selectedId) reveal(selectedId);
|
||||
onFocusRestored?.();
|
||||
}, [restoreFocus, selectedId, onFocusRestored]);
|
||||
}, [restoreFocus, selectedId, onFocusRestored, reveal]);
|
||||
function scrollTabs(direction: -1 | 1) {
|
||||
const tabs = tabsRef.current;
|
||||
if (!tabs) return;
|
||||
tabs.scrollBy({
|
||||
behavior: "smooth",
|
||||
left: direction * Math.max(tabs.clientWidth * 0.75, 180),
|
||||
});
|
||||
}
|
||||
function keydown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
let next: number | undefined;
|
||||
if (!runtimes.length) return;
|
||||
@@ -46,13 +104,45 @@ export function SessionTabs({
|
||||
else if (event.key === "End") next = runtimes.length - 1;
|
||||
if (next === undefined) return;
|
||||
event.preventDefault();
|
||||
refs.current[runtimes[next].summary.runtimeId]?.focus();
|
||||
onSelect(runtimes[next].summary.runtimeId);
|
||||
const runtimeId = runtimes[next].summary.runtimeId;
|
||||
refs.current[runtimeId]?.focus();
|
||||
reveal(runtimeId);
|
||||
onSelect(runtimeId);
|
||||
}
|
||||
return (
|
||||
<div className="session-strip">
|
||||
<div className="session-tab-bar" aria-label="Sessions">
|
||||
<div className="session-tabs" role="tablist" aria-label="Open sessions">
|
||||
<button
|
||||
type="button"
|
||||
className="session-scroll-button"
|
||||
aria-label="Scroll sessions left"
|
||||
title="Previous sessions"
|
||||
disabled={!scrollPosition.left}
|
||||
onClick={() => scrollTabs(-1)}
|
||||
>
|
||||
<span aria-hidden="true">‹</span>
|
||||
</button>
|
||||
<div
|
||||
className="session-tabs"
|
||||
role="tablist"
|
||||
aria-label="Open sessions"
|
||||
ref={tabsRef}
|
||||
onScroll={syncScrollPosition}
|
||||
onWheel={(event) => {
|
||||
const tabs = event.currentTarget;
|
||||
if (
|
||||
Math.abs(event.deltaY) <= Math.abs(event.deltaX) ||
|
||||
tabs.scrollWidth <= tabs.clientWidth
|
||||
)
|
||||
return;
|
||||
const atStart = tabs.scrollLeft <= 0;
|
||||
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 1;
|
||||
if ((event.deltaY < 0 && atStart) || (event.deltaY > 0 && atEnd)) return;
|
||||
event.preventDefault();
|
||||
tabs.scrollLeft += event.deltaY;
|
||||
syncScrollPosition();
|
||||
}}
|
||||
>
|
||||
{runtimes.map((runtime, index) => {
|
||||
const summary = runtime.summary;
|
||||
const selected = summary.runtimeId === selectedId;
|
||||
@@ -62,6 +152,7 @@ export function SessionTabs({
|
||||
<div
|
||||
className={`session-tab ${selected ? "selected" : ""}`}
|
||||
key={summary.runtimeId}
|
||||
role="presentation"
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
@@ -72,22 +163,29 @@ export function SessionTabs({
|
||||
ref={(element) => {
|
||||
refs.current[summary.runtimeId] = element;
|
||||
}}
|
||||
onFocus={() => reveal(summary.runtimeId)}
|
||||
onKeyDown={(event) => keydown(event, index)}
|
||||
onClick={() => onSelect(summary.runtimeId)}
|
||||
>
|
||||
<span>{summary.label}</span>
|
||||
<small className={summary.state}>
|
||||
{summary.state}
|
||||
{summary.queueCount ? ` · ${summary.queueCount} queued` : ""}
|
||||
</small>
|
||||
{runtime.unread && (
|
||||
<i className="unread-dot" aria-label="Unread activity" />
|
||||
)}
|
||||
{summary.attention && (
|
||||
<b className="attention-dot" aria-label="Needs attention">
|
||||
!
|
||||
</b>
|
||||
)}
|
||||
<span className="session-tab-label">{summary.label}</span>
|
||||
<span className="session-tab-meta">
|
||||
<i
|
||||
className={`session-state-dot ${summary.state}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<small>
|
||||
{summary.state}
|
||||
{summary.queueCount ? ` · ${summary.queueCount} queued` : ""}
|
||||
</small>
|
||||
{runtime.unread && (
|
||||
<i className="unread-dot" aria-label="Unread activity" />
|
||||
)}
|
||||
{summary.attention && (
|
||||
<b className="attention-dot" aria-label="Needs attention">
|
||||
!
|
||||
</b>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="renew-session"
|
||||
@@ -127,19 +225,29 @@ export function SessionTabs({
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="new-session-tab"
|
||||
ref={newSessionRef}
|
||||
aria-label={busy ? "Starting new session" : "New session"}
|
||||
title="New session"
|
||||
disabled={busy}
|
||||
onClick={onNew}
|
||||
type="button"
|
||||
className="session-scroll-button"
|
||||
aria-label="Scroll sessions right"
|
||||
title="More sessions"
|
||||
disabled={!scrollPosition.right}
|
||||
onClick={() => scrollTabs(1)}
|
||||
>
|
||||
<span aria-hidden="true">{busy ? "…" : "+"}</span>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
<div className="session-tab-actions" aria-label="Session actions">
|
||||
<button
|
||||
className="new-session-tab"
|
||||
ref={newSessionRef}
|
||||
aria-label={busy ? "Starting new session" : "New session"}
|
||||
disabled={busy}
|
||||
onClick={onNew}
|
||||
>
|
||||
<span aria-hidden="true">{busy ? "…" : "+"}</span>
|
||||
<span className="session-action-label">{busy ? "Starting" : "New"}</span>
|
||||
</button>
|
||||
<button className="load-session" disabled={busy} onClick={onResume}>
|
||||
<span aria-hidden="true">⌄</span>
|
||||
{busy ? "Working…" : "Load"}
|
||||
<span className="session-action-label">{busy ? "Working…" : "Load"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.thinking-selector {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: #bbb7b0;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.thinking-trigger {
|
||||
appearance: auto;
|
||||
padding-right: 6px;
|
||||
background-image: none;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ThinkingSelector } from "./ThinkingSelector";
|
||||
|
||||
const model = {
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
minimal: null,
|
||||
low: null,
|
||||
medium: null,
|
||||
high: "high",
|
||||
xhigh: "xhigh",
|
||||
},
|
||||
};
|
||||
|
||||
const fullModel = {
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
minimal: "minimal",
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
xhigh: "xhigh",
|
||||
},
|
||||
};
|
||||
|
||||
describe("ThinkingSelector", () => {
|
||||
it("uses reconciled model options", () => {
|
||||
render(
|
||||
<ThinkingSelector model={model} value="medium" onChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const select = screen.getByRole("combobox", { name: "Thinking" });
|
||||
expect(select).toHaveValue("off");
|
||||
expect(
|
||||
screen.getAllByRole("option").map((option) => option.textContent),
|
||||
).toEqual(["Off", "High", "XHigh"]);
|
||||
});
|
||||
|
||||
it("reports native selection changes", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ThinkingSelector model={fullModel} value="off" onChange={onChange} />,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Thinking" }), {
|
||||
target: { value: "high" },
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("high");
|
||||
});
|
||||
|
||||
it("supports labels, disabled state, and control classes", () => {
|
||||
render(
|
||||
<ThinkingSelector
|
||||
label="Default thinking"
|
||||
model={model}
|
||||
value="off"
|
||||
disabled
|
||||
className="compact"
|
||||
controlClassName="toolbar-control"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByRole("combobox", { name: "Default thinking" });
|
||||
expect(select).toBeDisabled();
|
||||
expect(select).toHaveClass("toolbar-control");
|
||||
expect(select.closest("label")).toHaveClass("compact");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { reconcileThinkingLevel, thinkingLevelOptions } from "../model-catalog";
|
||||
import type { Model } from "../workspace/types";
|
||||
import "./ModelSelector.css";
|
||||
import "./ThinkingSelector.css";
|
||||
|
||||
type ThinkingSelectorProps = {
|
||||
model?: Model;
|
||||
label?: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
controlClassName?: string;
|
||||
onChange: (level: string) => void;
|
||||
};
|
||||
|
||||
export function ThinkingSelector({
|
||||
model,
|
||||
label = "Thinking",
|
||||
value,
|
||||
disabled = false,
|
||||
className,
|
||||
controlClassName,
|
||||
onChange,
|
||||
}: ThinkingSelectorProps) {
|
||||
const options = thinkingLevelOptions(model);
|
||||
return (
|
||||
<label
|
||||
className={["thinking-selector", className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<select
|
||||
className={[
|
||||
"model-trigger",
|
||||
"thinking-trigger",
|
||||
controlClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
value={reconcileThinkingLevel(model, value)}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option value={option.value} key={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DirectorySidebar } from "./DirectorySidebar";
|
||||
@@ -75,7 +75,13 @@ describe("multi-session navigation", () => {
|
||||
expect(closeFirst.previousElementSibling).toBe(renewFirst);
|
||||
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
|
||||
const newSession = screen.getByRole("button", { name: "New session" });
|
||||
expect(tablist.nextElementSibling).toBe(newSession);
|
||||
expect(tablist.previousElementSibling).toHaveAccessibleName(
|
||||
"Scroll sessions left",
|
||||
);
|
||||
expect(tablist.nextElementSibling).toHaveAccessibleName(
|
||||
"Scroll sessions right",
|
||||
);
|
||||
expect(newSession.closest(".session-tab-actions")).not.toBeNull();
|
||||
await userEvent.click(newSession);
|
||||
expect(onNew).toHaveBeenCalledOnce();
|
||||
await userEvent.click(tabs[0]);
|
||||
@@ -94,6 +100,61 @@ describe("multi-session navigation", () => {
|
||||
);
|
||||
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
||||
});
|
||||
it("scrolls overflowing sessions and reveals the selected tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
const runtimes = ["a", "b", "c", "d", "e"].map((id) =>
|
||||
runtimeView(summary(id)),
|
||||
);
|
||||
const props = {
|
||||
runtimes,
|
||||
onSelect: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onRenew: vi.fn(),
|
||||
onNew: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<SessionTabs {...props} selectedId="a" />);
|
||||
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
|
||||
const scrollBy = vi.fn();
|
||||
Object.defineProperties(tablist, {
|
||||
scrollWidth: { configurable: true, value: 800 },
|
||||
clientWidth: { configurable: true, value: 240 },
|
||||
scrollLeft: { configurable: true, value: 0, writable: true },
|
||||
scrollBy: { configurable: true, value: scrollBy },
|
||||
});
|
||||
fireEvent.scroll(tablist);
|
||||
|
||||
const previous = screen.getByRole("button", {
|
||||
name: "Scroll sessions left",
|
||||
});
|
||||
const next = screen.getByRole("button", { name: "Scroll sessions right" });
|
||||
expect(previous).toBeDisabled();
|
||||
expect(next).toBeEnabled();
|
||||
await user.click(next);
|
||||
expect(scrollBy).toHaveBeenCalledWith({ behavior: "smooth", left: 180 });
|
||||
|
||||
(tablist as HTMLDivElement).scrollLeft = 300;
|
||||
fireEvent.wheel(tablist, { deltaX: 0, deltaY: -80 });
|
||||
expect((tablist as HTMLDivElement).scrollLeft).toBe(220);
|
||||
|
||||
(tablist as HTMLDivElement).scrollLeft = 560;
|
||||
fireEvent.scroll(tablist);
|
||||
expect(previous).toBeEnabled();
|
||||
expect(next).toBeDisabled();
|
||||
|
||||
const finalTab = screen.getByRole("tab", { name: /^e idle$/ });
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(finalTab, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
rerender(<SessionTabs {...props} selectedId="e" />);
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
});
|
||||
it("announces close progress only on the closing session", () => {
|
||||
render(
|
||||
<SessionTabs
|
||||
@@ -178,6 +239,34 @@ describe("multi-session navigation", () => {
|
||||
await user.click(screen.getByText("Tool result · read"));
|
||||
expect(details).toHaveAttribute("open");
|
||||
});
|
||||
it("uses omitted-reasoning state model metadata when catalog lacks current model", async () => {
|
||||
const runtime = runtimeView(summary("thinking"));
|
||||
runtime.state = {
|
||||
model: {
|
||||
provider: "provider",
|
||||
id: "current",
|
||||
thinkingLevelMap: { high: null, xhigh: "xhigh" },
|
||||
},
|
||||
};
|
||||
render(
|
||||
<ConversationWorkspace
|
||||
runtime={runtime}
|
||||
onDraft={vi.fn()}
|
||||
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", () => {
|
||||
const runtime = runtimeView(summary("status", "streaming"));
|
||||
runtime.summary.queueCount = 2;
|
||||
@@ -232,6 +321,47 @@ describe("multi-session navigation", () => {
|
||||
expect(modelControls.nextElementSibling).toBe(status);
|
||||
expect(status.nextElementSibling).toBe(composer);
|
||||
});
|
||||
it("keeps working activity at the transcript tail and follows it from the bottom", () => {
|
||||
const idle = runtimeView(summary("activity"));
|
||||
idle.messages = [{ role: "assistant", content: "Partial response" }];
|
||||
const props = {
|
||||
onDraft: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onAbort: vi.fn(),
|
||||
onScroll: vi.fn(),
|
||||
onModel: vi.fn().mockResolvedValue(true),
|
||||
onThinking: vi.fn().mockResolvedValue(true),
|
||||
onCommand: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<ConversationWorkspace runtime={idle} {...props} />,
|
||||
);
|
||||
const transcript = screen.getByRole("region", { name: "Conversation" });
|
||||
Object.defineProperty(transcript, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 600,
|
||||
});
|
||||
transcript.scrollTop = 125;
|
||||
|
||||
rerender(
|
||||
<ConversationWorkspace
|
||||
runtime={{
|
||||
...idle,
|
||||
summary: { ...idle.summary, state: "streaming" },
|
||||
progress: {
|
||||
phase: "working",
|
||||
detail: "Thinking and preparing a response",
|
||||
toolCount: 0,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
const activity = screen.getByText("Pi is working").closest(".agent-working");
|
||||
expect(transcript.lastElementChild).toBe(activity);
|
||||
expect(transcript.scrollTop).toBe(600);
|
||||
});
|
||||
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onScroll = vi.fn();
|
||||
@@ -351,29 +481,16 @@ describe("multi-session navigation", () => {
|
||||
await user.type(search, "missing");
|
||||
expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument();
|
||||
});
|
||||
it("focuses search, traps focus, and restores the opener", async () => {
|
||||
const user = userEvent.setup();
|
||||
const opener = document.createElement("button");
|
||||
document.body.append(opener);
|
||||
opener.focus();
|
||||
it("opens a native modal and handles cancellation", () => {
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(
|
||||
<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />,
|
||||
);
|
||||
const search = screen.getByRole("searchbox", {
|
||||
name: "Search saved sessions",
|
||||
});
|
||||
const close = screen.getByRole("button", { name: "Close" });
|
||||
expect(search).toHaveFocus();
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(close).toHaveFocus();
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(search).toHaveFocus();
|
||||
await user.keyboard("{Escape}");
|
||||
render(<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />);
|
||||
const dialog = screen.getByRole("dialog", { name: "Saved sessions" });
|
||||
expect(dialog).toHaveAttribute("open");
|
||||
expect(
|
||||
screen.getByRole("searchbox", { name: "Search saved sessions" }),
|
||||
).toHaveFocus();
|
||||
fireEvent(dialog, new Event("cancel", { cancelable: true }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(opener).toHaveFocus();
|
||||
document.body.removeChild(opener);
|
||||
});
|
||||
it("labels permission requests with the action and exact details", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -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
+1311
-202
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,648 @@
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; background: transparent; }
|
||||
.quick-pane {
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
color: #f4f2ee;
|
||||
font: 14px/1.4 system-ui, sans-serif;
|
||||
background: radial-gradient(circle at top, #46494f, #23252a 62%);
|
||||
border: 1px solid rgba(255,255,255,.16);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 22px 70px rgba(0,0,0,.55);
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.quick-pane {
|
||||
position: relative;
|
||||
container-type: size;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
color: #e6e3df;
|
||||
font:
|
||||
14px / 1.4 Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
background: rgba(34, 34, 34, 0.98);
|
||||
border: 1px solid #4b4b4b;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.quick-pane-toolbar,
|
||||
.quick-pane-primary-controls,
|
||||
.quick-pane-header-actions,
|
||||
.quick-pane-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quick-pane-titlebar {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
flex: 0 0 20px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid #4b4b4b;
|
||||
color: #aaa39a;
|
||||
cursor: grab;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.quick-pane-titlebar:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.quick-pane-toolbar {
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quick-pane-primary-controls,
|
||||
.quick-pane-header-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quick-pane-primary-controls {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-pane-header-actions {
|
||||
flex: 0 0 auto;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.quick-pane-field {
|
||||
gap: 4px;
|
||||
color: #bbb7b0;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quick-pane-model-selector {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
color: #bbb7b0;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quick-pane-catalog-overlay {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
color: #f1e4d1;
|
||||
background: rgba(34, 34, 34, 0.98);
|
||||
border-radius: inherit;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.quick-pane-catalog-overlay p {
|
||||
max-width: 440px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quick-pane-catalog-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 3px solid rgba(240, 163, 71, 0.28);
|
||||
border-top-color: #f0a347;
|
||||
border-radius: 50%;
|
||||
animation: quick-pane-catalog-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes quick-pane-catalog-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-pane .model-menu {
|
||||
width: min(400px, calc(100cqw - 24px));
|
||||
max-height: min(350px, calc(100cqh - 74px));
|
||||
color: #e6e3df;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.quick-pane .model-option {
|
||||
display: grid;
|
||||
padding: 6px 7px;
|
||||
border: 1px solid transparent;
|
||||
color: #f2ece4;
|
||||
background: #35322e;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quick-pane .model-option:hover {
|
||||
border-color: #8c6338;
|
||||
background: #44382d;
|
||||
}
|
||||
|
||||
.quick-pane .model-option[aria-selected="true"] {
|
||||
border-color: #f0a347;
|
||||
background: #4b3420;
|
||||
}
|
||||
|
||||
.quick-pane-thinking {
|
||||
display: grid;
|
||||
flex: 0 0 143px;
|
||||
grid-template-columns: auto minmax(84px, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quick-pane .compact-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-pane button {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.quick-pane button {
|
||||
flex: 0 0 auto;
|
||||
color: #25180b;
|
||||
background: #f0a347;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-pane button:hover {
|
||||
background: #ffc170;
|
||||
}
|
||||
|
||||
.quick-pane button:disabled {
|
||||
opacity: 0.58;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.quick-pane button:focus-visible {
|
||||
outline: 3px solid #f0a347;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.quick-pane button.quick-pane-toolbar-control,
|
||||
.quick-pane .model-trigger.quick-pane-toolbar-control,
|
||||
.quick-pane .thinking-trigger.quick-pane-toolbar-control {
|
||||
box-sizing: border-box;
|
||||
height: 27px;
|
||||
min-width: 48px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #70522f;
|
||||
border-radius: 5px;
|
||||
color: #e6e3df;
|
||||
background: #353535;
|
||||
font:
|
||||
650 11px / 1 Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-pane .model-trigger.quick-pane-toolbar-control,
|
||||
.quick-pane .thinking-trigger.quick-pane-toolbar-control {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.quick-pane .quick-pane-toolbar-control:hover:not(:disabled) {
|
||||
border-color: #d88735;
|
||||
background: #44382d;
|
||||
}
|
||||
|
||||
.quick-pane .quick-pane-toolbar-control:focus-visible {
|
||||
outline: 3px solid #f0a347;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.quick-pane .quick-pane-toolbar-control:disabled {
|
||||
opacity: 0.58;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.quick-pane-request:focus {
|
||||
outline: 3px solid #f0a347;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.quick-pane-exchange {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
border: 1px solid #4b4b4b;
|
||||
border-radius: 7px;
|
||||
background: #242424;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.quick-pane-exchange.empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-pane-transcript {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quick-pane-user-row {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.quick-pane-user-message,
|
||||
.quick-pane-assistant-message {
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.quick-pane-user-message {
|
||||
width: fit-content;
|
||||
max-width: 78%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #66513a;
|
||||
border-radius: 12px 12px 3px 12px;
|
||||
background: #3a3026;
|
||||
}
|
||||
|
||||
.quick-pane-user-message p,
|
||||
.quick-pane-notice p,
|
||||
.quick-pane-system-notice {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-author {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: #bdb7ae;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #484848;
|
||||
border-radius: 7px;
|
||||
background: #292929;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-elapsed,
|
||||
.quick-pane-assistant-activity {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #96918b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-activity {
|
||||
color: #c5beb5;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-footer {
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #484848;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-footer button {
|
||||
padding: 0;
|
||||
color: #ffc170;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-footer button:hover {
|
||||
color: #fff0d8;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message p,
|
||||
.quick-pane-assistant-message ul,
|
||||
.quick-pane-assistant-message ol,
|
||||
.quick-pane-assistant-message blockquote,
|
||||
.quick-pane-assistant-message pre,
|
||||
.quick-pane-assistant-message table {
|
||||
margin-block: 0 9px;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message ul,
|
||||
.quick-pane-assistant-message ol {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message a {
|
||||
color: #ffc170;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message code {
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
color: #f3ded0;
|
||||
background: #1d1d1d;
|
||||
font:
|
||||
0.9em / 1.45 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message pre {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid #414141;
|
||||
border-radius: 6px;
|
||||
padding: 9px;
|
||||
background: #1d1d1d;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message blockquote {
|
||||
border-left: 3px solid #80613e;
|
||||
padding-left: 9px;
|
||||
color: #c6c0b8;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-message th,
|
||||
.quick-pane-assistant-message td {
|
||||
border: 1px solid #4b4b4b;
|
||||
padding: 4px 7px;
|
||||
}
|
||||
|
||||
.quick-pane-activity {
|
||||
display: flex;
|
||||
min-height: 22px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline: 3px;
|
||||
color: #c5beb5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.typing-dots i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #f0a347;
|
||||
animation: quick-pane-dot 1.15s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-dots i:nth-child(2) {
|
||||
animation-delay: 0.14s;
|
||||
}
|
||||
|
||||
.typing-dots i:nth-child(3) {
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
|
||||
@keyframes quick-pane-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typing-dots i {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-pane-notice {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border: 1px solid #65533e;
|
||||
border-radius: 7px;
|
||||
padding: 8px 10px;
|
||||
color: #f1e4d1;
|
||||
background: #3b3228;
|
||||
}
|
||||
|
||||
.quick-pane-notice strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.quick-pane-notice.error {
|
||||
border-color: #70424a;
|
||||
color: #ffd7db;
|
||||
background: #3d252b;
|
||||
}
|
||||
|
||||
.quick-pane-notice button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.quick-pane-system-notice {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 8px;
|
||||
color: #c5beb5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quick-pane-exchange .extension {
|
||||
width: min(400px, calc(100cqw - 24px));
|
||||
max-height: calc(100cqh - 48px);
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #65533e;
|
||||
border-radius: 7px;
|
||||
background: #302b25;
|
||||
}
|
||||
|
||||
.quick-pane-exchange .extension h2,
|
||||
.quick-pane-exchange .extension p {
|
||||
margin: 0 0 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quick-pane-composer {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
height: 120px;
|
||||
min-height: 110px;
|
||||
max-height: 130px;
|
||||
flex: 0 0 120px;
|
||||
}
|
||||
|
||||
.quick-pane-request {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 11px 150px 29px 11px;
|
||||
resize: none;
|
||||
border: 1px solid #55514c;
|
||||
border-radius: 8px;
|
||||
color: #e6e3df;
|
||||
background: #292929;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.quick-pane-request[readonly] {
|
||||
color: #d1cbc4;
|
||||
background: #272727;
|
||||
}
|
||||
|
||||
.quick-pane-composer-actions {
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
bottom: 9px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quick-pane-send {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 9px;
|
||||
width: 34px;
|
||||
height: 30px;
|
||||
padding: 0 !important;
|
||||
border-radius: 7px !important;
|
||||
font-size: 20px !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.quick-pane-composer-hint {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
bottom: 8px;
|
||||
color: #96918b;
|
||||
font-size: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.quick-pane {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.quick-pane .full-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-pane .compact-label {
|
||||
display: inline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.quick-pane-toolbar,
|
||||
.quick-pane-primary-controls,
|
||||
.quick-pane-header-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quick-pane-model {
|
||||
flex-basis: 130px;
|
||||
}
|
||||
|
||||
.quick-pane-thinking {
|
||||
flex-basis: 74px;
|
||||
}
|
||||
|
||||
.quick-pane-field {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.quick-pane .thinking-trigger {
|
||||
padding-inline: 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.quick-pane-user-message {
|
||||
max-width: 88%;
|
||||
}
|
||||
}
|
||||
.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 { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
if (!HTMLDialogElement.prototype.showModal) {
|
||||
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||
this.open = true;
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function close() {
|
||||
this.open = false;
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
it("preserves reasoning model metadata from runtime snapshots", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot: {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 1,
|
||||
runtime: runtime("a"),
|
||||
state: {
|
||||
data: {
|
||||
model: {
|
||||
provider: "provider",
|
||||
id: "reasoning",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { high: null, xhigh: "xhigh" },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
provider: "provider",
|
||||
id: "reasoning",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { high: null, xhigh: "xhigh" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
extensions: [],
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.state.model).toEqual({
|
||||
provider: "provider",
|
||||
id: "reasoning",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { high: null, xhigh: "xhigh" },
|
||||
});
|
||||
expect(state.runtimesById.a.models[0]).toEqual(
|
||||
state.runtimesById.a.state.model,
|
||||
);
|
||||
});
|
||||
it("removes the optimistic submission when Pi returns its structured user message", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
|
||||
@@ -60,19 +60,23 @@ export type Message = {
|
||||
| string
|
||||
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
|
||||
};
|
||||
type ThinkingLevel =
|
||||
| "off"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh"
|
||||
| "max";
|
||||
|
||||
export type AgentState = {
|
||||
isStreaming?: boolean;
|
||||
thinkingLevel?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
sessionName?: string;
|
||||
sessionId?: string;
|
||||
messageCount?: number;
|
||||
pendingMessageCount?: number;
|
||||
model?: {
|
||||
provider?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
contextWindow?: number;
|
||||
};
|
||||
model?: Model & { contextWindow?: number };
|
||||
};
|
||||
export type SessionStats = {
|
||||
tokens?: {
|
||||
@@ -89,7 +93,13 @@ export type SessionStats = {
|
||||
};
|
||||
};
|
||||
export type Command = { name?: string; description?: string };
|
||||
export type Model = { provider?: string; id?: string; name?: string };
|
||||
export type Model = {
|
||||
provider?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
|
||||
};
|
||||
export type Extension = {
|
||||
id?: string;
|
||||
method?: string;
|
||||
@@ -228,8 +238,8 @@ export type QuickPaneSettings = {
|
||||
followUpProvider: string;
|
||||
followUpModel: string;
|
||||
instructions: string;
|
||||
maximumSeconds: number;
|
||||
maximumToolCalls: number;
|
||||
defaultThinkingLevel: string;
|
||||
};
|
||||
|
||||
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 { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { initialWorkspaceState, workspaceReducer } from "./reducer";
|
||||
import {
|
||||
@@ -28,6 +27,9 @@ export function useWorkspace() {
|
||||
const legacyMigrationPending = useRef(false);
|
||||
const disposed = useRef(true);
|
||||
const refreshGeneration = useRef(0);
|
||||
const workspaceReady = useRef(false);
|
||||
const pendingPromotedRuntime = useRef<string | undefined>(undefined);
|
||||
const consumingPromotion = useRef(false);
|
||||
|
||||
const loadSnapshot = useCallback(
|
||||
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
|
||||
@@ -150,10 +152,44 @@ export function useWorkspace() {
|
||||
[loadSnapshot],
|
||||
);
|
||||
|
||||
const consumePromotedRuntime = useCallback(() => {
|
||||
if (!workspaceReady.current || consumingPromotion.current) return;
|
||||
consumingPromotion.current = true;
|
||||
void (async () => {
|
||||
while (pendingPromotedRuntime.current && !disposed.current) {
|
||||
const runtimeId = pendingPromotedRuntime.current;
|
||||
pendingPromotedRuntime.current = undefined;
|
||||
await refresh();
|
||||
if (disposed.current) return;
|
||||
dispatch({ type: "selectRuntime", runtimeId });
|
||||
await loadSnapshot(runtimeId);
|
||||
if (disposed.current) return;
|
||||
await invoke("consume_pending_promoted_runtime", { runtimeId });
|
||||
}
|
||||
})()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
consumingPromotion.current = false;
|
||||
if (pendingPromotedRuntime.current) consumePromotedRuntime();
|
||||
});
|
||||
}, [loadSnapshot, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
disposed.current = false;
|
||||
workspaceReady.current = false;
|
||||
let stop: (() => void) | undefined;
|
||||
let stopNewRuntime: (() => void) | undefined;
|
||||
let stopPromotedRuntime: (() => void) | undefined;
|
||||
const promotedListenerReady = listen<{ runtimeId: string }>(
|
||||
"workspace-promoted-runtime",
|
||||
(message) => {
|
||||
pendingPromotedRuntime.current = message.payload.runtimeId;
|
||||
consumePromotedRuntime();
|
||||
},
|
||||
).then((unlisten) => {
|
||||
if (disposed.current) unlisten();
|
||||
else stopPromotedRuntime = unlisten;
|
||||
});
|
||||
void listen<{
|
||||
phase: "starting" | "ready" | "error";
|
||||
runtimeId?: string;
|
||||
@@ -184,10 +220,22 @@ export function useWorkspace() {
|
||||
void listen<HostEvent>("workspace-bridge", (message) => {
|
||||
if (disposed.current) return;
|
||||
const host = message.payload;
|
||||
const runtimeId =
|
||||
host.kind === "event" ? host.event.data?.runtimeId : undefined;
|
||||
if (
|
||||
runtimeId &&
|
||||
!stateRef.current.runtimesById[runtimeId] &&
|
||||
[
|
||||
"runtime_event",
|
||||
"runtime_diagnostic",
|
||||
"runtime_recovery",
|
||||
"runtime_closing",
|
||||
].includes(host.kind === "event" ? (host.event.type ?? "") : "")
|
||||
)
|
||||
return;
|
||||
dispatch({ type: "hostEvent", host });
|
||||
if (host.kind === "resetRequired") void refresh(undefined, true);
|
||||
else if (host.kind === "event") {
|
||||
const runtimeId = host.event.data?.runtimeId;
|
||||
const eventType = host.event.type;
|
||||
const piType = String(host.event.data?.eventData?.event?.type ?? "");
|
||||
if (
|
||||
@@ -197,6 +245,7 @@ export function useWorkspace() {
|
||||
"runtime_recovered",
|
||||
"runtime_renewed",
|
||||
"runtime_restore_failed",
|
||||
"runtime_promoted",
|
||||
].includes(eventType ?? "")
|
||||
) {
|
||||
void refresh();
|
||||
@@ -215,10 +264,17 @@ export function useWorkspace() {
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
await promotedListenerReady;
|
||||
const loaded = await loadPresentation();
|
||||
if (disposed.current) return;
|
||||
await refresh(loaded);
|
||||
if (disposed.current) return;
|
||||
workspaceReady.current = true;
|
||||
const runtimeId = await invoke<string | null>(
|
||||
"get_pending_promoted_runtime",
|
||||
).catch(() => null);
|
||||
if (runtimeId) pendingPromotedRuntime.current = runtimeId;
|
||||
consumePromotedRuntime();
|
||||
initialized.current = true;
|
||||
} catch (error) {
|
||||
if (!disposed.current)
|
||||
@@ -232,7 +288,6 @@ export function useWorkspace() {
|
||||
});
|
||||
}
|
||||
})();
|
||||
const appWindow = getCurrentWindow();
|
||||
const visibility = () =>
|
||||
dispatch({
|
||||
type: "setWindowActive",
|
||||
@@ -243,17 +298,18 @@ export function useWorkspace() {
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
return () => {
|
||||
disposed.current = true;
|
||||
workspaceReady.current = false;
|
||||
refreshGeneration.current += 1;
|
||||
stop?.();
|
||||
stopNewRuntime?.();
|
||||
stopPromotedRuntime?.();
|
||||
void invoke("unsubscribe_workspace").catch(() => {});
|
||||
window.removeEventListener("focus", visibility);
|
||||
window.removeEventListener("blur", visibility);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
Object.values(timers.current).forEach(clearTimeout);
|
||||
void appWindow;
|
||||
};
|
||||
}, [refresh, scheduleSnapshot]);
|
||||
}, [consumePromotedRuntime, refresh, scheduleSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current) return;
|
||||
|
||||
Reference in New Issue
Block a user