fix(quick-pane): restore model catalog controls
This commit is contained in:
@@ -273,6 +273,7 @@ export function createAgentRegistry({
|
|||||||
let workspaceSequence = 0;
|
let workspaceSequence = 0;
|
||||||
let manifestIssue;
|
let manifestIssue;
|
||||||
let canonicalHomeWorktree;
|
let canonicalHomeWorktree;
|
||||||
|
let lastSuccessfulModelCatalog;
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
|
|
||||||
function publishWorkspace(type, runtime, data = {}) {
|
function publishWorkspace(type, runtime, data = {}) {
|
||||||
@@ -307,13 +308,13 @@ export function createAgentRegistry({
|
|||||||
runtimes: [...runtimesByRuntimeId.values()]
|
runtimes: [...runtimesByRuntimeId.values()]
|
||||||
.filter((runtime) => !runtime.ephemeral)
|
.filter((runtime) => !runtime.ephemeral)
|
||||||
.map((runtime) => ({
|
.map((runtime) => ({
|
||||||
runtimeId: runtime.runtimeId,
|
runtimeId: runtime.runtimeId,
|
||||||
worktreePath: runtime.worktreePath,
|
worktreePath: runtime.worktreePath,
|
||||||
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
|
||||||
...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
|
...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
|
||||||
...(runtime.legacyDefault ? { legacyDefault: true } : {}),
|
...(runtime.legacyDefault ? { legacyDefault: true } : {}),
|
||||||
openedAt: runtime.openedAt,
|
openedAt: runtime.openedAt,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,7 +987,9 @@ export function createAgentRegistry({
|
|||||||
}
|
}
|
||||||
if (stopConfirmed) releaseClosedSessionLease(runtime);
|
if (stopConfirmed) releaseClosedSessionLease(runtime);
|
||||||
if (runtime.ephemeral && stopConfirmed)
|
if (runtime.ephemeral && stopConfirmed)
|
||||||
await rm(runtime.sessionDir, { recursive: true, force: true }).catch(() => {});
|
await rm(runtime.sessionDir, { recursive: true, force: true }).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
runtime.state = "stopped";
|
runtime.state = "stopped";
|
||||||
publishWorkspace("runtime_closed", runtime, {
|
publishWorkspace("runtime_closed", runtime, {
|
||||||
runtimeId: runtime.runtimeId,
|
runtimeId: runtime.runtimeId,
|
||||||
@@ -1160,6 +1163,15 @@ export function createAgentRegistry({
|
|||||||
});
|
});
|
||||||
await restoreRecords(records);
|
await restoreRecords(records);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!loaded.issue &&
|
||||||
|
![...runtimesByAgentId.values()].some((runtime) => runtime.adapter)
|
||||||
|
) {
|
||||||
|
await openRuntime({
|
||||||
|
worktreePath: canonicalHomeWorktree,
|
||||||
|
legacyDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
return this.listAgents()[0];
|
return this.listAgents()[0];
|
||||||
},
|
},
|
||||||
async selectWorktree(worktreePath) {
|
async selectWorktree(worktreePath) {
|
||||||
@@ -1206,14 +1218,31 @@ export function createAgentRegistry({
|
|||||||
return closeRuntime(runtimeId);
|
return closeRuntime(runtimeId);
|
||||||
},
|
},
|
||||||
async getModelCatalog() {
|
async getModelCatalog() {
|
||||||
const runtime = [...runtimesByAgentId.values()].find(
|
const candidates = [...runtimesByAgentId.values()].filter(
|
||||||
(candidate) => candidate.adapter,
|
(runtime) =>
|
||||||
|
runtime.adapter &&
|
||||||
|
!runtime.stopped &&
|
||||||
|
!["closing", "stopped", "failed"].includes(runtime.state),
|
||||||
);
|
);
|
||||||
if (!runtime?.adapter) return [];
|
let lastError;
|
||||||
const response = await enqueue(runtime, () =>
|
for (const runtime of candidates) {
|
||||||
runtime.adapter.send({ type: "get_available_models" }),
|
const adapter = runtime.adapter;
|
||||||
);
|
try {
|
||||||
return response?.data?.models ?? [];
|
const response = await enqueue(runtime, () =>
|
||||||
|
adapter.send({ type: "get_available_models" }),
|
||||||
|
);
|
||||||
|
if (response?.success !== true || !Array.isArray(response?.data?.models)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastSuccessfulModelCatalog = response.data.models;
|
||||||
|
return response.data.models;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastSuccessfulModelCatalog) return lastSuccessfulModelCatalog;
|
||||||
|
if (lastError) throw lastError;
|
||||||
|
return [];
|
||||||
},
|
},
|
||||||
listAgents() {
|
listAgents() {
|
||||||
return [...runtimesByAgentId.values()]
|
return [...runtimesByAgentId.values()]
|
||||||
|
|||||||
@@ -78,6 +78,60 @@ test("starts only the home agent and creates other worktree agents on explicit s
|
|||||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("model catalog retries unsuccessful responses and falls back to last success", async () => {
|
||||||
|
const worktrees = await createWorktrees();
|
||||||
|
const fixture = createAdapterFactory();
|
||||||
|
const registry = createAgentRegistry({
|
||||||
|
homeWorktree: worktrees.home,
|
||||||
|
sessionRoot: worktrees.sessionRoot,
|
||||||
|
startAdapter: fixture.startAdapter,
|
||||||
|
});
|
||||||
|
await registry.start();
|
||||||
|
await registry.selectWorktree(worktrees.feature);
|
||||||
|
const expected = [{ provider: "provider", id: "family/model" }];
|
||||||
|
const rejected = [{ provider: "wrong", id: "must-not-be-cached" }];
|
||||||
|
const sends = fixture.calls.map(({ adapter }) => adapter.send.bind(adapter));
|
||||||
|
let homeCatalogCalls = 0;
|
||||||
|
let featureCatalogCalls = 0;
|
||||||
|
fixture.calls[0].adapter.send = async (command, options) => {
|
||||||
|
if (command.type === "get_available_models") {
|
||||||
|
homeCatalogCalls += 1;
|
||||||
|
return {
|
||||||
|
type: "response",
|
||||||
|
success: false,
|
||||||
|
data: { models: rejected },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return sends[0](command, options);
|
||||||
|
};
|
||||||
|
fixture.calls[1].adapter.send = async (command, options) => {
|
||||||
|
if (command.type === "get_available_models") {
|
||||||
|
featureCatalogCalls += 1;
|
||||||
|
return { type: "response", success: true, data: { models: expected } };
|
||||||
|
}
|
||||||
|
return sends[1](command, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(await registry.getModelCatalog(), expected);
|
||||||
|
assert.equal(homeCatalogCalls, 1);
|
||||||
|
assert.equal(featureCatalogCalls, 1);
|
||||||
|
fixture.calls[1].adapter.send = async (command, options) => {
|
||||||
|
if (command.type === "get_available_models") {
|
||||||
|
featureCatalogCalls += 1;
|
||||||
|
return {
|
||||||
|
type: "response",
|
||||||
|
success: true,
|
||||||
|
data: { models: "malformed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return sends[1](command, options);
|
||||||
|
};
|
||||||
|
assert.deepEqual(await registry.getModelCatalog(), expected);
|
||||||
|
assert.equal(homeCatalogCalls, 2);
|
||||||
|
assert.equal(featureCatalogCalls, 2);
|
||||||
|
await registry.stop();
|
||||||
|
});
|
||||||
|
|
||||||
test("sends abort without waiting behind a blocked prompt", async () => {
|
test("sends abort without waiting behind a blocked prompt", async () => {
|
||||||
const worktrees = await createWorktrees();
|
const worktrees = await createWorktrees();
|
||||||
const fixture = createAdapterFactory();
|
const fixture = createAdapterFactory();
|
||||||
|
|||||||
@@ -987,7 +987,7 @@ test("closing retains a saved-session lease until the child stops", async () =>
|
|||||||
await registry.stop();
|
await registry.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("first migration retains a missing legacy session as failed dormant intent", async () => {
|
test("first migration retains missing intent and starts an idle home runtime", async () => {
|
||||||
const paths = await fixture();
|
const paths = await fixture();
|
||||||
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
|
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
|
||||||
const missing = join(sessionDir, "missing.jsonl");
|
const missing = join(sessionDir, "missing.jsonl");
|
||||||
@@ -1003,14 +1003,18 @@ test("first migration retains a missing legacy session as failed dormant intent"
|
|||||||
startAdapter: adapters.startAdapter,
|
startAdapter: adapters.startAdapter,
|
||||||
});
|
});
|
||||||
await registry.start();
|
await registry.start();
|
||||||
const runtime = registry.getWorkspace().directories[0].runtimes[0];
|
const runtimes = registry.getWorkspace().directories[0].runtimes;
|
||||||
assert.equal(runtime.state, "failed");
|
const failed = runtimes.find((runtime) => runtime.state === "failed");
|
||||||
assert.equal(runtime.sessionPath, missing);
|
const live = runtimes.find((runtime) => runtime.state === "idle");
|
||||||
assert.equal(adapters.calls.length, 0);
|
assert.equal(failed.sessionPath, missing);
|
||||||
|
assert.equal(live.worktreePath, paths.home);
|
||||||
|
assert.equal(adapters.calls.length, 1);
|
||||||
const manifest = await readJson(
|
const manifest = await readJson(
|
||||||
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
join(paths.sessionRoot, "bridge-workspace-v2.json"),
|
||||||
);
|
);
|
||||||
assert.equal(manifest.runtimes[0].sessionPath, missing);
|
assert.ok(
|
||||||
|
manifest.runtimes.some((runtime) => runtime.sessionPath === missing),
|
||||||
|
);
|
||||||
await registry.stop();
|
await registry.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1198,7 +1202,7 @@ test("snapshot rejects results from an adapter replaced during recovery", async
|
|||||||
await registry.stop();
|
await registry.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("closing the last runtime persists a zero-runtime restart", async () => {
|
test("restoration with no live adapter starts one idle home runtime", async () => {
|
||||||
const paths = await fixture();
|
const paths = await fixture();
|
||||||
const firstAdapters = adapterFactory();
|
const firstAdapters = adapterFactory();
|
||||||
const first = createAgentRegistry({
|
const first = createAgentRegistry({
|
||||||
@@ -1216,8 +1220,10 @@ test("closing the last runtime persists a zero-runtime restart", async () => {
|
|||||||
startAdapter: restoredAdapters.startAdapter,
|
startAdapter: restoredAdapters.startAdapter,
|
||||||
});
|
});
|
||||||
await restored.start();
|
await restored.start();
|
||||||
assert.equal(restored.getWorkspaceSummary().openCount, 0);
|
assert.equal(restored.getWorkspaceSummary().openCount, 1);
|
||||||
assert.equal(restoredAdapters.calls.length, 0);
|
assert.equal(restoredAdapters.calls.length, 1);
|
||||||
|
assert.equal(restored.listAgents()[0].worktreePath, paths.home);
|
||||||
|
assert.equal(restored.listAgents()[0].state, "idle");
|
||||||
await restored.stop();
|
await restored.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+39
-15
@@ -9,32 +9,34 @@ test("Quick Pane polls snapshots, renders answers, and gates handoff", async ()
|
|||||||
);
|
);
|
||||||
assert.match(source, /get_session_runtime_snapshot/);
|
assert.match(source, /get_session_runtime_snapshot/);
|
||||||
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
|
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
|
||||||
assert.match(
|
assert.match(source, /\{answer && <pre>\{answer\}<\/pre>\}/);
|
||||||
source,
|
assert.match(source, /const canContinue = !!escalation \|\| \(!!runtime && !!error\)/);
|
||||||
/<pre style=\{\{ whiteSpace: "pre-wrap" \}\}>\{answer\}<\/pre>/,
|
assert.match(source, /\{canContinue && \(/);
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
source,
|
|
||||||
/\{\(escalation \|\| error\) && \(\s*<button onClick=\{continueFull\}>Continue in full session<\/button>\s*\)\}/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("main Settings owns Quick Pane configuration while pane keeps request controls", async () => {
|
test("main Settings uses shared catalog selectors with provider derived from models", async () => {
|
||||||
const [pane, app, persistence] = await Promise.all([
|
const [pane, app, catalog, persistence] = await Promise.all([
|
||||||
readFile(
|
readFile(
|
||||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
readFile(new URL("../ui/src/App.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../ui/src/App.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../ui/src/model-catalog.ts", import.meta.url), "utf8"),
|
||||||
readFile(
|
readFile(
|
||||||
new URL("../ui/src/workspace/persistence.ts", import.meta.url),
|
new URL("../ui/src/workspace/persistence.ts", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
assert.match(app, /load_ui_state/);
|
assert.match(catalog, /invoke<\{ models\?: Model\[\] \}>\("get_model_catalog"\)/);
|
||||||
assert.match(app, /save_ui_state/);
|
assert.match(app, /getModelCatalog\(\)/);
|
||||||
|
assert.match(app, /Quick model[\s\S]*?<select/);
|
||||||
|
assert.match(app, /Follow-up model[\s\S]*?<select/);
|
||||||
|
assert.doesNotMatch(app, /Quick provider\s*<input/);
|
||||||
|
assert.doesNotMatch(app, /Follow-up provider\s*<input/);
|
||||||
|
assert.match(app, /provider: identity\.provider/);
|
||||||
|
assert.match(app, /followUpProvider: identity\.provider/);
|
||||||
assert.match(app, /Save Quick Pane settings/);
|
assert.match(app, /Save Quick Pane settings/);
|
||||||
assert.match(pane, /get_model_catalog/);
|
assert.match(pane, /getModelCatalog\(\)/);
|
||||||
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/);
|
||||||
@@ -42,6 +44,23 @@ test("main Settings owns Quick Pane configuration while pane keeps request contr
|
|||||||
assert.match(persistence, /quickPane: current\.quickPane/);
|
assert.match(persistence, /quickPane: current\.quickPane/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Quick Pane keeps slash-containing model IDs atomic and reloads catalog on focus", async () => {
|
||||||
|
const [pane, catalog] = await Promise.all([
|
||||||
|
readFile(
|
||||||
|
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
readFile(new URL("../ui/src/model-catalog.ts", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
assert.match(pane, /useState<ModelIdentity>/);
|
||||||
|
assert.match(pane, /provider: selectedModel\.provider/);
|
||||||
|
assert.match(pane, /modelId: selectedModel\.modelId/);
|
||||||
|
assert.doesNotMatch(pane, /selectedModel\.split/);
|
||||||
|
assert.match(pane, /onFocusChanged[\s\S]*?void loadModelCatalog\(\)/);
|
||||||
|
assert.match(catalog, /JSON\.stringify\(\[identity\.provider, identity\.modelId\]\)/);
|
||||||
|
assert.match(catalog, /JSON\.parse\(value\)/);
|
||||||
|
});
|
||||||
|
|
||||||
test("Quick Pane routes extension responses through existing bridge command", async () => {
|
test("Quick Pane routes extension responses through existing bridge command", async () => {
|
||||||
const source = await readFile(
|
const source = await readFile(
|
||||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||||
@@ -55,7 +74,7 @@ test("Quick Pane routes extension responses through existing bridge command", as
|
|||||||
assert.match(source, /requestId: extension\.id/);
|
assert.match(source, /requestId: extension\.id/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Quick Pane uses native quick dimensions and keyboard-first controls", async () => {
|
test("Quick Pane uses native dimensions and request-first compact layout", async () => {
|
||||||
const [source, stylesheet, configSource] = await Promise.all([
|
const [source, stylesheet, configSource] = await Promise.all([
|
||||||
readFile(
|
readFile(
|
||||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||||
@@ -78,6 +97,9 @@ test("Quick Pane uses native quick dimensions and keyboard-first controls", asyn
|
|||||||
},
|
},
|
||||||
{ width: 640, height: 440, transparent: true },
|
{ width: 640, height: 440, transparent: true },
|
||||||
);
|
);
|
||||||
|
assert.match(source, /className="quick-pane-request"/);
|
||||||
|
assert.match(source, /className="quick-pane-output"/);
|
||||||
|
assert.match(source, /<footer className="quick-pane-controls">/);
|
||||||
assert.match(source, /ref=\{inputRef\}/);
|
assert.match(source, /ref=\{inputRef\}/);
|
||||||
assert.match(source, /focusInput\(\)/);
|
assert.match(source, /focusInput\(\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -85,7 +107,9 @@ test("Quick Pane uses native quick dimensions and keyboard-first controls", asyn
|
|||||||
/event\.key === "Enter" &&\s*!event\.shiftKey &&\s*!event\.nativeEvent\.isComposing/,
|
/event\.key === "Enter" &&\s*!event\.shiftKey &&\s*!event\.nativeEvent\.isComposing/,
|
||||||
);
|
);
|
||||||
assert.match(source, /event\.preventDefault\(\);\s*void run\(\);/);
|
assert.match(source, /event\.preventDefault\(\);\s*void run\(\);/);
|
||||||
assert.match(source, /Could not load model catalog:/);
|
assert.match(stylesheet, /\.quick-pane-request[\s\S]*?flex: 1 1 auto/);
|
||||||
|
assert.match(stylesheet, /\.quick-pane-output[\s\S]*?max-height: 108px[\s\S]*?overflow: auto/);
|
||||||
|
assert.match(stylesheet, /\.quick-pane-controls[\s\S]*?display: flex/);
|
||||||
assert.match(stylesheet, /#f0a347/);
|
assert.match(stylesheet, /#f0a347/);
|
||||||
assert.doesNotMatch(stylesheet, /#8aa9d6|#46494f|#23252a/);
|
assert.doesNotMatch(stylesheet, /#8aa9d6|#46494f|#23252a/);
|
||||||
});
|
});
|
||||||
|
|||||||
+80
-2
@@ -7,16 +7,19 @@ import {
|
|||||||
reconcileWorkspace,
|
reconcileWorkspace,
|
||||||
workspaceReducer,
|
workspaceReducer,
|
||||||
} from "./workspace/reducer";
|
} from "./workspace/reducer";
|
||||||
|
import { modelIdentityFromValue, modelIdentityValue } from "./model-catalog";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
loadSnapshot: vi.fn(),
|
loadSnapshot: vi.fn(),
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
dispatch: vi.fn(),
|
dispatch: vi.fn(),
|
||||||
|
emitTo: vi.fn(),
|
||||||
useWorkspace: vi.fn(),
|
useWorkspace: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({ emitTo: mocks.emitTo }));
|
||||||
vi.mock("@tauri-apps/api/window", () => ({
|
vi.mock("@tauri-apps/api/window", () => ({
|
||||||
getCurrentWindow: () => ({ startDragging: vi.fn() }),
|
getCurrentWindow: () => ({ startDragging: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
@@ -87,10 +90,21 @@ function deferred<T>() {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("model catalog identity", () => {
|
||||||
|
it("preserves model IDs containing slashes", () => {
|
||||||
|
const identity = {
|
||||||
|
provider: "openrouter",
|
||||||
|
modelId: "anthropic/claude/sonnet",
|
||||||
|
};
|
||||||
|
expect(modelIdentityFromValue(modelIdentityValue(identity))).toEqual(identity);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("App command progress and errors", () => {
|
describe("App command progress and errors", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.invoke.mockResolvedValue(uiState);
|
mocks.invoke.mockResolvedValue(uiState);
|
||||||
|
mocks.emitTo.mockResolvedValue(undefined);
|
||||||
mocks.refresh.mockResolvedValue(undefined);
|
mocks.refresh.mockResolvedValue(undefined);
|
||||||
mocks.loadSnapshot.mockResolvedValue(true);
|
mocks.loadSnapshot.mockResolvedValue(true);
|
||||||
mocks.useWorkspace.mockReturnValue({
|
mocks.useWorkspace.mockReturnValue({
|
||||||
@@ -108,6 +122,69 @@ 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 () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const loaded: UiState = {
|
||||||
|
...uiState,
|
||||||
|
quickPane: {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-5",
|
||||||
|
followUpProvider: "openai",
|
||||||
|
followUpModel: "gpt-5",
|
||||||
|
instructions: "Stay concise.",
|
||||||
|
maximumSeconds: 60,
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
});
|
||||||
|
render(<App />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("get_model_catalog"),
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("save_ui_state", {
|
||||||
|
state: {
|
||||||
|
...loaded,
|
||||||
|
quickPane: {
|
||||||
|
...loaded.quickPane,
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "anthropic/claude/sonnet",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows restart progress until the replacement snapshot is restored", async () => {
|
it("shows restart progress until the replacement snapshot is restored", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const restart = deferred<unknown>();
|
const restart = deferred<unknown>();
|
||||||
@@ -127,9 +204,10 @@ describe("App command progress and errors", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
|
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
|
||||||
await user.click(restarting);
|
await user.click(restarting);
|
||||||
expect(mocks.invoke).toHaveBeenCalledTimes(2);
|
expect(mocks.invoke).toHaveBeenCalledTimes(3);
|
||||||
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "load_ui_state");
|
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "load_ui_state");
|
||||||
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "restart", {
|
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "get_model_catalog");
|
||||||
|
expect(mocks.invoke).toHaveBeenNthCalledWith(3, "restart", {
|
||||||
agentId: "agent-a",
|
agentId: "agent-a",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+96
-38
@@ -11,6 +11,13 @@ 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 {
|
||||||
|
catalogModels,
|
||||||
|
getModelCatalog,
|
||||||
|
modelIdentity,
|
||||||
|
modelIdentityFromValue,
|
||||||
|
modelIdentityValue,
|
||||||
|
} from "./model-catalog";
|
||||||
import type {
|
import type {
|
||||||
DirectorySession,
|
DirectorySession,
|
||||||
DirectoryWorkspace,
|
DirectoryWorkspace,
|
||||||
@@ -161,6 +168,7 @@ 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 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) =>
|
||||||
@@ -220,6 +228,16 @@ export default function App() {
|
|||||||
`Could not load Quick Pane settings: ${String(error)}`,
|
`Could not load Quick Pane settings: ${String(error)}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
void getModelCatalog()
|
||||||
|
.then((models) => {
|
||||||
|
if (active) setModelCatalog(models);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (active)
|
||||||
|
setQuickPaneSettingsError(
|
||||||
|
`Could not load model catalog: ${String(error)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
@@ -691,6 +709,30 @@ 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, [
|
||||||
|
{
|
||||||
|
provider: quickPaneSettings.provider,
|
||||||
|
modelId: quickPaneSettings.model,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: quickPaneSettings.followUpProvider,
|
||||||
|
modelId: quickPaneSettings.followUpModel,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
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"
|
||||||
@@ -873,53 +915,69 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
Quick provider
|
|
||||||
<input
|
|
||||||
value={quickPaneSettings.provider}
|
|
||||||
onChange={(event) =>
|
|
||||||
setQuickPaneSettings({
|
|
||||||
...quickPaneSettings,
|
|
||||||
provider: event.currentTarget.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
Quick model
|
Quick model
|
||||||
<input
|
<select
|
||||||
value={quickPaneSettings.model}
|
value={quickModelValue}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
|
const identity = modelIdentityFromValue(event.currentTarget.value);
|
||||||
|
if (!identity) return;
|
||||||
setQuickPaneSettings({
|
setQuickPaneSettings({
|
||||||
...quickPaneSettings,
|
...quickPaneSettings,
|
||||||
model: event.currentTarget.value,
|
provider: identity.provider,
|
||||||
})
|
model: identity.modelId,
|
||||||
}
|
});
|
||||||
/>
|
}}
|
||||||
</label>
|
>
|
||||||
<label>
|
<option value="" disabled>
|
||||||
Follow-up provider
|
Select model
|
||||||
<input
|
</option>
|
||||||
value={quickPaneSettings.followUpProvider}
|
{quickPaneModelOptions.map((model) => {
|
||||||
onChange={(event) =>
|
const identity = modelIdentity(model);
|
||||||
setQuickPaneSettings({
|
if (!identity) return null;
|
||||||
...quickPaneSettings,
|
return (
|
||||||
followUpProvider: event.currentTarget.value,
|
<option
|
||||||
})
|
key={modelIdentityValue(identity)}
|
||||||
}
|
value={modelIdentityValue(identity)}
|
||||||
/>
|
>
|
||||||
|
{identity.provider}/{identity.modelId}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<small>Provider comes from selected catalog model.</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Follow-up model
|
Follow-up model
|
||||||
<input
|
<select
|
||||||
value={quickPaneSettings.followUpModel}
|
value={followUpModelValue}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
|
const identity = modelIdentityFromValue(event.currentTarget.value);
|
||||||
|
if (!identity) return;
|
||||||
setQuickPaneSettings({
|
setQuickPaneSettings({
|
||||||
...quickPaneSettings,
|
...quickPaneSettings,
|
||||||
followUpModel: event.currentTarget.value,
|
followUpProvider: identity.provider,
|
||||||
})
|
followUpModel: identity.modelId,
|
||||||
}
|
});
|
||||||
/>
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Maximum seconds
|
Maximum seconds
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { Model } from "./workspace/types";
|
||||||
|
|
||||||
|
export type ModelIdentity = { provider: string; modelId: string };
|
||||||
|
|
||||||
|
export function modelIdentity(model: Model): ModelIdentity | undefined {
|
||||||
|
if (!model.provider || !model.id) return undefined;
|
||||||
|
return { provider: model.provider, modelId: model.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelIdentityValue(identity: ModelIdentity) {
|
||||||
|
return JSON.stringify([identity.provider, identity.modelId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelIdentityFromValue(
|
||||||
|
value: string,
|
||||||
|
): ModelIdentity | undefined {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
if (
|
||||||
|
!Array.isArray(parsed) ||
|
||||||
|
parsed.length !== 2 ||
|
||||||
|
!parsed.every((part) => typeof part === "string")
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
return { provider: parsed[0], modelId: parsed[1] };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function catalogModels(
|
||||||
|
models: Model[],
|
||||||
|
configured: ModelIdentity[],
|
||||||
|
): Model[] {
|
||||||
|
const available = models.filter((model) => model.provider && model.id);
|
||||||
|
const keys = new Set(
|
||||||
|
available
|
||||||
|
.map(modelIdentity)
|
||||||
|
.filter((identity): identity is ModelIdentity => !!identity)
|
||||||
|
.map(modelIdentityValue),
|
||||||
|
);
|
||||||
|
for (const identity of configured) {
|
||||||
|
if (!identity.provider || !identity.modelId) continue;
|
||||||
|
const key = modelIdentityValue(identity);
|
||||||
|
if (keys.has(key)) continue;
|
||||||
|
available.unshift({ provider: identity.provider, id: identity.modelId });
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModelCatalog() {
|
||||||
|
const catalog = await invoke<{ models?: Model[] }>("get_model_catalog");
|
||||||
|
return catalog?.models ?? [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { QuickPaneSettings, UiState } from "../workspace/types";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
invoke: vi.fn(),
|
||||||
|
listen: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
onFocusChanged: vi.fn(),
|
||||||
|
onCloseRequested: vi.fn(),
|
||||||
|
focusHandler: undefined as
|
||||||
|
| ((event: { payload: boolean }) => void)
|
||||||
|
| undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
|
||||||
|
vi.mock("@tauri-apps/api/window", () => ({
|
||||||
|
getCurrentWindow: () => ({
|
||||||
|
hide: mocks.hide,
|
||||||
|
onFocusChanged: mocks.onFocusChanged,
|
||||||
|
onCloseRequested: mocks.onCloseRequested,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import QuickPane from "./QuickPane";
|
||||||
|
|
||||||
|
const settings: QuickPaneSettings = {
|
||||||
|
workspace: "/repo",
|
||||||
|
provider: "provider",
|
||||||
|
model: "quick/model",
|
||||||
|
followUpProvider: "provider",
|
||||||
|
followUpModel: "full/model",
|
||||||
|
instructions: "Stay read-only.",
|
||||||
|
maximumSeconds: 60,
|
||||||
|
maximumToolCalls: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const uiState: UiState = {
|
||||||
|
version: 1,
|
||||||
|
directoryOrder: [],
|
||||||
|
runtimeOrderByDirectory: {},
|
||||||
|
drafts: {},
|
||||||
|
scroll: {},
|
||||||
|
lastSeen: {},
|
||||||
|
quickPane: settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("QuickPane model catalog errors", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.focusHandler = undefined;
|
||||||
|
mocks.listen.mockResolvedValue(vi.fn());
|
||||||
|
mocks.onFocusChanged.mockImplementation((handler) => {
|
||||||
|
mocks.focusHandler = handler;
|
||||||
|
return Promise.resolve(vi.fn());
|
||||||
|
});
|
||||||
|
mocks.onCloseRequested.mockResolvedValue(vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a rejected focus reload after success without enabling handoff", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
let catalogCalls = 0;
|
||||||
|
mocks.invoke.mockImplementation((command: string) => {
|
||||||
|
switch (command) {
|
||||||
|
case "load_ui_state":
|
||||||
|
return Promise.resolve(uiState);
|
||||||
|
case "get_model_catalog":
|
||||||
|
catalogCalls += 1;
|
||||||
|
if (catalogCalls === 2)
|
||||||
|
return Promise.reject(new Error("catalog unavailable"));
|
||||||
|
return Promise.resolve({
|
||||||
|
models: [{ provider: "provider", id: "quick/model" }],
|
||||||
|
});
|
||||||
|
case "create_quick_runtime":
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
|
||||||
|
});
|
||||||
|
case "get_session_runtime_snapshot":
|
||||||
|
return Promise.resolve({
|
||||||
|
runtime: {},
|
||||||
|
state: { data: { isStreaming: true } },
|
||||||
|
extensions: [],
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<QuickPane />);
|
||||||
|
await waitFor(() => expect(catalogCalls).toBe(1));
|
||||||
|
await user.type(
|
||||||
|
screen.getByPlaceholderText(/Ask a quick question/),
|
||||||
|
"Inspect",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Ask" }));
|
||||||
|
await screen.findByText(/Quick request running/);
|
||||||
|
|
||||||
|
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
"Could not load model catalog: Error: catalog unavailable",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Continue" })).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
"Could not load model catalog: Error: catalog unavailable",
|
||||||
|
),
|
||||||
|
).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Continue" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+111
-65
@@ -1,8 +1,16 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ExtensionDialog } from "../components/ExtensionDialog";
|
import { ExtensionDialog } from "../components/ExtensionDialog";
|
||||||
|
import {
|
||||||
|
catalogModels,
|
||||||
|
getModelCatalog,
|
||||||
|
modelIdentity,
|
||||||
|
modelIdentityFromValue,
|
||||||
|
modelIdentityValue,
|
||||||
|
type ModelIdentity,
|
||||||
|
} from "../model-catalog";
|
||||||
import "./quick-pane.css";
|
import "./quick-pane.css";
|
||||||
import type {
|
import type {
|
||||||
Extension,
|
Extension,
|
||||||
@@ -68,9 +76,13 @@ export default function QuickPane() {
|
|||||||
const [request, setRequest] = useState("");
|
const [request, setRequest] = useState("");
|
||||||
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
||||||
const [models, setModels] = useState<Model[]>([]);
|
const [models, setModels] = useState<Model[]>([]);
|
||||||
const [selectedModel, setSelectedModel] = useState("");
|
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
|
||||||
|
provider: "",
|
||||||
|
modelId: "",
|
||||||
|
});
|
||||||
const [status, setStatus] = useState("Loading Quick Pane settings…");
|
const [status, setStatus] = useState("Loading Quick Pane settings…");
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
const [catalogError, setCatalogError] = useState<string>();
|
||||||
const [runtime, setRuntime] = useState<Runtime>();
|
const [runtime, setRuntime] = useState<Runtime>();
|
||||||
const [answer, setAnswer] = useState("");
|
const [answer, setAnswer] = useState("");
|
||||||
const [escalation, setEscalation] = useState<string>();
|
const [escalation, setEscalation] = useState<string>();
|
||||||
@@ -78,9 +90,26 @@ export default function QuickPane() {
|
|||||||
const runtimeRef = useRef<Runtime | undefined>(undefined);
|
const runtimeRef = useRef<Runtime | undefined>(undefined);
|
||||||
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, [selectedModel]),
|
||||||
|
[models, selectedModel],
|
||||||
|
);
|
||||||
|
const selectedModelValue =
|
||||||
|
selectedModel.provider && selectedModel.modelId
|
||||||
|
? modelIdentityValue(selectedModel)
|
||||||
|
: "";
|
||||||
|
const canContinue = !!escalation || (!!runtime && !!error);
|
||||||
|
|
||||||
const dismissPane = () => getCurrentWindow().hide();
|
const dismissPane = () => getCurrentWindow().hide();
|
||||||
const focusInput = () => inputRef.current?.focus();
|
const focusInput = () => inputRef.current?.focus();
|
||||||
|
const loadModelCatalog = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setModels(await getModelCatalog());
|
||||||
|
setCatalogError(undefined);
|
||||||
|
} catch (reason) {
|
||||||
|
setCatalogError(`Could not load model catalog: ${String(reason)}`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -91,22 +120,18 @@ export default function QuickPane() {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
nextSettings = { ...defaults, ...state.quickPane };
|
nextSettings = { ...defaults, ...state.quickPane };
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (active)
|
if (active) setError(`Could not load settings: ${String(reason)}`);
|
||||||
setError(`Could not load settings: ${String(reason)}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSettings(nextSettings);
|
setSettings(nextSettings);
|
||||||
setSettingsLoaded(true);
|
setSettingsLoaded(true);
|
||||||
setSelectedModel(`${nextSettings.provider}/${nextSettings.model}`);
|
setSelectedModel({
|
||||||
|
provider: nextSettings.provider,
|
||||||
|
modelId: nextSettings.model,
|
||||||
|
});
|
||||||
setStatus("");
|
setStatus("");
|
||||||
try {
|
if (active) await loadModelCatalog();
|
||||||
const catalog = await invoke<{ models?: Model[] }>("get_model_catalog");
|
|
||||||
if (active) setModels(catalog?.models ?? []);
|
|
||||||
} catch (reason) {
|
|
||||||
if (active)
|
|
||||||
setError(`Could not load model catalog: ${String(reason)}`);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
@@ -116,7 +141,7 @@ export default function QuickPane() {
|
|||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [loadModelCatalog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsLoaded) focusInput();
|
if (settingsLoaded) focusInput();
|
||||||
@@ -129,7 +154,10 @@ export default function QuickPane() {
|
|||||||
(event) => {
|
(event) => {
|
||||||
const nextSettings = { ...defaults, ...event.payload };
|
const nextSettings = { ...defaults, ...event.payload };
|
||||||
setSettings(nextSettings);
|
setSettings(nextSettings);
|
||||||
setSelectedModel(`${nextSettings.provider}/${nextSettings.model}`);
|
setSelectedModel({
|
||||||
|
provider: nextSettings.provider,
|
||||||
|
modelId: nextSettings.model,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -137,7 +165,10 @@ export default function QuickPane() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
const focusListener = paneWindow.onFocusChanged((event) => {
|
const focusListener = paneWindow.onFocusChanged((event) => {
|
||||||
if (event.payload) focusInput();
|
if (event.payload) {
|
||||||
|
focusInput();
|
||||||
|
void loadModelCatalog();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const closeListener = paneWindow.onCloseRequested((event) => {
|
const closeListener = paneWindow.onCloseRequested((event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -149,7 +180,7 @@ export default function QuickPane() {
|
|||||||
void focusListener.then((unlisten) => unlisten());
|
void focusListener.then((unlisten) => unlisten());
|
||||||
void closeListener.then((unlisten) => unlisten());
|
void closeListener.then((unlisten) => unlisten());
|
||||||
};
|
};
|
||||||
}, []);
|
}, [loadModelCatalog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runtime) return;
|
if (!runtime) return;
|
||||||
@@ -231,11 +262,10 @@ export default function QuickPane() {
|
|||||||
);
|
);
|
||||||
runtimeRef.current = result.runtime;
|
runtimeRef.current = result.runtime;
|
||||||
setRuntime(result.runtime);
|
setRuntime(result.runtime);
|
||||||
const [provider, modelId] = selectedModel.split("/", 2);
|
|
||||||
await invoke("set_model", {
|
await invoke("set_model", {
|
||||||
agentId: result.runtime.agentId,
|
agentId: result.runtime.agentId,
|
||||||
provider: provider || settings.provider,
|
provider: selectedModel.provider || settings.provider,
|
||||||
modelId: modelId || settings.model,
|
modelId: selectedModel.modelId || settings.model,
|
||||||
});
|
});
|
||||||
await invoke("set_thinking_level", {
|
await invoke("set_thinking_level", {
|
||||||
agentId: result.runtime.agentId,
|
agentId: result.runtime.agentId,
|
||||||
@@ -296,17 +326,16 @@ export default function QuickPane() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="quick-pane">
|
<main className="quick-pane">
|
||||||
<header style={{ display: "flex", justifyContent: "space-between" }}>
|
<header>
|
||||||
<strong>Quick Pane</strong>
|
<strong>Quick Pane</strong>
|
||||||
<button onClick={dismissPane}>Close</button>
|
<button onClick={dismissPane}>Close</button>
|
||||||
</header>
|
</header>
|
||||||
<textarea
|
<textarea
|
||||||
|
className="quick-pane-request"
|
||||||
autoFocus
|
autoFocus
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={request}
|
value={request}
|
||||||
onChange={(e) => {
|
onChange={(event) => setRequest(event.currentTarget.value)}
|
||||||
setRequest(e.target.value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (
|
if (
|
||||||
event.key === "Enter" &&
|
event.key === "Enter" &&
|
||||||
@@ -318,51 +347,68 @@ export default function QuickPane() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Ask a quick question or give a quick instruction"
|
placeholder="Ask a quick question or give a quick instruction"
|
||||||
style={{ width: "100%", minHeight: 90, marginTop: 14 }}
|
|
||||||
/>
|
/>
|
||||||
<label>
|
<section className="quick-pane-output" aria-live="polite">
|
||||||
Model
|
{status && <p>{status}</p>}
|
||||||
<select
|
{answer && <pre>{answer}</pre>}
|
||||||
value={selectedModel}
|
{error && <p role="alert">{error}</p>}
|
||||||
onChange={(event) => setSelectedModel(event.currentTarget.value)}
|
{catalogError && <p role="alert">{catalogError}</p>}
|
||||||
>
|
<ExtensionDialog extension={extension} onRespond={respond} />
|
||||||
<option value={`${settings.provider}/${settings.model}`}>
|
</section>
|
||||||
{settings.provider}/{settings.model}
|
<footer className="quick-pane-controls">
|
||||||
</option>
|
<label>
|
||||||
{models.map((model) => (
|
<span>Model</span>
|
||||||
<option
|
<select
|
||||||
key={`${model.provider}/${model.id}`}
|
aria-label="Model"
|
||||||
value={`${model.provider}/${model.id}`}
|
value={selectedModelValue}
|
||||||
>
|
onChange={(event) => {
|
||||||
{model.provider}/{model.id ?? model.name}
|
const identity = modelIdentityFromValue(
|
||||||
|
event.currentTarget.value,
|
||||||
|
);
|
||||||
|
if (identity) setSelectedModel(identity);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select model
|
||||||
</option>
|
</option>
|
||||||
))}
|
{modelOptions.map((model) => {
|
||||||
</select>
|
const identity = modelIdentity(model);
|
||||||
</label>
|
if (!identity) return null;
|
||||||
<label>
|
return (
|
||||||
Thinking
|
<option
|
||||||
<select
|
key={modelIdentityValue(identity)}
|
||||||
value={thinkingLevel}
|
value={modelIdentityValue(identity)}
|
||||||
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
|
>
|
||||||
|
{identity.provider}/{identity.modelId}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Thinking</span>
|
||||||
|
<select
|
||||||
|
aria-label="Thinking"
|
||||||
|
value={thinkingLevel}
|
||||||
|
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
disabled={!settingsLoaded || !!invalid || !request.trim()}
|
||||||
|
onClick={run}
|
||||||
>
|
>
|
||||||
<option value="low">Low</option>
|
Ask
|
||||||
<option value="medium">Medium</option>
|
</button>
|
||||||
<option value="high">High</option>
|
{canContinue && (
|
||||||
</select>
|
<button className="quiet" onClick={continueFull}>
|
||||||
</label>
|
Continue
|
||||||
<button
|
</button>
|
||||||
disabled={!settingsLoaded || !!invalid || !request.trim()}
|
)}
|
||||||
onClick={run}
|
</footer>
|
||||||
>
|
|
||||||
Ask
|
|
||||||
</button>
|
|
||||||
{(escalation || error) && (
|
|
||||||
<button onClick={continueFull}>Continue in full session</button>
|
|
||||||
)}
|
|
||||||
{status && <p>{status}</p>}
|
|
||||||
{answer && <pre style={{ whiteSpace: "pre-wrap" }}>{answer}</pre>}
|
|
||||||
{error && <p role="alert">{error}</p>}
|
|
||||||
<ExtensionDialog extension={extension} onRespond={respond} />
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -11,11 +18,14 @@ body {
|
|||||||
|
|
||||||
.quick-pane {
|
.quick-pane {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 100vh;
|
display: flex;
|
||||||
overflow-y: auto;
|
flex-direction: column;
|
||||||
padding: 12px;
|
gap: 8px;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
color: #e6e3df;
|
color: #e6e3df;
|
||||||
font: 14px/1.4 Inter, ui-sans-serif, system-ui, sans-serif;
|
font: 14px/1.35 Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
background: rgba(34, 34, 34, 0.98);
|
background: rgba(34, 34, 34, 0.98);
|
||||||
border: 1px solid #4b4b4b;
|
border: 1px solid #4b4b4b;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -24,9 +34,9 @@ body {
|
|||||||
|
|
||||||
.quick-pane header {
|
.quick-pane header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 12px;
|
|
||||||
color: #f0a347;
|
color: #f0a347;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@@ -36,14 +46,15 @@ body {
|
|||||||
|
|
||||||
.quick-pane button,
|
.quick-pane button,
|
||||||
.quick-pane select {
|
.quick-pane select {
|
||||||
|
min-width: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 6px 9px;
|
padding: 5px 8px;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane button {
|
.quick-pane button {
|
||||||
margin-right: 6px;
|
flex: 0 0 auto;
|
||||||
background: #f0a347;
|
background: #f0a347;
|
||||||
color: #25180b;
|
color: #25180b;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
@@ -59,52 +70,108 @@ body {
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-pane button.quiet {
|
||||||
|
color: #e6e3df;
|
||||||
|
background: #45413c;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-pane select {
|
.quick-pane select {
|
||||||
color: #e6e3df;
|
color: #e6e3df;
|
||||||
background: #353535;
|
background: #353535;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane textarea {
|
.quick-pane-request {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 92px;
|
min-height: 180px;
|
||||||
|
flex: 1 1 auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
resize: none;
|
||||||
border: 1px solid #484848;
|
border: 1px solid #484848;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #e6e3df;
|
color: #e6e3df;
|
||||||
background: #292929;
|
background: #292929;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
resize: vertical;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane textarea:focus,
|
.quick-pane-request:focus,
|
||||||
.quick-pane button:focus-visible,
|
.quick-pane button:focus-visible,
|
||||||
.quick-pane select:focus-visible {
|
.quick-pane select:focus-visible {
|
||||||
outline: 3px solid #f0a347;
|
outline: 3px solid #f0a347;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane > label {
|
.quick-pane-output {
|
||||||
display: inline-flex;
|
max-height: 108px;
|
||||||
align-items: center;
|
min-height: 0;
|
||||||
gap: 7px;
|
flex: 0 1 auto;
|
||||||
margin: 10px 8px 10px 0;
|
overflow: auto;
|
||||||
color: #bbb7b0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-pane > pre,
|
|
||||||
.quick-pane > p {
|
|
||||||
margin: 10px 0 0;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid #484848;
|
border: 1px solid #484848;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
white-space: pre-wrap;
|
|
||||||
background: #292929;
|
background: #292929;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane [role="alert"] {
|
.quick-pane-output:empty {
|
||||||
border-color: #a65b67;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-output > pre,
|
||||||
|
.quick-pane-output > p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-output > :not(:last-child) {
|
||||||
|
border-bottom: 1px solid #484848;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-output [role="alert"] {
|
||||||
color: #ffd7db;
|
color: #ffd7db;
|
||||||
background: #3d252b;
|
background: #3d252b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-pane-controls {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-controls label {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
color: #bbb7b0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-controls label:first-child {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-controls label:nth-child(2) {
|
||||||
|
flex: 0 1 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-controls select {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-output .extension-backdrop,
|
||||||
|
.quick-pane-output .extension {
|
||||||
|
position: static;
|
||||||
|
display: block;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-output .extension h2,
|
||||||
|
.quick-pane-output .extension p {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user