fix(quick-pane): refresh runtime model catalog
This commit is contained in:
+40
-10
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user