fix(quick-pane): refresh runtime model catalog

This commit is contained in:
2026-08-26 17:59:31 +02:00
parent 4ec951bc05
commit ad66067a31
11 changed files with 1985 additions and 1265 deletions
+40 -10
View File
@@ -30,19 +30,21 @@ test("main Settings uses shared catalog selectors with provider derived from mod
"utf8", "utf8",
), ),
]); ]);
assert.match( assert.doesNotMatch(catalog, /get_model_catalog/);
catalog, assert.match(app, /create_quick_runtime/);
/invoke<\{ models\?: Model\[\] \}>\("get_model_catalog"\)/, assert.match(app, /get_session_runtime_snapshot/);
); assert.match(app, /close_quick_runtime/);
assert.match(app, /getModelCatalog\(\)/); assert.match(app, /label="Quick model"/);
assert.match(app, /Quick model[\s\S]*?<select/); assert.match(app, /label="Follow-up model"/);
assert.match(app, /Follow-up model[\s\S]*?<select/); assert.match(app, /<ModelSelector/);
assert.doesNotMatch(app, /Quick provider\s*<input/); assert.doesNotMatch(app, /Quick provider\s*<input/);
assert.doesNotMatch(app, /Follow-up provider\s*<input/); assert.doesNotMatch(app, /Follow-up provider\s*<input/);
assert.match(app, /provider: identity\.provider/); assert.match(app, /provider: identity\.provider/);
assert.match(app, /followUpProvider: identity\.provider/); assert.match(app, /followUpProvider: identity\.provider/);
assert.match(app, /Save Quick Pane settings/); assert.match(app, /Save Quick Pane settings/);
assert.match(pane, /getModelCatalog\(\)/); assert.match(pane, /snapshot\.models\?\.data\?\.models/);
assert.match(pane, /preparedRuntimeRef/);
assert.doesNotMatch(pane, /get_model_catalog/);
assert.match(pane, /set_thinking_level/); assert.match(pane, /set_thinking_level/);
assert.doesNotMatch(pane, /showSettings/); assert.doesNotMatch(pane, /showSettings/);
assert.doesNotMatch(pane, /Save Quick Pane settings/); assert.doesNotMatch(pane, /Save Quick Pane settings/);
@@ -68,7 +70,7 @@ test("Quick Pane keeps slash-containing model IDs atomic and reloads catalog on
assert.match(pane, /provider: submittedModel\.provider/); assert.match(pane, /provider: submittedModel\.provider/);
assert.match(pane, /modelId: submittedModel\.modelId/); assert.match(pane, /modelId: submittedModel\.modelId/);
assert.doesNotMatch(pane, /selectedModel\.split/); assert.doesNotMatch(pane, /selectedModel\.split/);
assert.match(pane, /onFocusChanged[\s\S]*?void loadModelCatalog\(\)/); assert.match(pane, /onFocusChanged[\s\S]*?void prepareRuntime\(\)/);
assert.match( assert.match(
catalog, catalog,
/JSON\.stringify\(\[identity\.provider, identity\.modelId\]\)/, /JSON\.stringify\(\[identity\.provider, identity\.modelId\]\)/,
@@ -87,7 +89,10 @@ test("Quick Pane dismiss and mutually exclusive window visibility stay native-se
assert.match(pane, /invoke\("dismiss_quick_pane"\)/); assert.match(pane, /invoke\("dismiss_quick_pane"\)/);
assert.doesNotMatch(pane, /getCurrentWindow\(\)\.hide\(\)/); assert.doesNotMatch(pane, /getCurrentWindow\(\)\.hide\(\)/);
assert.match(nativeSource, /fn dismiss_quick_pane\(/); assert.match(nativeSource, /fn dismiss_quick_pane\(/);
assert.match(nativeSource, /dismiss_quick_pane,[\s\S]*create_session_runtime/); assert.match(
nativeSource,
/dismiss_quick_pane,[\s\S]*create_session_runtime/,
);
assert.match( assert.match(
nativeSource, nativeSource,
/struct WindowVisibility\(Mutex<ManagedWindowVisibility>\)/, /struct WindowVisibility\(Mutex<ManagedWindowVisibility>\)/,
@@ -195,6 +200,31 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
); );
assert.match(stylesheet, /\.quick-pane-send[\s\S]*?position: absolute/); assert.match(stylesheet, /\.quick-pane-send[\s\S]*?position: absolute/);
assert.match(stylesheet, /@media \(max-width: 400px\)/); assert.match(stylesheet, /@media \(max-width: 400px\)/);
assert.match(stylesheet, /\.quick-pane\s*\{[\s\S]*?overflow: visible/);
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, /#f0a347/); assert.match(stylesheet, /#f0a347/);
assert.doesNotMatch(stylesheet, /#8aa9d6|#46494f|#23252a/); 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(
selectorStylesheet,
/\.model-trigger\s*>\s*span\s*\{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis/,
);
}); });
+17 -104
View File
@@ -717,16 +717,14 @@ select:focus-visible {
border-radius: 5px; border-radius: 5px;
background: #303030; background: #303030;
} }
.pi-controls label, .pi-controls label {
.model-control {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
gap: 6px; gap: 6px;
align-items: center; align-items: center;
font-size: 10px; font-size: 10px;
} }
.pi-controls select, .pi-controls select {
.model-trigger {
min-width: 0; min-width: 0;
appearance: none; appearance: none;
color-scheme: dark; color-scheme: dark;
@@ -738,13 +736,11 @@ select:focus-visible {
no-repeat right 7px center; no-repeat right 7px center;
color: #f2ece4; color: #f2ece4;
} }
.pi-controls select:hover, .pi-controls select:hover {
.model-trigger:hover {
border-color: #d88735; border-color: #d88735;
background-color: #383129; background-color: #383129;
} }
.pi-controls select:focus, .pi-controls select:focus {
.model-trigger:focus {
border-color: #f0a347; border-color: #f0a347;
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22); box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
} }
@@ -756,101 +752,6 @@ select:focus-visible {
background: #4b3420; background: #4b3420;
color: #fff2df; color: #fff2df;
} }
.model-selector {
position: relative;
min-width: 0;
}
.model-trigger {
width: 100%;
overflow: hidden;
text-align: left;
font-weight: 650;
}
.model-trigger > span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-menu {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 30;
display: grid;
width: min(400px, calc(100vw - 40px));
max-height: min(420px, calc(100vh - 80px));
gap: 6px;
padding: 8px;
border: 1px solid #70522f;
border-radius: 7px;
background: #292724;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
}
.model-menu input {
width: 100%;
padding: 7px 9px;
border: 1px solid #5b5146;
border-radius: 4px;
background: #1f1e1c;
color: #f2ece4;
}
.model-result-count {
margin: 0;
color: #aaa39a;
font-size: 10px;
}
.model-results {
display: grid;
gap: 7px;
min-height: 0;
overflow-y: auto;
}
.model-provider-group {
display: grid;
gap: 2px;
}
.model-provider-heading {
position: sticky;
top: 0;
z-index: 1;
padding: 4px 5px 3px;
background: #292724;
color: #f0a347;
font-size: 10px;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.model-option {
display: grid;
gap: 1px;
width: 100%;
padding: 6px 7px;
border: 1px solid transparent;
background: #35322e;
color: #f2ece4;
text-align: left;
}
.model-option:hover {
border-color: #8c6338;
background: #44382d;
}
.model-option[aria-selected="true"] {
border-color: #f0a347;
background: #4b3420;
color: #fff2df;
}
.model-option small {
overflow-wrap: anywhere;
color: #aaa39a;
font-size: 9px;
font-weight: 500;
}
.model-empty {
margin: 5px 0;
text-align: center;
}
.session-status-strip { .session-status-strip {
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -1055,13 +956,25 @@ select:focus-visible {
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
} }
.quick-pane-settings label { .quick-pane-settings label,
.quick-pane-model-setting {
display: grid; display: grid;
gap: 4px; gap: 4px;
color: #ddd8d0; color: #ddd8d0;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
} }
.quick-pane-model-setting .model-control {
grid-template-columns: 120px minmax(0, 1fr);
font-size: 12px;
}
.quick-pane-model-setting .model-menu {
width: min(400px, calc(100vw - 80px));
}
.quick-pane-model-setting small {
color: #b9afa4;
font-weight: 400;
}
.quick-pane-settings input, .quick-pane-settings input,
.quick-pane-settings textarea { .quick-pane-settings textarea {
width: 100%; width: 100%;
+681 -142
View File
@@ -1,7 +1,18 @@
import { act, render, screen, waitFor, within } from "@testing-library/react"; import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeSummary, UiState, Workspace } from "./workspace/types"; import type {
Model,
RuntimeSummary,
UiState,
Workspace,
} from "./workspace/types";
import { import {
initialWorkspaceState, initialWorkspaceState,
reconcileWorkspace, reconcileWorkspace,
@@ -96,7 +107,9 @@ describe("model catalog identity", () => {
provider: "openrouter", provider: "openrouter",
modelId: "anthropic/claude/sonnet", modelId: "anthropic/claude/sonnet",
}; };
expect(modelIdentityFromValue(modelIdentityValue(identity))).toEqual(identity); expect(modelIdentityFromValue(modelIdentityValue(identity))).toEqual(
identity,
);
}); });
}); });
@@ -122,7 +135,7 @@ describe("App command progress and errors", () => {
expect(status).toHaveTextContent("Ready for your next prompt"); expect(status).toHaveTextContent("Ready for your next prompt");
}); });
it("loads shared catalog selectors and derives providers from model choices", async () => { it("loads categorized snapshot selectors and derives providers from choices", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const loaded: UiState = { const loaded: UiState = {
...uiState, ...uiState,
@@ -138,36 +151,45 @@ describe("App command progress and errors", () => {
}, },
}; };
mocks.invoke.mockImplementation((command: string) => { mocks.invoke.mockImplementation((command: string) => {
if (command === "get_model_catalog")
return Promise.resolve({
models: [
{ provider: "openai", id: "gpt-5" },
{ provider: "openrouter", id: "anthropic/claude/sonnet" },
],
});
if (command === "load_ui_state") return Promise.resolve(loaded); 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); return Promise.resolve(undefined);
}); });
render(<App />); render(<App />);
await user.click(screen.getByRole("button", { name: "Controls" })); await user.click(screen.getByRole("button", { name: "Controls" }));
await waitFor(() => await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("get_model_catalog"), expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
); runtimeId: "settings-runtime",
const quickModel = screen.getByRole("combobox", { name: /Quick model/ });
expect(quickModel).toBeInTheDocument();
expect(
screen.getByRole("combobox", { name: /Follow-up model/ }),
).toBeInTheDocument();
expect(screen.queryByLabelText(/Quick provider/)).toBeNull();
expect(screen.queryByLabelText(/Follow-up provider/)).toBeNull();
await user.selectOptions(
quickModel,
modelIdentityValue({
provider: "openrouter",
modelId: "anthropic/claude/sonnet",
}), }),
); );
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 }),
);
await user.click( await user.click(
screen.getByRole("button", { name: "Save Quick Pane settings" }), screen.getByRole("button", { name: "Save Quick Pane settings" }),
); );
@@ -185,10 +207,75 @@ describe("App command progress and errors", () => {
); );
}); });
it("refreshes available models whenever Settings opens", async () => { it("uses and always closes a fresh temporary runtime whenever Settings opens", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let catalogCalls = 0; let runtimeCount = 0;
const loaded: UiState = { 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.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
});
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, ...uiState,
quickPane: { quickPane: {
workspace: "/repo", workspace: "/repo",
@@ -201,143 +288,593 @@ describe("App command progress and errors", () => {
maximumToolCalls: 6, maximumToolCalls: 6,
}, },
}; };
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.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
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.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
const snapshot = deferred<unknown>();
mocks.invoke.mockImplementation((command: string) => { mocks.invoke.mockImplementation((command: string) => {
if (command === "load_ui_state") return Promise.resolve(loaded); if (command === "load_ui_state") return Promise.resolve(loaded);
if (command === "get_model_catalog") { if (command === "create_quick_runtime")
catalogCalls += 1; return Promise.resolve({ runtime: { runtimeId: "in-flight-probe" } });
return Promise.resolve({ if (command === "get_session_runtime_snapshot") return snapshot.promise;
models: [
{
provider: "openai",
id: catalogCalls === 1 ? "old-model" : "new-model",
},
],
});
}
return Promise.resolve(undefined); return Promise.resolve(undefined);
}); });
const app = render(<App />);
render(<App />);
await user.click(screen.getByRole("button", { name: "Controls" })); await user.click(screen.getByRole("button", { name: "Controls" }));
const quickModel = await screen.findByRole("combobox", {
name: /Quick model/,
});
await waitFor(() => await waitFor(() =>
expect( expect(mocks.invoke).toHaveBeenCalledWith("get_session_runtime_snapshot", {
within(quickModel).getByRole("option", { name: "openai/old-model" }), runtimeId: "in-flight-probe",
).toBeInTheDocument(), }),
); );
app.unmount();
await user.click(screen.getByRole("button", { name: "Back" })); await waitFor(() =>
await user.click(screen.getByRole("button", { name: "Controls" })); expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "in-flight-probe",
await waitFor(() => expect(catalogCalls).toBe(2));
const refreshedQuickModel = screen.getByRole("combobox", {
name: /Quick model/,
});
await waitFor(() =>
expect(
within(refreshedQuickModel).getByRole("option", {
name: "openai/new-model",
}),
).toBeInTheDocument(),
);
expect(
within(refreshedQuickModel).queryByRole("option", {
name: "openai/old-model",
}), }),
).toBeNull();
expect(refreshedQuickModel).toHaveValue(
modelIdentityValue({ provider: "openai", modelId: "new-model" }),
); );
}); });
it("ignores an older catalog response that resolves after a newer refresh", async () => { it("closes temporary runtime and blocks save for snapshot failure or empty catalog", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const firstCatalog = deferred<{ models: { provider: string; id: string }[] }>(); const loaded = {
const secondCatalog = deferred<{ models: { provider: string; id: string }[] }>(); ...uiState,
let catalogCalls = 0; quickPane: {
mocks.invoke.mockImplementation((command: string) => { workspace: "/repo",
if (command === "load_ui_state") return Promise.resolve(uiState); provider: "openai",
if (command === "get_model_catalog") { model: "old-model",
catalogCalls += 1; followUpProvider: "openai",
return catalogCalls === 1 ? firstCatalog.promise : secondCatalog.promise; followUpModel: "old-model",
} instructions: "Stay concise.",
return Promise.resolve(undefined); maximumSeconds: 60,
}); maximumToolCalls: 6,
},
};
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 />); render(<App />);
await user.click(screen.getByRole("button", { name: "Controls" })); await user.click(screen.getByRole("button", { name: "Controls" }));
await waitFor(() => expect(catalogCalls).toBe(1));
await user.click(screen.getByRole("button", { name: "Back" }));
await user.click(screen.getByRole("button", { name: "Controls" }));
await waitFor(() => expect(catalogCalls).toBe(2));
await act(async () =>
secondCatalog.resolve({
models: [{ provider: "openai", id: "new-model" }],
}),
);
const quickModel = screen.getByRole("combobox", { name: /Quick model/ });
await waitFor(() =>
expect(
within(quickModel).getByRole("option", { name: "openai/new-model" }),
).toBeInTheDocument(),
);
await act(async () =>
firstCatalog.resolve({
models: [{ provider: "openai", id: "stale-model" }],
}),
);
expect( expect(
within(quickModel).getByRole("option", { name: "openai/new-model" }), await screen.findByText("Could not load model catalog: Error: offline"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
within(quickModel).queryByRole("option", { name: "openai/stale-model" }), screen.getByRole("button", { name: "Save Quick Pane settings" }),
).toBeNull(); ).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("clears catalog refresh error after a successful retry", async () => { it("invalidates selections and refreshes catalog for an edited workspace", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let catalogCalls = 0; const loaded = {
mocks.invoke.mockImplementation((command: string) => { ...uiState,
if (command === "load_ui_state") return Promise.resolve(uiState); quickPane: {
if (command === "get_model_catalog") { workspace: "/repo",
catalogCalls += 1; provider: "openai",
if (catalogCalls === 1) model: "old-model",
return Promise.reject(new Error("catalog offline")); followUpProvider: "openai",
return Promise.resolve({ followUpModel: "old-model",
models: [{ provider: "openai", id: "recovered-model" }], instructions: "Stay concise.",
}); maximumSeconds: 60,
} maximumToolCalls: 6,
return Promise.resolve(undefined); },
}); };
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.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
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.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
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 />); render(<App />);
await user.click(screen.getByRole("button", { name: "Controls" })); await user.click(screen.getByRole("button", { name: "Controls" }));
expect( expect(
await screen.findByText( await screen.findByText(
"Could not load model catalog: Error: catalog offline", "Could not load model catalog: Error: transient close failure",
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(runtimeCount).toBe(1);
await user.click(screen.getByRole("button", { name: "Back" })); await user.click(screen.getByRole("button", { name: "Back" }));
await user.click(screen.getByRole("button", { name: "Controls" })); await user.click(screen.getByRole("button", { name: "Controls" }));
await waitFor(() => expect(catalogCalls).toBe(2)); 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.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
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.",
maximumSeconds: 60,
maximumToolCalls: 6,
},
};
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(() => await waitFor(() =>
expect( expect(
screen.queryByText( screen.getByRole("button", { name: "Save Quick Pane settings" }),
"Could not load model catalog: Error: catalog offline", ).toBeEnabled(),
), );
).toBeNull(), 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(),
); );
expect(
within(screen.getByRole("combobox", { name: /Quick model/ })).getByRole(
"option",
{ name: "openai/recovered-model" },
),
).toBeInTheDocument();
}); });
it("shows restart progress until the replacement snapshot is restored", async () => { it("shows restart progress until the replacement snapshot is restored", async () => {
@@ -347,8 +884,14 @@ describe("App command progress and errors", () => {
mocks.invoke.mockImplementation((command: string) => { mocks.invoke.mockImplementation((command: string) => {
if (command === "restart") return restart.promise; if (command === "restart") return restart.promise;
if (command === "load_ui_state") return Promise.resolve(uiState); if (command === "load_ui_state") return Promise.resolve(uiState);
if (command === "get_model_catalog") if (command === "create_quick_runtime")
return Promise.resolve({ models: [] }); 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); return Promise.resolve(undefined);
}); });
mocks.loadSnapshot.mockReturnValue(snapshot.promise); mocks.loadSnapshot.mockReturnValue(snapshot.promise);
@@ -363,12 +906,8 @@ describe("App command progress and errors", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true"); expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
await user.click(restarting); await user.click(restarting);
expect(mocks.invoke).toHaveBeenCalledTimes(3); expect(mocks.invoke).toHaveBeenCalledWith("restart", { agentId: "agent-a" });
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "load_ui_state"); expect(mocks.invoke).not.toHaveBeenCalledWith("get_model_catalog");
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "get_model_catalog");
expect(mocks.invoke).toHaveBeenNthCalledWith(3, "restart", {
agentId: "agent-a",
});
await act(async () => restart.resolve(undefined)); await act(async () => restart.resolve(undefined));
expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled();
+219 -100
View File
@@ -5,25 +5,20 @@ import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "./App.css"; import "./App.css";
import { ConversationWorkspace } from "./components/ConversationWorkspace"; import { ConversationWorkspace } from "./components/ConversationWorkspace";
import { ModelSelector } from "./components/ModelSelector";
import { DirectorySidebar } from "./components/DirectorySidebar"; import { DirectorySidebar } from "./components/DirectorySidebar";
import { ExtensionDialog } from "./components/ExtensionDialog"; import { ExtensionDialog } from "./components/ExtensionDialog";
import { CommandFormDialog } from "./components/CommandFormDialog"; import { CommandFormDialog } from "./components/CommandFormDialog";
import type { RpcCommand } from "./commands/rpc"; import type { RpcCommand } from "./commands/rpc";
import { SessionPicker } from "./components/SessionPicker"; import { SessionPicker } from "./components/SessionPicker";
import { SessionTabs } from "./components/SessionTabs"; import { SessionTabs } from "./components/SessionTabs";
import { import { modelIdentity, reconcileModelIdentity } from "./model-catalog";
catalogModels,
getModelCatalog,
modelIdentity,
modelIdentityFromValue,
modelIdentityValue,
reconcileModelIdentity,
} from "./model-catalog";
import type { import type {
DirectorySession, DirectorySession,
DirectoryWorkspace, DirectoryWorkspace,
Model, Model,
QuickPaneSettings, QuickPaneSettings,
RuntimeSnapshot,
RuntimeView, RuntimeView,
UiState, UiState,
} from "./workspace/types"; } from "./workspace/types";
@@ -170,17 +165,131 @@ export default function App() {
const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState(""); const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState("");
const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>(); const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>();
const [modelCatalog, setModelCatalog] = useState<Model[]>([]); const [modelCatalog, setModelCatalog] = useState<Model[]>([]);
const [modelCatalogStatus, setModelCatalogStatus] = useState<
"idle" | "loading" | "ready" | "error"
>("idle");
const [modelCatalogError, setModelCatalogError] = useState<string>(); const [modelCatalogError, setModelCatalogError] = useState<string>();
const [catalogWorkspace, setCatalogWorkspace] = useState<string>();
const modelCatalogRequest = useRef(0); const modelCatalogRequest = useRef(0);
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 uiStateLoaded = uiState !== undefined;
const refreshModelCatalog = useCallback(async () => { const closeCatalogProbe = useCallback((runtimeId: string) => {
const requestId = ++modelCatalogRequest.current; if (!catalogProbeRuntimeIds.current.has(runtimeId)) return Promise.resolve();
try { const pendingClose = closingCatalogProbeRuntimes.current.get(runtimeId);
const freshModels = await getModelCatalog(); if (pendingClose) return pendingClose;
if (requestId !== modelCatalogRequest.current) return; const close = Promise.resolve()
setModelCatalog(freshModels); .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); 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) => { setQuickPaneSettings((current) => {
if (current.workspace !== workspace) return current;
const quickModel = reconcileModelIdentity(freshModels, { const quickModel = reconcileModelIdentity(freshModels, {
provider: current.provider, provider: current.provider,
modelId: current.model, modelId: current.model,
@@ -210,11 +319,9 @@ export default function App() {
); );
return next; return next;
}); });
} catch (error) { },
if (requestId !== modelCatalogRequest.current) return; [closeCatalogProbe, drainRetainedCatalogProbes],
setModelCatalogError(`Could not load model catalog: ${String(error)}`); );
}
}, []);
const setRuntimeStatus = (runtimeId: string, detail: string) => const setRuntimeStatus = (runtimeId: string, detail: string) =>
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail })); setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
const clearRuntimeStatus = (runtimeId: string) => const clearRuntimeStatus = (runtimeId: string) =>
@@ -254,8 +361,10 @@ export default function App() {
mountedRef.current = true; mountedRef.current = true;
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
for (const runtimeId of catalogProbeRuntimeIds.current)
void closeCatalogProbe(runtimeId).catch(() => undefined);
}; };
}, []); }, [closeCatalogProbe]);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
@@ -280,8 +389,21 @@ export default function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (view === "settings" && uiStateLoaded) void refreshModelCatalog(); if (view !== "settings" || !uiStateLoaded) return;
}, [refreshModelCatalog, uiStateLoaded, view]); const workspace = quickPaneSettings.workspace;
if (!workspace.startsWith("/")) {
setModelCatalogStatus("idle");
return;
}
const refreshTimer = window.setTimeout(
() => void refreshModelCatalog(workspace),
150,
);
return () => {
window.clearTimeout(refreshTimer);
modelCatalogRequest.current += 1;
};
}, [quickPaneSettings.workspace, refreshModelCatalog, uiStateLoaded, view]);
const selectedRuntimeId = selected?.summary.runtimeId; const selectedRuntimeId = selected?.summary.runtimeId;
useEffect(() => { useEffect(() => {
@@ -304,6 +426,14 @@ export default function App() {
}, [state.runtimesById]); }, [state.runtimesById]);
async function saveQuickPaneSettings() { async function saveQuickPaneSettings() {
if (
modelCatalogStatus !== "ready" ||
catalogWorkspace !== quickPaneSettings.workspace ||
modelCatalog.length === 0
) {
setQuickPaneSettingsError("Model catalog is not ready.");
return;
}
const validationError = validateQuickPaneSettings(quickPaneSettings); const validationError = validateQuickPaneSettings(quickPaneSettings);
if (validationError) { if (validationError) {
setQuickPaneSettingsError(validationError); setQuickPaneSettingsError(validationError);
@@ -749,21 +879,6 @@ export default function App() {
.map((entry) => entry.runtimeId as string), .map((entry) => entry.runtimeId as string),
); );
const sameDirectoryCollision = directory && directory.openCount > 1; const sameDirectoryCollision = directory && directory.openCount > 1;
const quickPaneModelOptions = catalogModels(modelCatalog, []);
const quickModelValue =
quickPaneSettings.provider && quickPaneSettings.model
? modelIdentityValue({
provider: quickPaneSettings.provider,
modelId: quickPaneSettings.model,
})
: "";
const followUpModelValue =
quickPaneSettings.followUpProvider && quickPaneSettings.followUpModel
? modelIdentityValue({
provider: quickPaneSettings.followUpProvider,
modelId: quickPaneSettings.followUpModel,
})
: "";
return ( return (
<main <main
className="app-shell" className="app-shell"
@@ -938,78 +1053,71 @@ export default function App() {
Workspace Workspace
<input <input
value={quickPaneSettings.workspace} value={quickPaneSettings.workspace}
onChange={(event) => onChange={(event) => {
setQuickPaneSettings({ const workspace = event.currentTarget.value;
...quickPaneSettings, modelCatalogRequest.current += 1;
workspace: event.currentTarget.value, setModelCatalog([]);
}) setCatalogWorkspace(undefined);
} setModelCatalogStatus("idle");
setModelCatalogError(undefined);
setQuickPaneSettingsStatus("");
setQuickPaneSettings((current) => ({
...current,
workspace,
provider: "",
model: "",
followUpProvider: "",
followUpModel: "",
}));
}}
/> />
</label> </label>
<label> <div className="quick-pane-model-setting">
Quick model <ModelSelector
<select label="Quick model"
value={quickModelValue} placement="down"
onChange={(event) => { models={modelCatalog}
const identity = modelIdentityFromValue(event.currentTarget.value); current={{
if (!identity) return; provider: quickPaneSettings.provider,
setQuickPaneSettings({ id: quickPaneSettings.model,
...quickPaneSettings, }}
disabled={modelCatalogStatus !== "ready"}
onSelect={async (model) => {
const identity = modelIdentity(model);
if (!identity) return false;
setQuickPaneSettings((current) => ({
...current,
provider: identity.provider, provider: identity.provider,
model: identity.modelId, model: identity.modelId,
}); }));
return true;
}} }}
> />
<option value="" disabled>
Select model
</option>
{quickPaneModelOptions.map((model) => {
const identity = modelIdentity(model);
if (!identity) return null;
return (
<option
key={modelIdentityValue(identity)}
value={modelIdentityValue(identity)}
>
{identity.provider}/{identity.modelId}
</option>
);
})}
</select>
<small>Provider comes from selected catalog model.</small> <small>Provider comes from selected catalog model.</small>
</label> </div>
<label> <div className="quick-pane-model-setting">
Follow-up model <ModelSelector
<select label="Follow-up model"
value={followUpModelValue} placement="down"
onChange={(event) => { models={modelCatalog}
const identity = modelIdentityFromValue(event.currentTarget.value); current={{
if (!identity) return; provider: quickPaneSettings.followUpProvider,
setQuickPaneSettings({ id: quickPaneSettings.followUpModel,
...quickPaneSettings, }}
disabled={modelCatalogStatus !== "ready"}
onSelect={async (model) => {
const identity = modelIdentity(model);
if (!identity) return false;
setQuickPaneSettings((current) => ({
...current,
followUpProvider: identity.provider, followUpProvider: identity.provider,
followUpModel: identity.modelId, followUpModel: identity.modelId,
}); }));
return true;
}} }}
> />
<option value="" disabled>
Select model
</option>
{quickPaneModelOptions.map((model) => {
const identity = modelIdentity(model);
if (!identity) return null;
return (
<option
key={modelIdentityValue(identity)}
value={modelIdentityValue(identity)}
>
{identity.provider}/{identity.modelId}
</option>
);
})}
</select>
<small>Provider comes from selected catalog model.</small> <small>Provider comes from selected catalog model.</small>
</label> </div>
<label> <label>
Maximum seconds Maximum seconds
<input <input
@@ -1052,9 +1160,20 @@ export default function App() {
} }
/> />
</label> </label>
<button type="submit" disabled={!uiState}> <button
type="submit"
disabled={
!uiState ||
modelCatalogStatus !== "ready" ||
catalogWorkspace !== quickPaneSettings.workspace ||
modelCatalog.length === 0
}
>
Save Quick Pane settings Save Quick Pane settings
</button> </button>
{modelCatalogStatus === "loading" && (
<p role="status">Loading model catalog…</p>
)}
{quickPaneSettingsStatus && ( {quickPaneSettingsStatus && (
<p role="status">{quickPaneSettingsStatus}</p> <p role="status">{quickPaneSettingsStatus}</p>
)} )}
+129
View File
@@ -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;
}
+16
View File
@@ -76,6 +76,22 @@ describe("ModelSelector", () => {
await waitFor(() => expect(trigger).toHaveFocus()); await waitFor(() => expect(trigger).toHaveFocus());
}); });
it("supports labeled downward placement for toolbar and Settings reuse", () => {
const { container } = render(
<ModelSelector
models={models}
current={{ provider: "openai", id: "organization/gpt-5" }}
label="Quick model"
placement="down"
onSelect={vi.fn().mockResolvedValue(true)}
/>,
);
expect(
screen.getByRole("button", { name: /Quick model openai · GPT 5/i }),
).toBeInTheDocument();
expect(container.querySelector(".model-control-down")).toBeInTheDocument();
});
it("keeps the selector open and restores controls when an update fails", async () => { it("keeps the selector open and restores controls when an update fails", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSelect = vi.fn().mockResolvedValue(false); const onSelect = vi.fn().mockResolvedValue(false);
+16 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { AgentState, Model } from "../workspace/types"; import type { AgentState, Model } from "../workspace/types";
import "./ModelSelector.css";
type SelectableModel = Model & { provider: string; id: string }; type SelectableModel = Model & { provider: string; id: string };
type ModelGroup = { provider: string; models: SelectableModel[] }; type ModelGroup = { provider: string; models: SelectableModel[] };
@@ -87,11 +88,19 @@ export function ModelSelector({
current, current,
onSelect, onSelect,
pending = false, pending = false,
disabled = false,
label = "Model",
placement = "up",
className = "",
}: { }: {
models: Model[]; models: Model[];
current?: AgentState["model"]; current?: AgentState["model"];
onSelect: (model: Model) => Promise<boolean>; onSelect: (model: Model) => Promise<boolean>;
pending?: boolean; pending?: boolean;
disabled?: boolean;
label?: string;
placement?: "up" | "down";
className?: string;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -161,8 +170,11 @@ export function ModelSelector({
}; };
return ( return (
<div className="model-control" aria-busy={busy}> <div
<span id={labelId}>Model</span> className={`model-control model-control-${placement} ${className}`.trim()}
aria-busy={busy}
>
<span id={labelId}>{label}</span>
<div className="model-selector" ref={rootRef}> <div className="model-selector" ref={rootRef}>
<button <button
ref={triggerRef} ref={triggerRef}
@@ -172,7 +184,7 @@ export function ModelSelector({
aria-haspopup="dialog" aria-haspopup="dialog"
aria-controls={open ? menuId : undefined} aria-controls={open ? menuId : undefined}
aria-labelledby={`${labelId} ${valueId}`} aria-labelledby={`${labelId} ${valueId}`}
disabled={availableModels.length === 0 || busy} disabled={availableModels.length === 0 || busy || disabled}
onClick={() => { onClick={() => {
setQuery(""); setQuery("");
setOpen((value) => !value); setOpen((value) => !value);
@@ -219,7 +231,7 @@ export function ModelSelector({
current={current} current={current}
headingId={`${menuId}-provider-${index}`} headingId={`${menuId}-provider-${index}`}
onSelect={(model) => void chooseModel(model)} onSelect={(model) => void chooseModel(model)}
disabled={busy} disabled={busy || disabled}
/> />
))} ))}
</div> </div>
-6
View File
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import type { Model } from "./workspace/types"; import type { Model } from "./workspace/types";
export type ModelIdentity = { provider: string; modelId: string }; export type ModelIdentity = { provider: string; modelId: string };
@@ -66,8 +65,3 @@ export function catalogModels(
} }
return available; return available;
} }
export async function getModelCatalog() {
const catalog = await invoke<{ models?: Model[] }>("get_model_catalog");
return catalog?.models ?? [];
}
File diff suppressed because it is too large Load Diff
+235 -116
View File
@@ -5,11 +5,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { ExtensionDialog } from "../components/ExtensionDialog"; import { ExtensionDialog } from "../components/ExtensionDialog";
import { ModelSelector } from "../components/ModelSelector";
import { import {
catalogModels,
getModelCatalog,
modelIdentity, modelIdentity,
modelIdentityFromValue,
modelIdentityValue, modelIdentityValue,
reconcileModelIdentity, reconcileModelIdentity,
type ModelIdentity, type ModelIdentity,
@@ -24,6 +22,8 @@ import type {
} from "../workspace/types"; } from "../workspace/types";
const ESCALATION_MARKER = "[ESCALATE]"; const ESCALATION_MARKER = "[ESCALATE]";
const pendingQuickRuntimeCleanupIds = new Set<string>();
const pendingQuickRuntimeCleanupPromises = new Map<string, Promise<void>>();
const defaults: QuickPaneSettings = { const defaults: QuickPaneSettings = {
workspace: "", workspace: "",
@@ -38,6 +38,7 @@ const defaults: QuickPaneSettings = {
}; };
type Runtime = { agentId: string; runtimeId: string }; type Runtime = { agentId: string; runtimeId: string };
type PreparedRuntime = { runtime: Runtime; models: Model[] };
type HistoryEntry = { id: string; request: string; answer: string }; type HistoryEntry = { id: string; request: string; answer: string };
type ActivitySnapshot = { type ActivitySnapshot = {
activeTool?: string; activeTool?: string;
@@ -109,6 +110,7 @@ export default function QuickPane() {
const [request, setRequest] = useState(""); const [request, setRequest] = useState("");
const [currentRequest, setCurrentRequest] = useState(""); const [currentRequest, setCurrentRequest] = useState("");
const [submissionStarting, setSubmissionStarting] = useState(false); const [submissionStarting, setSubmissionStarting] = useState(false);
const [requestActive, setRequestActive] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium"); const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]); const [models, setModels] = useState<Model[]>([]);
const [catalogStatus, setCatalogStatus] = useState< const [catalogStatus, setCatalogStatus] = useState<
@@ -125,7 +127,6 @@ export default function QuickPane() {
const [runtime, setRuntime] = useState<Runtime>(); const [runtime, setRuntime] = useState<Runtime>();
const [answer, setAnswer] = useState(""); const [answer, setAnswer] = useState("");
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>(); const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
const [failedRuntimeId, setFailedRuntimeId] = useState<string>();
const [history, setHistory] = useState<HistoryEntry[]>([]); const [history, setHistory] = useState<HistoryEntry[]>([]);
const [selectedHistoryId, setSelectedHistoryId] = useState(""); const [selectedHistoryId, setSelectedHistoryId] = useState("");
const [escalation, setEscalation] = useState<string>(); const [escalation, setEscalation] = useState<string>();
@@ -135,6 +136,12 @@ export default function QuickPane() {
}); });
const [elapsedSeconds, setElapsedSeconds] = useState(0); const [elapsedSeconds, setElapsedSeconds] = useState(0);
const runtimeRef = useRef<Runtime | undefined>(undefined); const runtimeRef = useRef<Runtime | undefined>(undefined);
const preparedRuntimeRef = useRef<PreparedRuntime | undefined>(undefined);
const cleanupQueueRef = useRef(pendingQuickRuntimeCleanupIds);
const mountedRef = useRef(true);
const requestActiveRef = useRef(false);
const settingsRef = useRef(settings);
settingsRef.current = settings;
const submittedRequestRef = useRef< const submittedRequestRef = useRef<
{ runtimeId: string; request: string } | undefined { runtimeId: string; request: string } | undefined
>(undefined); >(undefined);
@@ -142,21 +149,13 @@ export default function QuickPane() {
const completedRuntimeRef = useRef<string | undefined>(undefined); const completedRuntimeRef = useRef<string | undefined>(undefined);
const submissionStartingRef = useRef(false); const submissionStartingRef = useRef(false);
const runStartedAtRef = useRef<number | undefined>(undefined); const runStartedAtRef = useRef<number | undefined>(undefined);
const catalogRequestRef = useRef(0); const preparationGenerationRef = useRef(0);
const preparationLockRef = useRef(false);
const catalogReadyRef = useRef(false); const catalogReadyRef = useRef(false);
const selectedModelRef = useRef(selectedModel); const selectedModelRef = useRef(selectedModel);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const invalid = useMemo(() => settingsError(settings), [settings]); const invalid = useMemo(() => settingsError(settings), [settings]);
const modelOptions = useMemo(() => catalogModels(models, []), [models]); const runActive = submissionStarting || requestActive;
const selectedModelValue =
selectedModel.provider && selectedModel.modelId
? modelIdentityValue(selectedModel)
: "";
const runActive =
submissionStarting ||
(!!runtime &&
completedRuntimeId !== runtime.runtimeId &&
failedRuntimeId !== runtime.runtimeId);
const viewingHistory = !!selectedHistoryId && !runActive; const viewingHistory = !!selectedHistoryId && !runActive;
const displayedEntry = viewingHistory const displayedEntry = viewingHistory
? history.find((entry) => entry.id === selectedHistoryId) ? history.find((entry) => entry.id === selectedHistoryId)
@@ -179,48 +178,160 @@ export default function QuickPane() {
? `${activityPhase} · ${elapsedSeconds}s` ? `${activityPhase} · ${elapsedSeconds}s`
: activityPhase; : activityPhase;
const dismissPane = () => invoke("dismiss_quick_pane");
const focusInput = () => inputRef.current?.focus(); const focusInput = () => inputRef.current?.focus();
const loadModelCatalog = useCallback( const closeRuntime = useCallback(async (runtimeId: string) => {
async (preferredModel?: ModelIdentity) => { const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId);
const requestId = ++catalogRequestRef.current; 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; catalogReadyRef.current = false;
setCatalogStatus("loading"); setCatalogStatus("loading");
setCatalogError(undefined);
setCatalogNotice(undefined);
setModels([]);
try { try {
const freshModels = await getModelCatalog(); for (const runtimeId of [...cleanupQueueRef.current]) {
if (requestId !== catalogRequestRef.current) return; 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<RuntimeSnapshot>(
"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 preferred = preferredModel ?? selectedModelRef.current;
const reconciled = reconcileModelIdentity(freshModels, preferred); const reconciled = reconcileModelIdentity(freshModels, preferred);
runtimeRef.current = created;
preparedRuntimeRef.current = { runtime: created, models: freshModels };
setRuntime(created);
setModels(freshModels); setModels(freshModels);
setCatalogError(undefined); retained = true;
catalogReadyRef.current = true;
setCatalogStatus("ready");
if (!reconciled) { if (!reconciled) {
const emptyModel = { provider: "", modelId: "" }; const emptyModel = { provider: "", modelId: "" };
selectedModelRef.current = emptyModel; selectedModelRef.current = emptyModel;
setSelectedModel(emptyModel); setSelectedModel(emptyModel);
setCatalogStatus("ready");
setCatalogNotice("No models are currently available."); setCatalogNotice("No models are currently available.");
return; return false;
} }
selectedModelRef.current = reconciled; selectedModelRef.current = reconciled;
setSelectedModel(reconciled); setSelectedModel(reconciled);
catalogReadyRef.current = true;
setCatalogStatus("ready");
setCatalogNotice( setCatalogNotice(
modelIdentityValue(reconciled) === modelIdentityValue(preferred) modelIdentityValue(reconciled) === modelIdentityValue(preferred)
? undefined ? undefined
: `Selected model unavailable; using ${reconciled.provider}/${reconciled.modelId}.`, : `Selected model unavailable; using ${reconciled.provider}/${reconciled.modelId}.`,
); );
return true;
} catch (reason) { } catch (reason) {
if (requestId !== catalogRequestRef.current) return; preparationFailure = reason;
catalogReadyRef.current = false; if (generation === preparationGenerationRef.current) {
setCatalogStatus("error"); catalogReadyRef.current = false;
setCatalogError(`Could not load model catalog: ${String(reason)}`); 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(() => { useEffect(() => {
let active = true; let active = true;
mountedRef.current = true;
void (async () => { void (async () => {
let nextSettings: QuickPaneSettings; let nextSettings: QuickPaneSettings;
try { try {
@@ -232,6 +343,7 @@ export default function QuickPane() {
return; return;
} }
settingsRef.current = nextSettings;
setSettings(nextSettings); setSettings(nextSettings);
setSettingsLoaded(true); setSettingsLoaded(true);
const configuredModel = { const configuredModel = {
@@ -241,17 +353,16 @@ export default function QuickPane() {
selectedModelRef.current = configuredModel; selectedModelRef.current = configuredModel;
setSelectedModel(configuredModel); setSelectedModel(configuredModel);
setNotice(""); setNotice("");
if (active) await loadModelCatalog(configuredModel); if (active) await prepareRuntime(configuredModel);
})(); })();
return () => { return () => {
active = false; active = false;
mountedRef.current = false;
preparationGenerationRef.current += 1;
const current = runtimeRef.current; const current = runtimeRef.current;
if (current) if (current) void closeRuntime(current.runtimeId).catch(() => undefined);
invoke("close_quick_runtime", { runtimeId: current.runtimeId }).catch(
() => undefined,
);
}; };
}, [loadModelCatalog]); }, [closeRuntime, prepareRuntime]);
useEffect(() => { useEffect(() => {
if (settingsLoaded) focusInput(); if (settingsLoaded) focusInput();
@@ -279,6 +390,7 @@ export default function QuickPane() {
"quick-pane-settings-saved", "quick-pane-settings-saved",
(event) => { (event) => {
const nextSettings = { ...defaults, ...event.payload }; const nextSettings = { ...defaults, ...event.payload };
settingsRef.current = nextSettings;
setSettings(nextSettings); setSettings(nextSettings);
const configuredModel = { const configuredModel = {
provider: nextSettings.provider, provider: nextSettings.provider,
@@ -286,17 +398,30 @@ export default function QuickPane() {
}; };
selectedModelRef.current = configuredModel; selectedModelRef.current = configuredModel;
setSelectedModel(configuredModel); setSelectedModel(configuredModel);
void loadModelCatalog(configuredModel); if (!requestActiveRef.current && !submissionStartingRef.current)
void prepareRuntime(configuredModel);
}, },
); );
const onKeyDown = (event: KeyboardEvent) => { 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); window.addEventListener("keydown", onKeyDown);
const focusListener = paneWindow.onFocusChanged((event) => { const focusListener = paneWindow.onFocusChanged((event) => {
if (event.payload) { if (event.payload) {
focusInput(); focusInput();
void loadModelCatalog(); if (!requestActiveRef.current && !submissionStartingRef.current)
void prepareRuntime();
} else if (!submissionStartingRef.current) {
void closeIdleRuntime();
} }
}); });
return () => { return () => {
@@ -304,10 +429,10 @@ export default function QuickPane() {
void settingsListener.then((unlisten) => unlisten()); void settingsListener.then((unlisten) => unlisten());
void focusListener.then((unlisten) => unlisten()); void focusListener.then((unlisten) => unlisten());
}; };
}, [loadModelCatalog]); }, [closeIdleRuntime, dismissPane, prepareRuntime]);
useEffect(() => { useEffect(() => {
if (!runtime) return; if (!runtime || !requestActive) return;
let active = true; let active = true;
const refresh = async () => { const refresh = async () => {
try { try {
@@ -347,6 +472,8 @@ export default function QuickPane() {
completedRuntimeRef.current !== runtime.runtimeId completedRuntimeRef.current !== runtime.runtimeId
) { ) {
completedRuntimeRef.current = runtime.runtimeId; completedRuntimeRef.current = runtime.runtimeId;
requestActiveRef.current = false;
setRequestActive(false);
setCompletedRuntimeId(runtime.runtimeId); setCompletedRuntimeId(runtime.runtimeId);
setNotice(""); setNotice("");
const submitted = submittedRequestRef.current; const submitted = submittedRequestRef.current;
@@ -370,7 +497,8 @@ export default function QuickPane() {
setExtension(snapshot.extensions[0]); setExtension(snapshot.extensions[0]);
if (snapshot.runtime.error?.message) { if (snapshot.runtime.error?.message) {
setError(snapshot.runtime.error.message); setError(snapshot.runtime.error.message);
setFailedRuntimeId(runtime.runtimeId); requestActiveRef.current = false;
setRequestActive(false);
setEscalation( setEscalation(
"Quick runtime failed. Continue in a full session if needed.", "Quick runtime failed. Continue in a full session if needed.",
); );
@@ -386,84 +514,94 @@ export default function QuickPane() {
active = false; active = false;
window.clearInterval(interval); window.clearInterval(interval);
}; };
}, [runtime]); }, [requestActive, runtime]);
useEffect(() => { useEffect(() => {
if (!runtime || completedRuntimeId === runtime.runtimeId) return; if (!runtime || !requestActive) return;
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
setEscalation( setEscalation(
`Quick time budget of ${settings.maximumSeconds} seconds reached.`, `Quick time budget of ${settings.maximumSeconds} seconds reached.`,
); );
setFailedRuntimeId(runtime.runtimeId); requestActiveRef.current = false;
setRequestActive(false);
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined); invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
}, settings.maximumSeconds * 1000); }, settings.maximumSeconds * 1000);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [completedRuntimeId, runtime, settings.maximumSeconds]); }, [requestActive, runtime, settings.maximumSeconds]);
async function run() { async function run() {
if (submissionStartingRef.current || runActive) return; if (submissionStartingRef.current || requestActiveRef.current) return;
if (!catalogReadyRef.current || !selectedModelRef.current.modelId)
return setError("Model catalog is not ready.");
if (invalid || !request.trim()) if (invalid || !request.trim())
return setError(invalid ?? "Enter a request."); return setError(invalid ?? "Enter a request.");
if (!catalogReadyRef.current || !selectedModelRef.current.modelId)
return setError("Model catalog is not ready.");
submissionStartingRef.current = true; submissionStartingRef.current = true;
preparationLockRef.current = true;
setSubmissionStarting(true); setSubmissionStarting(true);
const submittedRequest = request; const submittedRequest = request;
const submittedModel = selectedModelRef.current;
setCurrentRequest(submittedRequest);
setSelectedHistoryId("");
runStartedAtRef.current = Date.now();
setElapsedSeconds(0);
try { try {
const previous = runtimeRef.current; if (!preparedRuntimeRef.current) {
if (previous) const prepared = await prepareRuntime(selectedModelRef.current, true);
await invoke("close_quick_runtime", { if (!prepared) throw new Error("Model catalog is not ready.");
runtimeId: previous.runtimeId, }
}).catch(() => undefined); const prepared = preparedRuntimeRef.current;
runtimeRef.current = undefined; const submittedModel = selectedModelRef.current;
submittedRequestRef.current = undefined; 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; promptSubmittedRuntimeRef.current = undefined;
completedRuntimeRef.current = undefined; completedRuntimeRef.current = undefined;
setRuntime(undefined); setCurrentRequest(submittedRequest);
setSelectedHistoryId("");
setCompletedRuntimeId(undefined); setCompletedRuntimeId(undefined);
setFailedRuntimeId(undefined);
setError(undefined); setError(undefined);
setAnswer(""); setAnswer("");
setEscalation(undefined); setEscalation(undefined);
setExtension(undefined); setExtension(undefined);
setActivitySnapshot({ hasAssistantText: false }); setActivitySnapshot({ hasAssistantText: false });
setNotice(""); setNotice("");
const result = await invoke<{ runtime: Runtime }>( runStartedAtRef.current = Date.now();
"create_quick_runtime", setElapsedSeconds(0);
{ requestActiveRef.current = true;
worktreePath: settings.workspace, setRequestActive(true);
},
);
runtimeRef.current = result.runtime;
submittedRequestRef.current = {
runtimeId: result.runtime.runtimeId,
request: submittedRequest,
};
setRuntime(result.runtime);
await invoke("set_model", { await invoke("set_model", {
agentId: result.runtime.agentId, agentId: submittedRuntime.agentId,
provider: submittedModel.provider, provider: submittedModel.provider,
modelId: submittedModel.modelId, modelId: submittedModel.modelId,
}); });
await invoke("set_thinking_level", { await invoke("set_thinking_level", {
agentId: result.runtime.agentId, agentId: submittedRuntime.agentId,
level: thinkingLevel, 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}`; 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", { await invoke("submit_prompt", {
agentId: result.runtime.agentId, agentId: submittedRuntime.agentId,
message: guardrails, message: guardrails,
}); });
promptSubmittedRuntimeRef.current = result.runtime.runtimeId; promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId;
} catch (reason) { } catch (reason) {
requestActiveRef.current = false;
setRequestActive(false);
setError(`Could not start quick request: ${String(reason)}`); setError(`Could not start quick request: ${String(reason)}`);
setFailedRuntimeId(runtimeRef.current?.runtimeId);
} finally { } finally {
preparationLockRef.current = false;
submissionStartingRef.current = false; submissionStartingRef.current = false;
setSubmissionStarting(false); setSubmissionStarting(false);
focusInput(); focusInput();
@@ -516,42 +654,23 @@ export default function QuickPane() {
<main className="quick-pane"> <main className="quick-pane">
<header className="quick-pane-toolbar"> <header className="quick-pane-toolbar">
<div className="quick-pane-primary-controls"> <div className="quick-pane-primary-controls">
<label className="quick-pane-field quick-pane-model"> <ModelSelector
<span className="full-label">Model</span> className="quick-pane-model-selector"
<span className="compact-label" aria-hidden="true"> placement="down"
M models={models}
</span> current={{
<select provider: selectedModel.provider,
aria-label="Model" id: selectedModel.modelId,
value={selectedModelValue} }}
disabled={runActive} disabled={runActive || catalogStatus !== "ready"}
onChange={(event) => { onSelect={async (model) => {
const identity = modelIdentityFromValue( const identity = modelIdentity(model);
event.currentTarget.value, if (!identity) return false;
); selectedModelRef.current = identity;
if (identity) { setSelectedModel(identity);
selectedModelRef.current = identity; return true;
setSelectedModel(identity); }}
} />
}}
>
<option value="" disabled>
Select model
</option>
{modelOptions.map((model) => {
const identity = modelIdentity(model);
if (!identity) return null;
return (
<option
key={modelIdentityValue(identity)}
value={modelIdentityValue(identity)}
>
{identity.provider}/{identity.modelId}
</option>
);
})}
</select>
</label>
<label className="quick-pane-field quick-pane-thinking"> <label className="quick-pane-field quick-pane-thinking">
<span className="full-label">Thinking</span> <span className="full-label">Thinking</span>
<span className="compact-label" aria-hidden="true"> <span className="compact-label" aria-hidden="true">
+46 -4
View File
@@ -22,7 +22,7 @@ body {
flex-direction: column; flex-direction: column;
gap: 9px; gap: 9px;
height: 100vh; height: 100vh;
overflow: hidden; overflow: visible;
padding: 10px; padding: 10px;
color: #e6e3df; color: #e6e3df;
font: font:
@@ -58,13 +58,11 @@ body {
.quick-pane-primary-controls { .quick-pane-primary-controls {
flex: 1 1 auto; flex: 1 1 auto;
overflow: hidden;
} }
.quick-pane-header-actions { .quick-pane-header-actions {
flex: 0 1 auto; flex: 0 1 auto;
justify-content: end; justify-content: end;
overflow: hidden;
} }
.quick-pane-field { .quick-pane-field {
@@ -76,9 +74,53 @@ body {
text-transform: uppercase; text-transform: uppercase;
} }
.quick-pane-model { .quick-pane-model-selector {
flex: 1 1 230px; flex: 1 1 230px;
max-width: 310px; max-width: 310px;
color: #bbb7b0;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.quick-pane .model-trigger {
height: 27px;
border: 0;
padding: 5px 25px 5px 8px;
color: #e6e3df;
background-color: #353535;
font-size: 11px;
font-weight: 500;
}
.quick-pane .model-menu {
width: min(400px, calc(100vw - 24px));
max-height: min(350px, calc(100vh - 56px));
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 { .quick-pane-thinking {