fix(quick-pane): restore model catalog controls

This commit is contained in:
2026-08-25 22:34:58 +02:00
parent ab482d3daa
commit 1b0b34fad8
10 changed files with 706 additions and 170 deletions
+54
View File
@@ -78,6 +78,60 @@ test("starts only the home agent and creates other worktree agents on explicit s
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
});
test("model catalog retries unsuccessful responses and falls back to last success", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
const registry = createAgentRegistry({
homeWorktree: worktrees.home,
sessionRoot: worktrees.sessionRoot,
startAdapter: fixture.startAdapter,
});
await registry.start();
await registry.selectWorktree(worktrees.feature);
const expected = [{ provider: "provider", id: "family/model" }];
const rejected = [{ provider: "wrong", id: "must-not-be-cached" }];
const sends = fixture.calls.map(({ adapter }) => adapter.send.bind(adapter));
let homeCatalogCalls = 0;
let featureCatalogCalls = 0;
fixture.calls[0].adapter.send = async (command, options) => {
if (command.type === "get_available_models") {
homeCatalogCalls += 1;
return {
type: "response",
success: false,
data: { models: rejected },
};
}
return sends[0](command, options);
};
fixture.calls[1].adapter.send = async (command, options) => {
if (command.type === "get_available_models") {
featureCatalogCalls += 1;
return { type: "response", success: true, data: { models: expected } };
}
return sends[1](command, options);
};
assert.deepEqual(await registry.getModelCatalog(), expected);
assert.equal(homeCatalogCalls, 1);
assert.equal(featureCatalogCalls, 1);
fixture.calls[1].adapter.send = async (command, options) => {
if (command.type === "get_available_models") {
featureCatalogCalls += 1;
return {
type: "response",
success: true,
data: { models: "malformed" },
};
}
return sends[1](command, options);
};
assert.deepEqual(await registry.getModelCatalog(), expected);
assert.equal(homeCatalogCalls, 2);
assert.equal(featureCatalogCalls, 2);
await registry.stop();
});
test("sends abort without waiting behind a blocked prompt", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
+15 -9
View File
@@ -987,7 +987,7 @@ test("closing retains a saved-session lease until the child stops", async () =>
await registry.stop();
});
test("first migration retains a missing legacy session as failed dormant intent", async () => {
test("first migration retains missing intent and starts an idle home runtime", async () => {
const paths = await fixture();
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
const missing = join(sessionDir, "missing.jsonl");
@@ -1003,14 +1003,18 @@ test("first migration retains a missing legacy session as failed dormant intent"
startAdapter: adapters.startAdapter,
});
await registry.start();
const runtime = registry.getWorkspace().directories[0].runtimes[0];
assert.equal(runtime.state, "failed");
assert.equal(runtime.sessionPath, missing);
assert.equal(adapters.calls.length, 0);
const runtimes = registry.getWorkspace().directories[0].runtimes;
const failed = runtimes.find((runtime) => runtime.state === "failed");
const live = runtimes.find((runtime) => runtime.state === "idle");
assert.equal(failed.sessionPath, missing);
assert.equal(live.worktreePath, paths.home);
assert.equal(adapters.calls.length, 1);
const manifest = await readJson(
join(paths.sessionRoot, "bridge-workspace-v2.json"),
);
assert.equal(manifest.runtimes[0].sessionPath, missing);
assert.ok(
manifest.runtimes.some((runtime) => runtime.sessionPath === missing),
);
await registry.stop();
});
@@ -1198,7 +1202,7 @@ test("snapshot rejects results from an adapter replaced during recovery", async
await registry.stop();
});
test("closing the last runtime persists a zero-runtime restart", async () => {
test("restoration with no live adapter starts one idle home runtime", async () => {
const paths = await fixture();
const firstAdapters = adapterFactory();
const first = createAgentRegistry({
@@ -1216,8 +1220,10 @@ test("closing the last runtime persists a zero-runtime restart", async () => {
startAdapter: restoredAdapters.startAdapter,
});
await restored.start();
assert.equal(restored.getWorkspaceSummary().openCount, 0);
assert.equal(restoredAdapters.calls.length, 0);
assert.equal(restored.getWorkspaceSummary().openCount, 1);
assert.equal(restoredAdapters.calls.length, 1);
assert.equal(restored.listAgents()[0].worktreePath, paths.home);
assert.equal(restored.listAgents()[0].state, "idle");
await restored.stop();
});
+39 -15
View File
@@ -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, /setInterval\(\(\) => void refresh\(\), 800\)/);
assert.match(
source,
/<pre style=\{\{ whiteSpace: "pre-wrap" \}\}>\{answer\}<\/pre>/,
);
assert.match(
source,
/\{\(escalation \|\| error\) && \(\s*<button onClick=\{continueFull\}>Continue in full session<\/button>\s*\)\}/,
);
assert.match(source, /\{answer && <pre>\{answer\}<\/pre>\}/);
assert.match(source, /const canContinue = !!escalation \|\| \(!!runtime && !!error\)/);
assert.match(source, /\{canContinue && \(/);
});
test("main Settings owns Quick Pane configuration while pane keeps request controls", async () => {
const [pane, app, persistence] = await Promise.all([
test("main Settings uses shared catalog selectors with provider derived from models", async () => {
const [pane, app, catalog, persistence] = await Promise.all([
readFile(
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
"utf8",
),
readFile(new URL("../ui/src/App.tsx", import.meta.url), "utf8"),
readFile(new URL("../ui/src/model-catalog.ts", import.meta.url), "utf8"),
readFile(
new URL("../ui/src/workspace/persistence.ts", import.meta.url),
"utf8",
),
]);
assert.match(app, /load_ui_state/);
assert.match(app, /save_ui_state/);
assert.match(catalog, /invoke<\{ models\?: Model\[\] \}>\("get_model_catalog"\)/);
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(pane, /get_model_catalog/);
assert.match(pane, /getModelCatalog\(\)/);
assert.match(pane, /set_thinking_level/);
assert.doesNotMatch(pane, /showSettings/);
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/);
});
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 () => {
const source = await readFile(
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/);
});
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([
readFile(
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 },
);
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, /focusInput\(\)/);
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/,
);
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.doesNotMatch(stylesheet, /#8aa9d6|#46494f|#23252a/);
});