From ad66067a31ea6be1863dd5144f90b617afdff4da Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Wed, 26 Aug 2026 17:59:31 +0200 Subject: [PATCH] fix(quick-pane): refresh runtime model catalog --- test/quick-pane-ui.test.js | 50 +- ui/src/App.css | 121 +- ui/src/App.test.tsx | 823 ++++++++++--- ui/src/App.tsx | 319 +++-- ui/src/components/ModelSelector.css | 129 ++ ui/src/components/ModelSelector.test.tsx | 16 + ui/src/components/ModelSelector.tsx | 20 +- ui/src/model-catalog.ts | 6 - ui/src/quick-pane/QuickPane.test.tsx | 1365 ++++++++++------------ ui/src/quick-pane/QuickPane.tsx | 351 ++++-- ui/src/quick-pane/quick-pane.css | 50 +- 11 files changed, 1985 insertions(+), 1265 deletions(-) create mode 100644 ui/src/components/ModelSelector.css diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js index 43746cd..5d1bb05 100644 --- a/test/quick-pane-ui.test.js +++ b/test/quick-pane-ui.test.js @@ -30,19 +30,21 @@ test("main Settings uses shared catalog selectors with provider derived from mod "utf8", ), ]); - assert.match( - catalog, - /invoke<\{ models\?: Model\[\] \}>\("get_model_catalog"\)/, - ); - assert.match(app, /getModelCatalog\(\)/); - assert.match(app, /Quick model[\s\S]*? { - const identity = modelIdentityFromValue(event.currentTarget.value); - if (!identity) return; - setQuickPaneSettings({ - ...quickPaneSettings, +
+ { + const identity = modelIdentity(model); + if (!identity) return false; + setQuickPaneSettings((current) => ({ + ...current, provider: identity.provider, model: identity.modelId, - }); + })); + return true; }} - > - - {quickPaneModelOptions.map((model) => { - const identity = modelIdentity(model); - if (!identity) return null; - return ( - - ); - })} - + /> Provider comes from selected catalog model. - - +
- + {modelCatalogStatus === "loading" && ( +

Loading model catalog…

+ )} {quickPaneSettingsStatus && (

{quickPaneSettingsStatus}

)} diff --git a/ui/src/components/ModelSelector.css b/ui/src/components/ModelSelector.css new file mode 100644 index 0000000..d582c26 --- /dev/null +++ b/ui/src/components/ModelSelector.css @@ -0,0 +1,129 @@ +.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; + box-sizing: border-box; + 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-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-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; +} diff --git a/ui/src/components/ModelSelector.test.tsx b/ui/src/components/ModelSelector.test.tsx index 7a78407..ed0367a 100644 --- a/ui/src/components/ModelSelector.test.tsx +++ b/ui/src/components/ModelSelector.test.tsx @@ -76,6 +76,22 @@ describe("ModelSelector", () => { await waitFor(() => expect(trigger).toHaveFocus()); }); + it("supports labeled downward placement for toolbar and Settings reuse", () => { + const { container } = render( + , + ); + 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); diff --git a/ui/src/components/ModelSelector.tsx b/ui/src/components/ModelSelector.tsx index 465c37a..ce83bd3 100644 --- a/ui/src/components/ModelSelector.tsx +++ b/ui/src/components/ModelSelector.tsx @@ -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,11 +88,19 @@ export function ModelSelector({ current, onSelect, pending = false, + disabled = false, + label = "Model", + placement = "up", + className = "", }: { models: Model[]; current?: AgentState["model"]; onSelect: (model: Model) => Promise; pending?: boolean; + disabled?: boolean; + label?: string; + placement?: "up" | "down"; + className?: string; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); @@ -161,8 +170,11 @@ export function ModelSelector({ }; return ( -
- Model +
+ {label}
diff --git a/ui/src/model-catalog.ts b/ui/src/model-catalog.ts index ecca4a0..543b239 100644 --- a/ui/src/model-catalog.ts +++ b/ui/src/model-catalog.ts @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import type { Model } from "./workspace/types"; export type ModelIdentity = { provider: string; modelId: string }; @@ -66,8 +65,3 @@ export function catalogModels( } return available; } - -export async function getModelCatalog() { - const catalog = await invoke<{ models?: Model[] }>("get_model_catalog"); - return catalog?.models ?? []; -} diff --git a/ui/src/quick-pane/QuickPane.test.tsx b/ui/src/quick-pane/QuickPane.test.tsx index ef83195..0b3b3ca 100644 --- a/ui/src/quick-pane/QuickPane.test.tsx +++ b/ui/src/quick-pane/QuickPane.test.tsx @@ -8,14 +8,17 @@ import { } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { QuickPaneSettings, UiState } from "../workspace/types"; +import type { + Model, + QuickPaneSettings, + RuntimeSnapshot, + UiState, +} from "../workspace/types"; const mocks = vi.hoisted(() => ({ invoke: vi.fn(), listen: vi.fn(), - hide: vi.fn(), onFocusChanged: vi.fn(), - onCloseRequested: vi.fn(), focusHandler: undefined as | ((event: { payload: boolean }) => void) | undefined, @@ -24,11 +27,7 @@ const mocks = vi.hoisted(() => ({ 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: () => ({ - hide: mocks.hide, - onFocusChanged: mocks.onFocusChanged, - onCloseRequested: mocks.onCloseRequested, - }), + getCurrentWindow: () => ({ onFocusChanged: mocks.onFocusChanged }), })); import QuickPane from "./QuickPane"; @@ -43,7 +42,6 @@ const settings: QuickPaneSettings = { maximumSeconds: 60, maximumToolCalls: 6, }; - const uiState: UiState = { version: 1, directoryOrder: [], @@ -53,18 +51,100 @@ const uiState: UiState = { lastSeen: {}, quickPane: settings, }; +const defaultModels: Model[] = [ + { provider: "provider", id: "quick/model", name: "Quick Model" }, +]; -function snapshot(answer: string, isStreaming: boolean) { +function runtimeSnapshot( + models: Model[], + answer = "", + isStreaming = false, + runtime: RuntimeSnapshot["runtime"] = { + runtimeId: "unused", + worktreePath: "/repo", + state: "idle", + label: "Quick", + attention: false, + queueCount: 0, + lastActivity: "now", + openedAt: "now", + }, +): RuntimeSnapshot { return { - runtime: {}, + bridgeInstanceId: "bridge", + latestSeq: 1, + runtime, + models: { data: { models } }, state: { data: { isStreaming } }, - transcript: { - data: { messages: [{ role: "assistant", content: answer }] }, - }, + transcript: answer + ? { data: { messages: [{ role: "assistant", content: answer }] } } + : undefined, extensions: [], }; } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function bridgeMock( + catalogs: Model[][], + activeSnapshot?: ( + index: number, + ) => RuntimeSnapshot | Promise, +) { + let runtimeCount = 0; + const submitted = new Set(); + mocks.invoke.mockImplementation( + (command: string, payload?: { runtimeId?: string; agentId?: string }) => { + if (command === "load_ui_state") return Promise.resolve(uiState); + if (command === "create_quick_runtime") { + runtimeCount += 1; + return Promise.resolve({ + runtime: { + agentId: `agent-${runtimeCount}`, + runtimeId: `runtime-${runtimeCount}`, + }, + }); + } + if (command === "submit_prompt") { + if (payload?.agentId) submitted.add(payload.agentId); + return Promise.resolve(undefined); + } + if (command === "get_session_runtime_snapshot") { + const segments = payload?.runtimeId?.split("-") ?? []; + const index = Number(segments[segments.length - 1] ?? 1) - 1; + if (submitted.has(`agent-${index + 1}`) && activeSnapshot) + return Promise.resolve(activeSnapshot(index)); + return Promise.resolve( + runtimeSnapshot( + catalogs[index] ?? catalogs[catalogs.length - 1] ?? [], + ), + ); + } + return Promise.resolve(undefined); + }, + ); + return { runtimeCount: () => runtimeCount }; +} + +async function preparedTrigger() { + return screen.findByRole("button", { name: /provider ·/i }); +} + +async function ask(text = "Inspect") { + const user = userEvent.setup(); + const input = screen.getByRole("textbox", { name: "Request" }); + await user.type(input, text); + await user.click(screen.getByRole("button", { name: "Send request" })); +} + describe("QuickPane", () => { beforeEach(() => { vi.clearAllMocks(); @@ -74,20 +154,13 @@ describe("QuickPane", () => { mocks.focusHandler = handler; return Promise.resolve(vi.fn()); }); - mocks.onCloseRequested.mockResolvedValue(vi.fn()); }); - it("renders toolbar before composer before exchange", () => { - mocks.invoke.mockImplementation((command: string) => { - if (command === "load_ui_state") return Promise.resolve(uiState); - if (command === "get_model_catalog") - return Promise.resolve({ models: [] }); - return Promise.resolve(undefined); - }); - + it("renders toolbar before composer before exchange", async () => { + bridgeMock([defaultModels]); const { container } = render(); + await preparedTrigger(); const pane = container.querySelector(".quick-pane"); - expect( Array.from(pane?.children ?? []).map((element) => element.className), ).toEqual([ @@ -97,801 +170,217 @@ describe("QuickPane", () => { ]); }); - it("replaces a removed model and submits only the returned catalog model", async () => { + it("keeps downward model menu unclipped while trigger text stays truncatable", async () => { const user = userEvent.setup(); - let catalogCalls = 0; - mocks.invoke.mockImplementation((command: string) => { - if (command === "load_ui_state") return Promise.resolve(uiState); - if (command === "get_model_catalog") { - catalogCalls += 1; - return Promise.resolve({ - models: - catalogCalls === 1 - ? [ - { provider: "provider", id: "quick/model" }, - { provider: "other", id: "other/model" }, - ] - : [ - { provider: "provider", id: "new/model" }, - { provider: "other", id: "other/model" }, - ], - }); - } - if (command === "create_quick_runtime") - return Promise.resolve({ - runtime: { agentId: "agent-1", runtimeId: "runtime-1" }, - }); - if (command === "get_session_runtime_snapshot") - return Promise.resolve({ - runtime: {}, - state: { data: { isStreaming: true } }, - extensions: [], - }); - return Promise.resolve(undefined); - }); + bridgeMock([defaultModels]); + const { container } = render(); + const trigger = await preparedTrigger(); + await user.click(trigger); + const menu = screen.getByRole("dialog", { name: "Choose model" }); + let ancestor = menu.parentElement; + while (ancestor && ancestor !== document.body) { + expect(getComputedStyle(ancestor).overflow).not.toMatch(/hidden|clip/); + ancestor = ancestor.parentElement; + } + const value = trigger.querySelector("span"); + expect(value).toHaveTextContent("provider · Quick Model"); + expect(trigger).toHaveClass("model-trigger"); + expect(container.querySelector(".model-control-down .model-menu")).toBe( + menu, + ); + }); + it("closes an open selector on first Escape and dismisses pane on second", async () => { + const user = userEvent.setup(); + bridgeMock([defaultModels]); render(); - const selector = await screen.findByRole("combobox", { name: "Model" }); + const trigger = await preparedTrigger(); + await user.click(trigger); + await waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus()); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull(); + expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane"); + await waitFor(() => expect(trigger).toHaveFocus()); + + fireEvent.keyDown(window, { key: "Escape" }); await waitFor(() => - expect( - within(selector).getByRole("option", { name: "provider/quick/model" }), - ).toBeInTheDocument(), - ); - - await act(async () => mocks.focusHandler?.({ payload: true })); - - expect( - await within(selector).findByRole("option", { - name: "provider/new/model", - }), - ).toBeInTheDocument(); - expect( - within(selector).queryByRole("option", { - name: "provider/quick/model", - }), - ).toBeNull(); - expect(selector).toHaveValue( - JSON.stringify(["provider", "new/model"]), - ); - expect( - screen.getByText( - "Selected model unavailable; using provider/new/model.", - ), - ).toBeInTheDocument(); - - await user.type(screen.getByRole("textbox", { name: "Request" }), "Inspect"); - await user.click(screen.getByRole("button", { name: "Send request" })); - await waitFor(() => - expect(mocks.invoke).toHaveBeenCalledWith("set_model", { - agentId: "agent-1", - provider: "provider", - modelId: "new/model", - }), - ); - expect(mocks.invoke).not.toHaveBeenCalledWith("set_model", { - agentId: "agent-1", - provider: "provider", - modelId: "quick/model", - }); - }); - - it("clears selection and blocks submission for an empty catalog", async () => { - const user = userEvent.setup(); - mocks.invoke.mockImplementation((command: string) => { - if (command === "load_ui_state") return Promise.resolve(uiState); - if (command === "get_model_catalog") return Promise.resolve({ models: [] }); - return Promise.resolve(undefined); - }); - - render(); - const input = await screen.findByRole("textbox", { name: "Request" }); - await screen.findByText("No models are currently available."); - expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue(""); - - await user.type(input, "Must not run"); - expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled(); - fireEvent.keyDown(input, { key: "Enter" }); - - expect(mocks.invoke).not.toHaveBeenCalledWith( - "create_quick_runtime", - expect.anything(), - ); - expect(screen.getByRole("alert")).toHaveTextContent( - "Model catalog is not ready.", + expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane"), ); }); - it("blocks submission while catalog is loading and after refresh failure", async () => { - let rejectCatalog!: (reason?: unknown) => void; - const catalog = new Promise((_, reject) => { - rejectCatalog = reject; - }); - mocks.invoke.mockImplementation((command: string) => { - if (command === "load_ui_state") return Promise.resolve(uiState); - if (command === "get_model_catalog") return catalog; - return Promise.resolve(undefined); - }); - - render(); - const input = await screen.findByRole("textbox", { name: "Request" }); - fireEvent.change(input, { target: { value: "Must wait" } }); - expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled(); - fireEvent.keyDown(input, { key: "Enter" }); - expect(mocks.invoke).not.toHaveBeenCalledWith( - "create_quick_runtime", - expect.anything(), - ); - - await act(async () => rejectCatalog(new Error("offline"))); - expect( - await screen.findByText("Could not load model catalog: Error: offline"), - ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled(); - fireEvent.keyDown(input, { key: "Enter" }); - expect(mocks.invoke).not.toHaveBeenCalledWith( - "create_quick_runtime", - expect.anything(), - ); - }); - - it("fetches a fresh catalog on every pane focus", async () => { - let catalogCalls = 0; - mocks.invoke.mockImplementation((command: string) => { - if (command === "load_ui_state") return Promise.resolve(uiState); - if (command === "get_model_catalog") { - catalogCalls += 1; - return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], - }); - } - return Promise.resolve(undefined); - }); - - render(); - await waitFor(() => expect(catalogCalls).toBe(1)); - await act(async () => mocks.focusHandler?.({ payload: true })); - await waitFor(() => expect(catalogCalls).toBe(2)); - await act(async () => mocks.focusHandler?.({ payload: false })); - expect(catalogCalls).toBe(2); - await act(async () => mocks.focusHandler?.({ payload: true })); - await waitFor(() => expect(catalogCalls).toBe(3)); - }); - - it("keeps the submitted model when catalog refreshes during startup", async () => { - const user = userEvent.setup(); - let catalogCalls = 0; - let resolveCreate: - | ((value: { runtime: { agentId: string; runtimeId: string } }) => void) - | undefined; - const createRuntime = new Promise<{ - runtime: { agentId: string; runtimeId: string }; - }>((resolve) => { - resolveCreate = resolve; - }); - mocks.invoke.mockImplementation((command: string) => { - if (command === "load_ui_state") return Promise.resolve(uiState); - if (command === "get_model_catalog") { - catalogCalls += 1; - return Promise.resolve({ - models: [ - { - provider: "provider", - id: catalogCalls === 1 ? "quick/model" : "new/model", - }, - ], - }); - } - if (command === "create_quick_runtime") return createRuntime; - if (command === "get_session_runtime_snapshot") - return Promise.resolve({ - runtime: {}, - state: { data: { isStreaming: true } }, - extensions: [], - }); - return Promise.resolve(undefined); - }); - - render(); - const input = await screen.findByRole("textbox", { name: "Request" }); - await waitFor(() => expect(catalogCalls).toBe(1)); - await user.type(input, "Inspect with selected model"); - await user.click(screen.getByRole("button", { name: "Send request" })); - await act(async () => mocks.focusHandler?.({ payload: true })); - await waitFor(() => expect(catalogCalls).toBe(2)); - - await act(async () => { - resolveCreate?.({ - runtime: { agentId: "agent-1", runtimeId: "runtime-1" }, - }); - await createRuntime; - }); - - await waitFor(() => - expect(mocks.invoke).toHaveBeenCalledWith("set_model", { - agentId: "agent-1", - provider: "provider", - modelId: "quick/model", - }), - ); - }); - - it("clears a rejected focus reload after success without enabling handoff", async () => { - const user = userEvent.setup(); - let catalogCalls = 0; - mocks.invoke.mockImplementation((command: string) => { - switch (command) { - case "load_ui_state": - return Promise.resolve(uiState); - case "get_model_catalog": - catalogCalls += 1; - if (catalogCalls === 2) - return Promise.reject(new Error("catalog unavailable")); - return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], - }); - case "create_quick_runtime": - return Promise.resolve({ - runtime: { agentId: "agent-1", runtimeId: "runtime-1" }, - }); - case "get_session_runtime_snapshot": - return Promise.resolve({ - runtime: {}, - state: { data: { isStreaming: true } }, - extensions: [], - }); - default: - return Promise.resolve(undefined); - } - }); - - render(); - await waitFor(() => expect(catalogCalls).toBe(1)); - await user.type( - screen.getByPlaceholderText(/Ask a quick question/), - "Inspect", - ); - await user.click(screen.getByRole("button", { name: "Send request" })); - const exchange = screen.getByRole("region", { name: "Quick exchange" }); - expect(within(exchange).getByText("Inspect")).toBeInTheDocument(); - const visualActivity = within(exchange).getByText(/Thinking · \d+s/); - expect(visualActivity.closest(".quick-pane-activity")).toHaveAttribute( - "aria-hidden", - "true", - ); - const activityAnnouncement = within(exchange).getByText("Thinking", { - selector: ".quick-pane-activity-announcement", - }); - expect(activityAnnouncement).toHaveAttribute("aria-live", "polite"); - expect(activityAnnouncement).toHaveTextContent("Thinking"); - expect(activityAnnouncement).not.toHaveTextContent("·"); - - await act(async () => mocks.focusHandler?.({ payload: true })); - expect( - await screen.findByText( - "Could not load model catalog: Error: catalog unavailable", - ), - ).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Continue" })).toBeNull(); - - await act(async () => mocks.focusHandler?.({ payload: true })); - await waitFor(() => - expect( - screen.queryByText( - "Could not load model catalog: Error: catalog unavailable", - ), - ).toBeNull(), - ); - expect(screen.queryByRole("button", { name: "Continue" })).toBeNull(); - }); - - it("allows only one runtime creation while a submission is starting", async () => { - let resolveCreate: - | ((value: { runtime: { agentId: string; runtimeId: string } }) => void) - | undefined; - const createRuntime = new Promise<{ - runtime: { agentId: string; runtimeId: string }; - }>((resolve) => { - resolveCreate = resolve; - }); - mocks.invoke.mockImplementation((command: string) => { - switch (command) { - case "load_ui_state": - return Promise.resolve(uiState); - case "get_model_catalog": - return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], - }); - case "create_quick_runtime": - return createRuntime; - case "get_session_runtime_snapshot": - return Promise.resolve({ - runtime: {}, - state: { data: { isStreaming: true } }, - extensions: [], - }); - default: - return Promise.resolve(undefined); - } - }); - - render(); - const input = await screen.findByPlaceholderText(/Ask a quick question/); - fireEvent.change(input, { target: { value: "Inspect once" } }); - const ask = screen.getByRole("button", { name: "Send request" }); - fireEvent.click(ask); - fireEvent.click(ask); - - expect( - mocks.invoke.mock.calls.filter( - ([command]) => command === "create_quick_runtime", - ), - ).toHaveLength(1); - expect( - screen.getByRole("button", { name: "Starting request" }), - ).toBeDisabled(); - expect(input).toHaveFocus(); - expect(input).toHaveAttribute("readonly"); - - await act(async () => { - resolveCreate?.({ - runtime: { agentId: "agent-1", runtimeId: "runtime-1" }, - }); - await createRuntime; - }); - }); - - it("keeps pre-text activity inside the stable busy assistant region through completion", async () => { - const user = userEvent.setup(); - let resolveSnapshot!: (value: ReturnType) => void; - const firstSnapshot = new Promise>( - (resolve) => { - resolveSnapshot = resolve; + it("allows only one submission start while commands are pending", async () => { + const pendingModel = deferred(); + const tracker = bridgeMock([defaultModels]); + const baseImplementation = mocks.invoke.getMockImplementation(); + mocks.invoke.mockImplementation( + (command: string, payload?: Record) => { + if (command === "set_model") return pendingModel.promise; + return baseImplementation?.(command, payload); }, ); - mocks.invoke.mockImplementation((command: string) => { - switch (command) { - case "load_ui_state": - return Promise.resolve(uiState); - case "get_model_catalog": - return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], - }); - case "create_quick_runtime": - return Promise.resolve({ - runtime: { agentId: "agent-1", runtimeId: "runtime-1" }, - }); - case "get_session_runtime_snapshot": - return firstSnapshot; - default: - return Promise.resolve(undefined); - } - }); - render(); - const input = await screen.findByRole("textbox", { name: "Request" }); - await user.type(input, "Inspect this"); - await user.click(screen.getByRole("button", { name: "Send request" })); + await preparedTrigger(); + const input = screen.getByRole("textbox", { name: "Request" }); + fireEvent.change(input, { target: { value: "Inspect once" } }); + const send = screen.getByRole("button", { name: "Send request" }); + fireEvent.click(send); + fireEvent.click(send); - const assistantResponse = screen.getByRole("region", { - name: "Assistant response", - }); - expect(assistantResponse).not.toBeEmptyDOMElement(); - expect(assistantResponse).toHaveAttribute("aria-live", "polite"); - expect(assistantResponse).toHaveAttribute("aria-busy", "true"); - const preTextActivity = assistantResponse.querySelector( - ".quick-pane-activity", - ); - expect(preTextActivity).toBeInTheDocument(); - expect(preTextActivity).toHaveTextContent(/Starting|Thinking/); + expect(tracker.runtimeCount()).toBe(1); expect( - assistantResponse.parentElement?.querySelector( - ":scope > .quick-pane-activity", - ), - ).toBeNull(); - await waitFor(() => - expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", { - agentId: "agent-1", - message: expect.any(String), - }), - ); + mocks.invoke.mock.calls.filter(([command]) => command === "set_model"), + ).toHaveLength(1); + expect(input).toHaveAttribute("readonly"); + await act(async () => pendingModel.resolve(undefined)); + }); - await act(async () => { - resolveSnapshot(snapshot("Final answer", false)); - await firstSnapshot; - }); + it("keeps live assistant region stable through completion", async () => { + const pendingSnapshot = deferred(); + bridgeMock([defaultModels], () => pendingSnapshot.promise); + render(); + await preparedTrigger(); + await ask("Inspect this"); + + const response = screen.getByRole("region", { name: "Assistant response" }); + expect(response).toHaveAttribute("aria-live", "polite"); + expect(response).toHaveAttribute("aria-busy", "true"); + expect(response.querySelector(".quick-pane-activity")).toBeInTheDocument(); + + await act(async () => + pendingSnapshot.resolve( + runtimeSnapshot(defaultModels, "Final answer", false), + ), + ); expect(await screen.findByText("Final answer")).toBeInTheDocument(); expect(screen.getByRole("region", { name: "Assistant response" })).toBe( - assistantResponse, + response, ); - expect(assistantResponse).toHaveAttribute("aria-busy", "false"); + expect(response).toHaveAttribute("aria-busy", "false"); expect( - within(assistantResponse).getByText("Response complete."), + within(response).getByText("Response complete."), ).toBeInTheDocument(); }); - it("clears only the submitted request after a completed non-streaming answer", async () => { - const user = userEvent.setup(); - let currentSnapshot = snapshot("Partial answer", true); - mocks.invoke.mockImplementation((command: string) => { - switch (command) { - case "load_ui_state": - return Promise.resolve(uiState); - case "get_model_catalog": - return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], - }); - case "create_quick_runtime": - return Promise.resolve({ - runtime: { agentId: "agent-1", runtimeId: "runtime-1" }, - }); - case "get_session_runtime_snapshot": - return Promise.resolve(currentSnapshot); - default: - return Promise.resolve(undefined); - } - }); - + it("clears only submitted draft and restores focus after completion", async () => { + let current = runtimeSnapshot(defaultModels, "Partial answer", true); + bridgeMock([defaultModels], () => current); render(); - const input = await screen.findByPlaceholderText(/Ask a quick question/); - await user.type(input, "Inspect this"); - await user.click(screen.getByRole("button", { name: "Send request" })); - + await preparedTrigger(); + const input = screen.getByRole("textbox", { name: "Request" }); + await ask("Inspect this"); expect(await screen.findByText("Partial answer")).toBeInTheDocument(); - expect( - within(screen.getByRole("region", { name: "Quick exchange" })).getByText( - "Inspect this", - ), - ).toBeInTheDocument(); - const assistantResponse = screen.getByRole("region", { - name: "Assistant response", - }); - expect(assistantResponse).toHaveAttribute("aria-live", "polite"); - expect(assistantResponse).toHaveAttribute("aria-busy", "true"); - expect( - within(assistantResponse).queryByText("Response complete."), - ).toBeNull(); expect(input).toHaveValue("Inspect this"); expect(input).toHaveAttribute("readonly"); - expect(input).toHaveFocus(); - currentSnapshot = snapshot("Final answer", false); + current = runtimeSnapshot(defaultModels, "Final answer", false); expect( await screen.findByText("Final answer", {}, { timeout: 2_000 }), ).toBeInTheDocument(); await waitFor(() => expect(input).toHaveValue("")); - expect(assistantResponse).toHaveAttribute("aria-busy", "false"); - expect( - within(assistantResponse).getByText("Response complete."), - ).toBeInTheDocument(); expect(input).not.toHaveAttribute("readonly"); expect(input).toHaveFocus(); - - await user.type(input, "Next draft"); - await new Promise((resolve) => window.setTimeout(resolve, 900)); + await userEvent.type(input, "Next draft"); + await new Promise((resolve) => window.setTimeout(resolve, 850)); expect(input).toHaveValue("Next draft"); - expect( - within(assistantResponse).getByText("Response complete."), - ).toBeInTheDocument(); }); - it("keeps five in-memory answers and history selection preserves current answer", async () => { - const user = userEvent.setup(); - let runtimeCount = 0; - mocks.invoke.mockImplementation( - (command: string, payload?: { runtimeId?: string }) => { - switch (command) { - case "load_ui_state": - return Promise.resolve(uiState); - case "get_model_catalog": - return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], - }); - case "create_quick_runtime": { - runtimeCount += 1; - return Promise.resolve({ - runtime: { - agentId: `agent-${runtimeCount}`, - runtimeId: `runtime-${runtimeCount}`, - }, - }); - } - case "get_session_runtime_snapshot": { - const segments = payload?.runtimeId?.split("-") ?? []; - const id = Number(segments[segments.length - 1]); - return Promise.resolve(snapshot(`Answer ${id}`, false)); - } - default: - return Promise.resolve(undefined); - } - }, + it("keeps five answers and previews history without changing composer draft", async () => { + const tracker = bridgeMock( + Array.from({ length: 6 }, () => defaultModels), + (index) => runtimeSnapshot(defaultModels, `Answer ${index + 1}`, false), ); - render(); - const input = await screen.findByPlaceholderText(/Ask a quick question/); + await preparedTrigger(); + const input = screen.getByRole("textbox", { name: "Request" }); for (let index = 1; index <= 6; index += 1) { - await user.type(input, `Request ${index}`); - await user.click(screen.getByRole("button", { name: "Send request" })); + await ask(`Request ${index}`); expect(await screen.findByText(`Answer ${index}`)).toBeInTheDocument(); await waitFor(() => expect(input).toHaveValue("")); } - + expect(tracker.runtimeCount()).toBe(6); const history = screen.getByRole("combobox", { name: "Answer history" }); expect(within(history).getAllByRole("option")).toHaveLength(6); expect( within(history).queryByRole("option", { name: "Request 1" }), ).toBeNull(); - expect( - within(history).getByRole("option", { name: "Request 2" }), - ).toBeInTheDocument(); - await user.type(input, "Composer draft"); - await user.selectOptions(history, "runtime-2"); + await userEvent.type(input, "Composer draft"); + await userEvent.selectOptions(history, "runtime-2"); const exchange = screen.getByRole("region", { name: "Quick exchange" }); expect(within(exchange).getByText("Request 2")).toBeInTheDocument(); expect(within(exchange).getByText("Answer 2")).toBeInTheDocument(); - expect(screen.queryByText("Answer 6")).toBeNull(); + expect(input).toHaveValue("Composer draft"); expect( within(exchange).getByRole("region", { name: "Assistant response" }), ).toHaveAttribute("aria-live", "off"); - expect(input).toHaveValue("Composer draft"); - await user.selectOptions(history, ""); - expect(within(exchange).getByText("Request 6")).toBeInTheDocument(); - expect(within(exchange).getByText("Answer 6")).toBeInTheDocument(); - expect( - within(exchange).getByRole("region", { name: "Assistant response" }), - ).toHaveAttribute("aria-live", "polite"); - expect(input).toHaveValue("Composer draft"); - expect(mocks.invoke).not.toHaveBeenCalledWith( - "save_ui_state", - expect.anything(), - ); }); - it("suppresses partial escalation prefixes and delays its notice until completion", async () => { - const user = userEvent.setup(); - let currentSnapshot = snapshot("", true); - mocks.invoke.mockImplementation((command: string) => { - switch (command) { - case "load_ui_state": - return Promise.resolve(uiState); - case "get_model_catalog": - return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], - }); - case "create_quick_runtime": - return Promise.resolve({ - runtime: { agentId: "agent-1", runtimeId: "runtime-1" }, - }); - case "get_session_runtime_snapshot": - return Promise.resolve(currentSnapshot); - default: - return Promise.resolve(undefined); - } + it("keeps runtime error and extension request visible in history preview", async () => { + let failed = false; + bridgeMock([defaultModels, defaultModels], (index) => { + if (index === 0) + return runtimeSnapshot(defaultModels, "First answer", false); + const result = runtimeSnapshot(defaultModels, "", true, { + ...runtimeSnapshot(defaultModels).runtime, + activeTool: "functions.read", + error: failed ? { message: "Runtime disconnected." } : undefined, + }); + result.extensions = [ + { + id: "permission-1", + method: "confirm", + title: "Run guarded command", + message: "Approval needed", + }, + ]; + return result; }); - render(); - const input = await screen.findByRole("textbox", { name: "Request" }); - await user.type(input, "Change files"); - await user.click(screen.getByRole("button", { name: "Send request" })); - - const assistantResponse = screen.getByRole("region", { - name: "Assistant response", - }); - const prefixes = ["[", "[ESCAL", "[ESCALATE]"]; - for (const [index, prefix] of prefixes.entries()) { - currentSnapshot = { - ...snapshot(prefix, true), - runtime: { activeTool: `functions.prefix_${index + 1}` }, - }; - expect( - await screen.findByText( - `Prefix ${index + 1}`, - { selector: ".quick-pane-activity > span:last-child" }, - { timeout: 2_000 }, - ), - ).toBeInTheDocument(); - expect( - assistantResponse.querySelector(".quick-pane-activity"), - ).toBeInTheDocument(); - expect(assistantResponse).not.toHaveTextContent(prefix); - expect( - screen.queryByText("Continue in full session"), - ).not.toBeInTheDocument(); - } - - currentSnapshot = snapshot("[ESCALATE]Needs full session.", true); - expect( - await within(assistantResponse).findByText( - "Needs full session.", - {}, - { timeout: 2_000 }, - ), - ).toBeInTheDocument(); - expect(assistantResponse).toHaveAttribute("aria-busy", "true"); - expect( - screen.queryByText("Continue in full session"), - ).not.toBeInTheDocument(); - - currentSnapshot = snapshot("[ESCALATE]Needs full session.", false); - await waitFor( - () => expect(assistantResponse).toHaveAttribute("aria-busy", "false"), - { timeout: 2_000 }, - ); - expect(assistantResponse).toHaveAttribute("aria-live", "polite"); - expect(assistantResponse).toHaveAttribute("aria-busy", "false"); - expect( - within(assistantResponse).getAllByText("Response complete."), - ).toHaveLength(1); - expect(screen.getByText("Continue in full session")).toBeInTheDocument(); - }); - - it("never renders or stores a completed partial escalation marker", async () => { - const user = userEvent.setup(); - mocks.invoke.mockImplementation((command: string) => { - switch (command) { - case "load_ui_state": - return Promise.resolve(uiState); - case "get_model_catalog": - return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], - }); - case "create_quick_runtime": - return Promise.resolve({ - runtime: { agentId: "agent-1", runtimeId: "runtime-1" }, - }); - case "get_session_runtime_snapshot": - return Promise.resolve(snapshot("[ESCAL", false)); - default: - return Promise.resolve(undefined); - } - }); - - render(); - const input = await screen.findByRole("textbox", { name: "Request" }); - await user.type(input, "Needs review"); - await user.click(screen.getByRole("button", { name: "Send request" })); - - const assistantResponse = screen.getByRole("region", { - name: "Assistant response", - }); - await waitFor( - () => expect(assistantResponse).toHaveAttribute("aria-busy", "false"), - { timeout: 2_000 }, - ); - expect(assistantResponse).not.toHaveTextContent("[ESCAL"); - expect(screen.queryByText("Continue in full session")).toBeNull(); - expect( - screen.getByRole("combobox", { name: "Answer history" }), - ).toBeInTheDocument(); - }); - - it("keeps runtime error and extension visible while viewing history", async () => { - const user = userEvent.setup(); - let runtimeCount = 0; - let runtimeFailed = false; - mocks.invoke.mockImplementation( - (command: string, payload?: { runtimeId?: string }) => { - switch (command) { - case "load_ui_state": - return Promise.resolve(uiState); - case "get_model_catalog": - return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], - }); - case "create_quick_runtime": - runtimeCount += 1; - return Promise.resolve({ - runtime: { - agentId: `agent-${runtimeCount}`, - runtimeId: `runtime-${runtimeCount}`, - }, - }); - case "get_session_runtime_snapshot": - if (payload?.runtimeId === "runtime-1") - return Promise.resolve(snapshot("First answer", false)); - return Promise.resolve({ - runtime: { - activeTool: "functions.read", - error: runtimeFailed - ? { message: "Runtime disconnected." } - : undefined, - }, - state: { data: { isStreaming: true } }, - transcript: { data: { messages: [] } }, - extensions: [ - { - id: "permission-1", - method: "confirm", - title: "Run guarded command", - message: "Approval needed", - }, - ], - }); - default: - return Promise.resolve(undefined); - } - }, - ); - - render(); - const input = await screen.findByRole("textbox", { name: "Request" }); - await user.type(input, "First request"); - await user.click(screen.getByRole("button", { name: "Send request" })); - expect( - await screen.findByText("First answer", {}, { timeout: 2_000 }), - ).toBeInTheDocument(); + await preparedTrigger(); + const input = screen.getByRole("textbox", { name: "Request" }); + await ask("First request"); + expect(await screen.findByText("First answer")).toBeInTheDocument(); await waitFor(() => expect(input).toHaveValue("")); - - const history = screen.getByRole("combobox", { name: "Answer history" }); - await user.type(input, "Active request"); - await user.click(screen.getByRole("button", { name: "Send request" })); + await ask("Active request"); expect( await screen.findByRole("dialog", { name: "Review request" }), ).toBeInTheDocument(); - expect(history).toBeDisabled(); - expect(history).toHaveValue(""); - runtimeFailed = true; + failed = true; expect( await screen.findByText("Runtime disconnected.", {}, { timeout: 2_000 }), ).toBeInTheDocument(); - await waitFor(() => expect(history).not.toBeDisabled()); - await user.selectOptions(history, "runtime-1"); - + const history = screen.getByRole("combobox", { name: "Answer history" }); + await waitFor(() => expect(history).toBeEnabled()); + await userEvent.selectOptions(history, "runtime-1"); const exchange = screen.getByRole("region", { name: "Quick exchange" }); - expect(within(exchange).getByText("First request")).toBeInTheDocument(); expect(within(exchange).getByText("First answer")).toBeInTheDocument(); expect(within(exchange).getByRole("alert")).toHaveTextContent( "Runtime disconnected.", ); - expect(within(exchange).getByRole("status")).toHaveTextContent( - "Quick runtime failed.", - ); expect( within(exchange).getByRole("dialog", { name: "Review request" }), ).toBeInTheDocument(); - expect(screen.getByText("Run guarded command")).toBeInTheDocument(); - expect(screen.getByText("Approval needed")).toBeInTheDocument(); }); - it("renders safe GFM Markdown and reports active tools without tool cards", async () => { - const user = userEvent.setup(); - let currentSnapshot: ReturnType & { - runtime: { activeTool?: string }; - } = { - ...snapshot("", true), - runtime: { activeTool: "functions.grep" }, - }; - mocks.invoke.mockImplementation((command: string) => { - switch (command) { - case "load_ui_state": - return Promise.resolve(uiState); - case "get_model_catalog": - return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], - }); - case "create_quick_runtime": - return Promise.resolve({ - runtime: { agentId: "agent-1", runtimeId: "runtime-1" }, - }); - case "get_session_runtime_snapshot": - return Promise.resolve(currentSnapshot); - default: - return Promise.resolve(undefined); - } + it("renders sanitized GFM Markdown and reports active tools", async () => { + let current = runtimeSnapshot(defaultModels, "", true, { + ...runtimeSnapshot(defaultModels).runtime, + activeTool: "functions.grep", }); - + bridgeMock([defaultModels], () => current); render(); - const input = await screen.findByRole("textbox", { name: "Request" }); - await user.type(input, "Explain output"); - await user.click(screen.getByRole("button", { name: "Send request" })); + await preparedTrigger(); + await ask("Explain output"); expect( await screen.findByText("Searching files", { selector: ".quick-pane-activity > span:last-child", }), ).toBeInTheDocument(); - currentSnapshot = snapshot( + current = runtimeSnapshot( + defaultModels, "## Result\n\n- item\n\n`const safe = true`\n\n[unsafe](javascript:alert(1))\n\n", false, ); @@ -905,63 +394,381 @@ describe("QuickPane", () => { expect(screen.getByText("const safe = true")).toBeInTheDocument(); expect(screen.getByText("unsafe").closest("a")).toHaveAttribute("href", ""); expect(document.querySelector("img")).toBeNull(); - expect(document.querySelector("details.tool-result")).toBeNull(); }); - it("uses Enter to send and leaves Shift+Enter available for newlines", async () => { + it("suppresses escalation prefixes until content and completion are ready", async () => { + let current = runtimeSnapshot(defaultModels, "", true); + bridgeMock([defaultModels], () => current); + render(); + await preparedTrigger(); + await ask("Change files"); + const response = screen.getByRole("region", { name: "Assistant response" }); + + for (const prefix of ["[", "[ESCAL", "[ESCALATE]"]) { + current = runtimeSnapshot(defaultModels, prefix, true); + await new Promise((resolve) => window.setTimeout(resolve, 820)); + expect(response).not.toHaveTextContent(prefix); + expect(screen.queryByText("Continue in full session")).toBeNull(); + } + current = runtimeSnapshot( + defaultModels, + "[ESCALATE]Needs full session.", + true, + ); + expect( + await within(response).findByText( + "Needs full session.", + {}, + { timeout: 2_000 }, + ), + ).toBeInTheDocument(); + expect(screen.queryByText("Continue in full session")).toBeNull(); + + current = runtimeSnapshot( + defaultModels, + "[ESCALATE]Needs full session.", + false, + ); + expect( + await screen.findByText( + "Continue in full session", + {}, + { timeout: 2_000 }, + ), + ).toBeInTheDocument(); + expect(response).not.toHaveTextContent("[ESCALATE]"); + }, 10_000); + + it("does not render or retain a completed partial escalation marker", async () => { + let current = runtimeSnapshot(defaultModels, "", true); + bridgeMock([defaultModels], () => current); + render(); + await preparedTrigger(); + await ask("Change files"); + + current = runtimeSnapshot(defaultModels, "[ESCAL", false); + const history = await screen.findByRole( + "combobox", + { name: "Answer history" }, + { timeout: 2_000 }, + ); + const response = screen.getByRole("region", { name: "Assistant response" }); + expect(response).not.toHaveTextContent("[ESCAL"); + + await userEvent.selectOptions(history, "runtime-1"); + expect(response).not.toHaveTextContent("[ESCAL"); + expect( + within( + screen.getByRole("region", { name: "Quick exchange" }), + ).queryByText("[ESCAL"), + ).toBeNull(); + }); + + it("uses Enter to send and leaves Shift+Enter for a newline", async () => { + const tracker = bridgeMock([defaultModels], () => + runtimeSnapshot(defaultModels, "", true), + ); + render(); + await preparedTrigger(); + const input = screen.getByRole("textbox", { name: "Request" }); + fireEvent.change(input, { target: { value: "Inspect" } }); + fireEvent.keyDown(input, { key: "Enter", shiftKey: true }); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "submit_prompt", + expect.anything(), + ); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith( + "submit_prompt", + expect.objectContaining({ agentId: "agent-1" }), + ), + ); + expect(tracker.runtimeCount()).toBe(1); + }); + + it("routes Close and pane-level Escape through native dismissal", async () => { + bridgeMock([[]]); + render(); + await screen.findByText("No models are currently available."); + await userEvent.click( + screen.getByRole("button", { name: "Close Quick Pane" }), + ); + fireEvent.keyDown(window, { key: "Escape" }); + await waitFor(() => + expect( + mocks.invoke.mock.calls.filter( + ([command]) => command === "dismiss_quick_pane", + ), + ).toHaveLength(2), + ); + }); + + it("retries catalog preparation after an error", async () => { + let attempts = 0; mocks.invoke.mockImplementation((command: string) => { if (command === "load_ui_state") return Promise.resolve(uiState); - if (command === "get_model_catalog") + if (command === "create_quick_runtime") { + attempts += 1; return Promise.resolve({ - models: [{ provider: "provider", id: "quick/model" }], + runtime: { + agentId: `agent-${attempts}`, + runtimeId: `runtime-${attempts}`, + }, }); + } + if (command === "get_session_runtime_snapshot") { + if (attempts === 1) return Promise.reject(new Error("offline")); + return Promise.resolve(runtimeSnapshot(defaultModels)); + } + return Promise.resolve(undefined); + }); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not prepare model catalog: Error: offline", + ); + await act(async () => mocks.focusHandler?.({ payload: true })); + expect(await preparedTrigger()).toBeInTheDocument(); + expect(screen.queryByText(/Could not prepare model catalog/)).toBeNull(); + }); +}); + +describe("QuickPane fresh runtime catalog", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.listen.mockResolvedValue(vi.fn()); + mocks.onFocusChanged.mockImplementation((handler) => { + mocks.focusHandler = handler; + return Promise.resolve(vi.fn()); + }); + }); + + it("uses snapshot catalog, removes stale model, and submits on that exact prepared runtime", async () => { + const tracker = bridgeMock([ + [{ provider: "provider", id: "new/model", name: "New Model" }], + ]); + render(); + const trigger = await preparedTrigger(); + expect(trigger).toHaveAccessibleName(/provider · New Model/i); + expect( + screen.getByText("Selected model unavailable; using provider/new/model."), + ).toBeInTheDocument(); + await ask(); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith("set_model", { + agentId: "agent-1", + provider: "provider", + modelId: "new/model", + }), + ); + expect(tracker.runtimeCount()).toBe(1); + expect(mocks.invoke).not.toHaveBeenCalledWith("get_model_catalog"); + }); + + it("preserves prepared runtime and catalog when focus changes during active request", async () => { + const tracker = bridgeMock([defaultModels]); + render(); + await preparedTrigger(); + await ask(); + await act(async () => mocks.focusHandler?.({ payload: false })); + await act(async () => mocks.focusHandler?.({ payload: true })); + expect(tracker.runtimeCount()).toBe(1); + expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", { + runtimeId: "runtime-1", + }); + }); + + it("retains a prior runtime when close fails and retries before replacement", async () => { + let runtimeCount = 0; + let closeAttempts = 0; + const activeRuntimeIds = new Set(); + let maximumActive = 0; + mocks.invoke.mockImplementation( + (command: string, payload?: { runtimeId?: string }) => { + if (command === "load_ui_state") return Promise.resolve(uiState); + if (command === "create_quick_runtime") { + runtimeCount += 1; + const runtimeId = `runtime-${runtimeCount}`; + activeRuntimeIds.add(runtimeId); + maximumActive = Math.max(maximumActive, activeRuntimeIds.size); + return Promise.resolve({ + runtime: { agentId: `agent-${runtimeCount}`, runtimeId }, + }); + } + if (command === "get_session_runtime_snapshot") + return Promise.resolve(runtimeSnapshot(defaultModels)); + if (command === "close_quick_runtime") { + closeAttempts += 1; + if (closeAttempts === 1) + return Promise.reject(new Error("close unavailable")); + activeRuntimeIds.delete(payload?.runtimeId ?? ""); + return Promise.resolve(undefined); + } + return Promise.resolve(undefined); + }, + ); + render(); + await preparedTrigger(); + + await act(async () => mocks.focusHandler?.({ payload: true })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not prepare model catalog: Error: close unavailable", + ); + expect(runtimeCount).toBe(1); + expect(activeRuntimeIds).toEqual(new Set(["runtime-1"])); + + await act(async () => mocks.focusHandler?.({ payload: true })); + await waitFor(() => expect(runtimeCount).toBe(2)); + expect(closeAttempts).toBe(2); + expect(activeRuntimeIds).toEqual(new Set(["runtime-2"])); + expect(maximumActive).toBe(1); + expect(screen.queryByText(/close unavailable/)).toBeNull(); + }); + + it("queues a created stale runtime after close failure and drains it before preparing again", async () => { + const firstSnapshot = deferred(); + let runtimeCount = 0; + let firstCloseAttempts = 0; + const activeRuntimeIds = new Set(); + mocks.invoke.mockImplementation( + (command: string, payload?: { runtimeId?: string }) => { + if (command === "load_ui_state") return Promise.resolve(uiState); + if (command === "create_quick_runtime") { + runtimeCount += 1; + const runtimeId = `runtime-${runtimeCount}`; + activeRuntimeIds.add(runtimeId); + return Promise.resolve({ + runtime: { agentId: `agent-${runtimeCount}`, runtimeId }, + }); + } + if (command === "get_session_runtime_snapshot") { + if (payload?.runtimeId === "runtime-1") return firstSnapshot.promise; + return Promise.resolve(runtimeSnapshot(defaultModels)); + } + if (command === "close_quick_runtime") { + if (payload?.runtimeId === "runtime-1") { + firstCloseAttempts += 1; + if (firstCloseAttempts === 1) + return Promise.reject(new Error("stale close unavailable")); + } + activeRuntimeIds.delete(payload?.runtimeId ?? ""); + return Promise.resolve(undefined); + } + return Promise.resolve(undefined); + }, + ); + render(); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith( + "get_session_runtime_snapshot", + { runtimeId: "runtime-1" }, + ), + ); + + await act(async () => mocks.focusHandler?.({ payload: true })); + expect(await preparedTrigger()).toBeInTheDocument(); + await act(async () => + firstSnapshot.resolve(runtimeSnapshot(defaultModels)), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not clean up stale quick runtime: Error: stale close unavailable", + ); + expect(activeRuntimeIds).toEqual(new Set(["runtime-1", "runtime-2"])); + + await act(async () => mocks.focusHandler?.({ payload: true })); + await waitFor(() => expect(runtimeCount).toBe(3)); + expect(firstCloseAttempts).toBe(2); + expect(activeRuntimeIds).toEqual(new Set(["runtime-3"])); + expect(screen.queryByText(/stale close unavailable/)).toBeNull(); + expect( + mocks.invoke.mock.calls.filter( + ([command, payload]) => + command === "close_quick_runtime" && + payload?.runtimeId === "runtime-2", + ), + ).toHaveLength(1); + }); + + it("recycles idle prepared runtime on focus and exposes new catalog", async () => { + const tracker = bridgeMock([ + defaultModels, + [{ provider: "provider", id: "new/model", name: "Fresh" }], + ]); + render(); + await preparedTrigger(); + await act(async () => mocks.focusHandler?.({ payload: true })); + await waitFor(() => expect(tracker.runtimeCount()).toBe(2)); + expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", { + runtimeId: "runtime-1", + }); + expect( + await screen.findByRole("button", { name: /provider · Fresh/i }), + ).toBeInTheDocument(); + }); + + it("prepares a fresh runtime before a repeated request without reopen", async () => { + const tracker = bridgeMock( + [defaultModels, [{ provider: "provider", id: "second/model" }]], + (index) => runtimeSnapshot(defaultModels, `Done ${index + 1}`, false), + ); + render(); + await preparedTrigger(); + await ask("First"); + expect(await screen.findByText("Done 1")).toBeInTheDocument(); + await waitFor(() => + expect( + screen.getByRole("textbox", { name: "Request" }), + ).not.toHaveAttribute("readonly"), + ); + await ask("Second"); + await waitFor(() => expect(tracker.runtimeCount()).toBe(2)); + expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", { + runtimeId: "runtime-1", + }); + expect(mocks.invoke).toHaveBeenCalledWith("set_model", { + agentId: "agent-2", + provider: "provider", + modelId: "second/model", + }); + }); + + it("blocks Send for empty snapshot catalog", async () => { + bridgeMock([[]]); + render(); + expect( + await screen.findByText("No models are currently available."), + ).toBeInTheDocument(); + const input = screen.getByRole("textbox", { name: "Request" }); + fireEvent.change(input, { target: { value: "Must not run" } }); + expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled(); + fireEvent.keyDown(input, { key: "Enter" }); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "submit_prompt", + expect.anything(), + ); + }); + + it("closes failed preparation runtime and blocks Send", async () => { + mocks.invoke.mockImplementation((command: string) => { + if (command === "load_ui_state") return Promise.resolve(uiState); if (command === "create_quick_runtime") return Promise.resolve({ runtime: { agentId: "agent-1", runtimeId: "runtime-1" }, }); if (command === "get_session_runtime_snapshot") - return Promise.resolve(snapshot("", true)); + return Promise.reject(new Error("offline")); return Promise.resolve(undefined); }); - render(); - const input = await screen.findByRole("textbox", { name: "Request" }); - fireEvent.change(input, { target: { value: "Inspect" } }); - fireEvent.keyDown(input, { key: "Enter", shiftKey: true }); - expect(mocks.invoke).not.toHaveBeenCalledWith( - "create_quick_runtime", - expect.anything(), + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not prepare model catalog: Error: offline", ); - - fireEvent.keyDown(input, { key: "Enter" }); - await waitFor(() => - expect(mocks.invoke).toHaveBeenCalledWith("create_quick_runtime", { - worktreePath: "/repo", - }), - ); - }); - - it("routes Close and Escape through the serialized native dismiss command", async () => { - mocks.invoke.mockImplementation((command: string) => { - if (command === "load_ui_state") return Promise.resolve(uiState); - if (command === "get_model_catalog") - return Promise.resolve({ models: [] }); - return Promise.resolve(undefined); + expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", { + runtimeId: "runtime-1", }); - - render(); - await screen.findByPlaceholderText(/Ask a quick question/); - expect(mocks.onCloseRequested).not.toHaveBeenCalled(); - - await userEvent.click( - screen.getByRole("button", { name: "Close Quick Pane" }), - ); - fireEvent.keyDown(window, { key: "Escape" }); - expect( - mocks.invoke.mock.calls.filter( - ([command]) => command === "dismiss_quick_pane", - ), - ).toHaveLength(2); - expect(mocks.hide).not.toHaveBeenCalled(); + fireEvent.change(screen.getByRole("textbox", { name: "Request" }), { + target: { value: "Blocked" }, + }); + expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled(); }); }); diff --git a/ui/src/quick-pane/QuickPane.tsx b/ui/src/quick-pane/QuickPane.tsx index 447455b..71f29d6 100644 --- a/ui/src/quick-pane/QuickPane.tsx +++ b/ui/src/quick-pane/QuickPane.tsx @@ -5,11 +5,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { ExtensionDialog } from "../components/ExtensionDialog"; +import { ModelSelector } from "../components/ModelSelector"; import { - catalogModels, - getModelCatalog, modelIdentity, - modelIdentityFromValue, modelIdentityValue, reconcileModelIdentity, type ModelIdentity, @@ -24,6 +22,8 @@ import type { } from "../workspace/types"; const ESCALATION_MARKER = "[ESCALATE]"; +const pendingQuickRuntimeCleanupIds = new Set(); +const pendingQuickRuntimeCleanupPromises = new Map>(); const defaults: QuickPaneSettings = { workspace: "", @@ -38,6 +38,7 @@ const defaults: QuickPaneSettings = { }; type Runtime = { agentId: string; runtimeId: string }; +type PreparedRuntime = { runtime: Runtime; models: Model[] }; type HistoryEntry = { id: string; request: string; answer: string }; type ActivitySnapshot = { activeTool?: string; @@ -109,6 +110,7 @@ export default function QuickPane() { const [request, setRequest] = useState(""); const [currentRequest, setCurrentRequest] = useState(""); const [submissionStarting, setSubmissionStarting] = useState(false); + const [requestActive, setRequestActive] = useState(false); const [thinkingLevel, setThinkingLevel] = useState("medium"); const [models, setModels] = useState([]); const [catalogStatus, setCatalogStatus] = useState< @@ -125,7 +127,6 @@ export default function QuickPane() { const [runtime, setRuntime] = useState(); const [answer, setAnswer] = useState(""); const [completedRuntimeId, setCompletedRuntimeId] = useState(); - const [failedRuntimeId, setFailedRuntimeId] = useState(); const [history, setHistory] = useState([]); const [selectedHistoryId, setSelectedHistoryId] = useState(""); const [escalation, setEscalation] = useState(); @@ -135,6 +136,12 @@ export default function QuickPane() { }); const [elapsedSeconds, setElapsedSeconds] = useState(0); const runtimeRef = useRef(undefined); + const preparedRuntimeRef = useRef(undefined); + const cleanupQueueRef = useRef(pendingQuickRuntimeCleanupIds); + const mountedRef = useRef(true); + const requestActiveRef = useRef(false); + const settingsRef = useRef(settings); + settingsRef.current = settings; const submittedRequestRef = useRef< { runtimeId: string; request: string } | undefined >(undefined); @@ -142,21 +149,13 @@ export default function QuickPane() { const completedRuntimeRef = useRef(undefined); const submissionStartingRef = useRef(false); const runStartedAtRef = useRef(undefined); - const catalogRequestRef = useRef(0); + const preparationGenerationRef = useRef(0); + const preparationLockRef = useRef(false); const catalogReadyRef = useRef(false); const selectedModelRef = useRef(selectedModel); const inputRef = useRef(null); const invalid = useMemo(() => settingsError(settings), [settings]); - const modelOptions = useMemo(() => catalogModels(models, []), [models]); - const selectedModelValue = - selectedModel.provider && selectedModel.modelId - ? modelIdentityValue(selectedModel) - : ""; - const runActive = - submissionStarting || - (!!runtime && - completedRuntimeId !== runtime.runtimeId && - failedRuntimeId !== runtime.runtimeId); + const runActive = submissionStarting || requestActive; const viewingHistory = !!selectedHistoryId && !runActive; const displayedEntry = viewingHistory ? history.find((entry) => entry.id === selectedHistoryId) @@ -179,48 +178,160 @@ export default function QuickPane() { ? `${activityPhase} · ${elapsedSeconds}s` : activityPhase; - const dismissPane = () => invoke("dismiss_quick_pane"); const focusInput = () => inputRef.current?.focus(); - const loadModelCatalog = useCallback( - async (preferredModel?: ModelIdentity) => { - const requestId = ++catalogRequestRef.current; + const closeRuntime = useCallback(async (runtimeId: string) => { + const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId); + if (pending) return pending; + + cleanupQueueRef.current.add(runtimeId); + const cleanup = invoke("close_quick_runtime", { runtimeId }) + .then(() => { + cleanupQueueRef.current.delete(runtimeId); + }) + .finally(() => { + pendingQuickRuntimeCleanupPromises.delete(runtimeId); + }); + pendingQuickRuntimeCleanupPromises.set(runtimeId, cleanup); + return cleanup; + }, []); + const clearRuntimeIfCurrent = useCallback((runtimeId: string) => { + if (runtimeRef.current?.runtimeId !== runtimeId) return; + runtimeRef.current = undefined; + preparedRuntimeRef.current = undefined; + setRuntime(undefined); + }, []); + const prepareRuntime = useCallback( + async (preferredModel?: ModelIdentity, ignoreLock = false) => { + if ( + requestActiveRef.current || + (preparationLockRef.current && !ignoreLock) + ) + return false; + const generation = ++preparationGenerationRef.current; catalogReadyRef.current = false; setCatalogStatus("loading"); + setCatalogError(undefined); + setCatalogNotice(undefined); + setModels([]); + try { - const freshModels = await getModelCatalog(); - if (requestId !== catalogRequestRef.current) return; + for (const runtimeId of [...cleanupQueueRef.current]) { + await closeRuntime(runtimeId); + clearRuntimeIfCurrent(runtimeId); + } + + const previous = runtimeRef.current; + if (previous) { + await closeRuntime(previous.runtimeId); + clearRuntimeIfCurrent(previous.runtimeId); + } + } catch (reason) { + if (generation === preparationGenerationRef.current) { + setCatalogStatus("error"); + setCatalogError(`Could not prepare model catalog: ${String(reason)}`); + } + return false; + } + if (generation !== preparationGenerationRef.current) return false; + + let created: Runtime | undefined; + let retained = false; + let preparationFailure: unknown; + try { + const result = await invoke<{ runtime: Runtime }>( + "create_quick_runtime", + { worktreePath: settingsRef.current.workspace }, + ); + created = result.runtime; + if (generation !== preparationGenerationRef.current) return false; + const snapshot = await invoke( + "get_session_runtime_snapshot", + { runtimeId: created.runtimeId }, + ); + if (generation !== preparationGenerationRef.current) return false; + if (snapshot.models?.error) throw new Error(snapshot.models.error); + const freshModels = snapshot.models?.data?.models ?? []; const preferred = preferredModel ?? selectedModelRef.current; const reconciled = reconcileModelIdentity(freshModels, preferred); + + runtimeRef.current = created; + preparedRuntimeRef.current = { runtime: created, models: freshModels }; + setRuntime(created); setModels(freshModels); - setCatalogError(undefined); - catalogReadyRef.current = true; - setCatalogStatus("ready"); + retained = true; if (!reconciled) { const emptyModel = { provider: "", modelId: "" }; selectedModelRef.current = emptyModel; setSelectedModel(emptyModel); + setCatalogStatus("ready"); setCatalogNotice("No models are currently available."); - return; + return false; } selectedModelRef.current = reconciled; setSelectedModel(reconciled); + catalogReadyRef.current = true; + setCatalogStatus("ready"); setCatalogNotice( modelIdentityValue(reconciled) === modelIdentityValue(preferred) ? undefined : `Selected model unavailable; using ${reconciled.provider}/${reconciled.modelId}.`, ); + return true; } catch (reason) { - if (requestId !== catalogRequestRef.current) return; - catalogReadyRef.current = false; - setCatalogStatus("error"); - setCatalogError(`Could not load model catalog: ${String(reason)}`); + preparationFailure = reason; + if (generation === preparationGenerationRef.current) { + catalogReadyRef.current = false; + preparedRuntimeRef.current = undefined; + setCatalogStatus("error"); + setCatalogError(`Could not prepare model catalog: ${String(reason)}`); + } + return false; + } finally { + if (created && !retained) { + try { + await closeRuntime(created.runtimeId); + } catch (cleanupReason) { + if (generation === preparationGenerationRef.current) { + catalogReadyRef.current = false; + setCatalogStatus("error"); + setCatalogError( + preparationFailure + ? `Could not prepare model catalog: ${String(preparationFailure)}; cleanup failed: ${String(cleanupReason)}` + : `Could not clean up stale quick runtime: ${String(cleanupReason)}`, + ); + } else if (mountedRef.current) { + setCatalogError( + `Could not clean up stale quick runtime: ${String(cleanupReason)}`, + ); + } + } + } } }, - [], + [clearRuntimeIfCurrent, closeRuntime], ); + const closeIdleRuntime = useCallback(async () => { + const current = runtimeRef.current; + if (!current || requestActiveRef.current) return true; + try { + await closeRuntime(current.runtimeId); + clearRuntimeIfCurrent(current.runtimeId); + return true; + } catch (reason) { + setCatalogStatus("error"); + setCatalogError(`Could not close Quick runtime: ${String(reason)}`); + return false; + } + }, [clearRuntimeIfCurrent, closeRuntime]); + + const dismissPane = useCallback(async () => { + if (await closeIdleRuntime()) await invoke("dismiss_quick_pane"); + }, [closeIdleRuntime]); + useEffect(() => { let active = true; + mountedRef.current = true; void (async () => { let nextSettings: QuickPaneSettings; try { @@ -232,6 +343,7 @@ export default function QuickPane() { return; } + settingsRef.current = nextSettings; setSettings(nextSettings); setSettingsLoaded(true); const configuredModel = { @@ -241,17 +353,16 @@ export default function QuickPane() { selectedModelRef.current = configuredModel; setSelectedModel(configuredModel); setNotice(""); - if (active) await loadModelCatalog(configuredModel); + if (active) await prepareRuntime(configuredModel); })(); return () => { active = false; + mountedRef.current = false; + preparationGenerationRef.current += 1; const current = runtimeRef.current; - if (current) - invoke("close_quick_runtime", { runtimeId: current.runtimeId }).catch( - () => undefined, - ); + if (current) void closeRuntime(current.runtimeId).catch(() => undefined); }; - }, [loadModelCatalog]); + }, [closeRuntime, prepareRuntime]); useEffect(() => { if (settingsLoaded) focusInput(); @@ -279,6 +390,7 @@ export default function QuickPane() { "quick-pane-settings-saved", (event) => { const nextSettings = { ...defaults, ...event.payload }; + settingsRef.current = nextSettings; setSettings(nextSettings); const configuredModel = { provider: nextSettings.provider, @@ -286,17 +398,30 @@ export default function QuickPane() { }; selectedModelRef.current = configuredModel; setSelectedModel(configuredModel); - void loadModelCatalog(configuredModel); + if (!requestActiveRef.current && !submissionStartingRef.current) + void prepareRuntime(configuredModel); }, ); const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") void dismissPane(); + if (event.key !== "Escape" || event.defaultPrevented) return; + const target = event.target; + const selector = + target instanceof Element ? target.closest(".model-selector") : null; + if ( + selector?.querySelector(".model-menu") || + document.querySelector(".model-selector .model-menu") + ) + return; + void dismissPane(); }; window.addEventListener("keydown", onKeyDown); const focusListener = paneWindow.onFocusChanged((event) => { if (event.payload) { focusInput(); - void loadModelCatalog(); + if (!requestActiveRef.current && !submissionStartingRef.current) + void prepareRuntime(); + } else if (!submissionStartingRef.current) { + void closeIdleRuntime(); } }); return () => { @@ -304,10 +429,10 @@ export default function QuickPane() { void settingsListener.then((unlisten) => unlisten()); void focusListener.then((unlisten) => unlisten()); }; - }, [loadModelCatalog]); + }, [closeIdleRuntime, dismissPane, prepareRuntime]); useEffect(() => { - if (!runtime) return; + if (!runtime || !requestActive) return; let active = true; const refresh = async () => { try { @@ -347,6 +472,8 @@ export default function QuickPane() { completedRuntimeRef.current !== runtime.runtimeId ) { completedRuntimeRef.current = runtime.runtimeId; + requestActiveRef.current = false; + setRequestActive(false); setCompletedRuntimeId(runtime.runtimeId); setNotice(""); const submitted = submittedRequestRef.current; @@ -370,7 +497,8 @@ export default function QuickPane() { setExtension(snapshot.extensions[0]); if (snapshot.runtime.error?.message) { setError(snapshot.runtime.error.message); - setFailedRuntimeId(runtime.runtimeId); + requestActiveRef.current = false; + setRequestActive(false); setEscalation( "Quick runtime failed. Continue in a full session if needed.", ); @@ -386,84 +514,94 @@ export default function QuickPane() { active = false; window.clearInterval(interval); }; - }, [runtime]); + }, [requestActive, runtime]); useEffect(() => { - if (!runtime || completedRuntimeId === runtime.runtimeId) return; + if (!runtime || !requestActive) return; const timeout = window.setTimeout(() => { setEscalation( `Quick time budget of ${settings.maximumSeconds} seconds reached.`, ); - setFailedRuntimeId(runtime.runtimeId); + requestActiveRef.current = false; + setRequestActive(false); invoke("abort", { agentId: runtime.agentId }).catch(() => undefined); }, settings.maximumSeconds * 1000); return () => window.clearTimeout(timeout); - }, [completedRuntimeId, runtime, settings.maximumSeconds]); + }, [requestActive, runtime, settings.maximumSeconds]); async function run() { - if (submissionStartingRef.current || runActive) return; - if (!catalogReadyRef.current || !selectedModelRef.current.modelId) - return setError("Model catalog is not ready."); + if (submissionStartingRef.current || requestActiveRef.current) return; if (invalid || !request.trim()) return setError(invalid ?? "Enter a request."); + if (!catalogReadyRef.current || !selectedModelRef.current.modelId) + return setError("Model catalog is not ready."); + submissionStartingRef.current = true; + preparationLockRef.current = true; setSubmissionStarting(true); const submittedRequest = request; - const submittedModel = selectedModelRef.current; - setCurrentRequest(submittedRequest); - setSelectedHistoryId(""); - runStartedAtRef.current = Date.now(); - setElapsedSeconds(0); try { - const previous = runtimeRef.current; - if (previous) - await invoke("close_quick_runtime", { - runtimeId: previous.runtimeId, - }).catch(() => undefined); - runtimeRef.current = undefined; - submittedRequestRef.current = undefined; + if (!preparedRuntimeRef.current) { + const prepared = await prepareRuntime(selectedModelRef.current, true); + if (!prepared) throw new Error("Model catalog is not ready."); + } + const prepared = preparedRuntimeRef.current; + const submittedModel = selectedModelRef.current; + if ( + !prepared || + !prepared.models.some((model) => { + const identity = modelIdentity(model); + return ( + identity?.provider === submittedModel.provider && + identity.modelId === submittedModel.modelId + ); + }) + ) + throw new Error("Selected model is not in prepared runtime catalog."); + + const submittedRuntime = prepared.runtime; + preparedRuntimeRef.current = undefined; + submittedRequestRef.current = { + runtimeId: submittedRuntime.runtimeId, + request: submittedRequest, + }; promptSubmittedRuntimeRef.current = undefined; completedRuntimeRef.current = undefined; - setRuntime(undefined); + setCurrentRequest(submittedRequest); + setSelectedHistoryId(""); setCompletedRuntimeId(undefined); - setFailedRuntimeId(undefined); setError(undefined); setAnswer(""); setEscalation(undefined); setExtension(undefined); setActivitySnapshot({ hasAssistantText: false }); setNotice(""); - const result = await invoke<{ runtime: Runtime }>( - "create_quick_runtime", - { - worktreePath: settings.workspace, - }, - ); - runtimeRef.current = result.runtime; - submittedRequestRef.current = { - runtimeId: result.runtime.runtimeId, - request: submittedRequest, - }; - setRuntime(result.runtime); + runStartedAtRef.current = Date.now(); + setElapsedSeconds(0); + requestActiveRef.current = true; + setRequestActive(true); + await invoke("set_model", { - agentId: result.runtime.agentId, + agentId: submittedRuntime.agentId, provider: submittedModel.provider, modelId: submittedModel.modelId, }); await invoke("set_thinking_level", { - agentId: result.runtime.agentId, + agentId: submittedRuntime.agentId, level: thinkingLevel, }); const guardrails = `[Quick Pane advisory mode: target ${settings.maximumSeconds}s and ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more budget, start final response with [ESCALATE] and explain why.]\n${settings.instructions}\n\nUser request: ${submittedRequest}`; await invoke("submit_prompt", { - agentId: result.runtime.agentId, + agentId: submittedRuntime.agentId, message: guardrails, }); - promptSubmittedRuntimeRef.current = result.runtime.runtimeId; + promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId; } catch (reason) { + requestActiveRef.current = false; + setRequestActive(false); setError(`Could not start quick request: ${String(reason)}`); - setFailedRuntimeId(runtimeRef.current?.runtimeId); } finally { + preparationLockRef.current = false; submissionStartingRef.current = false; setSubmissionStarting(false); focusInput(); @@ -516,42 +654,23 @@ export default function QuickPane() {
- + { + const identity = modelIdentity(model); + if (!identity) return false; + selectedModelRef.current = identity; + setSelectedModel(identity); + return true; + }} + />