From b44b1be4b78807e63953f47ebaeb8aaced8737be Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 28 Aug 2026 12:07:50 +0200 Subject: [PATCH] feat(quick-pane): promote active runtime --- src/bridge/agent-registry.js | 273 ++++++++++- src/bridge/service.js | 4 + src/protocol/index.js | 1 + test/protocol.test.js | 1 + test/quick-pane-ui.test.js | 30 +- test/quick-runtime-promotion.test.js | 473 ++++++++++++++++++++ ui/src-tauri/src/bridge.rs | 11 + ui/src-tauri/src/lib.rs | 102 ++++- ui/src/components/ConversationWorkspace.tsx | 2 +- ui/src/components/ThinkingSelector.test.tsx | 14 +- ui/src/components/components.test.tsx | 29 ++ ui/src/model-catalog.test.ts | 61 ++- ui/src/model-catalog.ts | 44 +- ui/src/quick-pane/QuickPane.test.tsx | 64 ++- ui/src/quick-pane/QuickPane.tsx | 55 +-- ui/src/thinking-parity.test.tsx | 140 ++++++ ui/src/workspace/reducer.test.ts | 50 +++ ui/src/workspace/types.ts | 21 +- ui/src/workspace/useWorkspace.ts | 49 +- 19 files changed, 1298 insertions(+), 126 deletions(-) create mode 100644 test/quick-runtime-promotion.test.js create mode 100644 ui/src/thinking-parity.test.tsx diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js index 9659a64..e802aaa 100644 --- a/src/bridge/agent-registry.js +++ b/src/bridge/agent-registry.js @@ -5,8 +5,11 @@ import { readdir, readFile, realpath, + rename, rm, stat, + symlink, + unlink, writeFile, } from "node:fs/promises"; import path from "node:path"; @@ -74,6 +77,69 @@ async function persistSessionReference(sessionDir, sessionPath, worktreePath) { ); } +export async function promoteQuickSessionDirectory( + quickDir, + sessionDir, + { renameFile = rename, makeSymlink = symlink } = {}, +) { + const stagingDir = `${quickDir}.promoting-${randomUUID()}`; + const moved = []; + let quickStaged = false; + let quickLinked = false; + + async function rollback() { + if (quickLinked) await unlink(quickDir).catch(() => {}); + for (const entry of moved.reverse()) + await renameFile(path.join(sessionDir, entry), path.join(stagingDir, entry)); + if (quickStaged) await renameFile(stagingDir, quickDir); + } + + try { + await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE }); + await chmod(sessionDir, SESSION_DIRECTORY_MODE); + await renameFile(quickDir, stagingDir); + quickStaged = true; + await makeSymlink(sessionDir, quickDir, "dir"); + quickLinked = true; + for (const entry of await readdir(stagingDir)) { + const destination = path.join(sessionDir, entry); + try { + await stat(destination); + throw new Error("quick session conflicts with an existing session file"); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + await renameFile(path.join(stagingDir, entry), destination); + moved.push(entry); + } + } catch (error) { + try { + await rollback(); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + "could not promote quick session directory; staging retained", + ); + } + throw error; + } + + return { + async rollback() { + try { + await rollback(); + } catch (error) { + throw new Error( + `could not roll back quick session promotion; staging retained: ${error.message}`, + ); + } + }, + async finalize() { + await rm(stagingDir, { recursive: true, force: true }).catch(() => {}); + }, + }; +} + function sessionPreview(content) { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; @@ -245,6 +311,7 @@ export function createAgentRegistry({ homeWorktree, sessionRoot, startAdapter = startPiRpcAdapter, + workspaceStoreFactory = createWorkspaceStore, eventLimit = DEFAULT_EVENT_LIMIT, maxRecoveryAttempts, recoveryDelayForAttempt, @@ -257,9 +324,11 @@ export function createAgentRegistry({ throw new TypeError("sessionRoot must be an absolute path"); if (typeof startAdapter !== "function") throw new TypeError("startAdapter must be a function"); + if (typeof workspaceStoreFactory !== "function") + throw new TypeError("workspaceStoreFactory must be a function"); const bridgeInstanceId = randomUUID(); - const workspaceStore = createWorkspaceStore(sessionRoot); + const workspaceStore = workspaceStoreFactory(sessionRoot); const runtimesByRuntimeId = new Map(); const runtimesByAgentId = new Map(); const runtimesByWorktreePath = new Map(); @@ -272,6 +341,7 @@ export function createAgentRegistry({ const workspaceEvents = []; let workspaceSequence = 0; let manifestIssue; + let manifestWriteQueue = Promise.resolve(); let canonicalHomeWorktree; let lastSuccessfulModelCatalog; let stopping = false; @@ -318,9 +388,15 @@ export function createAgentRegistry({ }; } - async function persistWorkspace() { - await workspaceStore.save(manifest()); - manifestIssue = undefined; + function persistWorkspace() { + const operation = manifestWriteQueue + .catch(() => {}) + .then(async () => { + await workspaceStore.save(manifest()); + manifestIssue = undefined; + }); + manifestWriteQueue = operation.catch(() => {}); + return operation; } function addWorktreeRuntime(runtime) { @@ -430,10 +506,23 @@ export function createAgentRegistry({ return resolvedSessionPath; } + async function projectQuickSessionPath(quickDir, sessionDir, sessionPath) { + const activePath = await ownedSessionPath(quickDir, sessionPath); + const relativePath = path.relative(await realpath(quickDir), activePath); + return ownedSessionPath(sessionDir, path.join(sessionDir, relativePath), { + allowMissing: true, + }); + } + async function updateRuntimeIdentity( runtime, stateResponse, - { expectedSessionPath, adapter = runtime.adapter } = {}, + { + expectedSessionPath, + adapter = runtime.adapter, + persist = true, + indexSession = true, + } = {}, ) { if ( runtime.stopped || @@ -477,15 +566,17 @@ export function createAgentRegistry({ runtime.sessionName = state.sessionName; if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId; if (resolved) { - if ( - runtime.sessionPath && - runtime.sessionPath !== resolved && - runtimesBySessionPath.get(runtime.sessionPath) === runtime - ) - runtimesBySessionPath.delete(runtime.sessionPath); + if (indexSession) { + if ( + runtime.sessionPath && + runtime.sessionPath !== resolved && + runtimesBySessionPath.get(runtime.sessionPath) === runtime + ) + runtimesBySessionPath.delete(runtime.sessionPath); + runtimesBySessionPath.set(resolved, runtime); + releaseSessionReservation(resolved, runtime); + } runtime.sessionPath = resolved; - runtimesBySessionPath.set(resolved, runtime); - releaseSessionReservation(resolved, runtime); if (runtime.legacyDefault) await persistSessionReference( runtime.sessionDir, @@ -493,7 +584,16 @@ export function createAgentRegistry({ runtime.worktreePath, ); } - await persistWorkspace(); + if (persist) await persistWorkspace(); + } + + async function restoreSessionReference(sessionDir, previous) { + const referencePath = path.join(sessionDir, SESSION_REFERENCE_FILE); + if (previous === undefined) { + await rm(referencePath, { force: true }); + return; + } + await writeFile(referencePath, previous, { encoding: "utf8", mode: 0o600 }); } function publishAgent(runtime, type, data) { @@ -587,6 +687,8 @@ export function createAgentRegistry({ adapter: undefined, supervisor: undefined, stopped: false, + promoting: false, + promotionPromise: undefined, recoveryPromise: undefined, }; } @@ -1000,6 +1102,8 @@ export function createAgentRegistry({ function closeRuntime(runtimeId) { const runtime = getRuntime(runtimeId); + if (runtime.promoting) + return runtime.promotionPromise.then(() => closeRuntime(runtimeId)); if (runtime.state === "closing" || runtime.state === "stopped") return Promise.reject(new Error("session runtime is closing")); const wasWorkingAtRequest = @@ -1018,6 +1122,136 @@ export function createAgentRegistry({ return result; } + function promoteQuickRuntime(runtimeId) { + const runtime = getRuntime(runtimeId); + if (!runtime.ephemeral) return Promise.resolve(publicRuntime(runtime)); + if (runtime.state === "closing" || runtime.state === "stopped") + return Promise.reject(new Error("session runtime is closing")); + if (runtime.promotionPromise) return runtime.promotionPromise; + + runtime.promoting = true; + const promotion = runtime.operationQueue.then(async () => { + if (!runtime.ephemeral) return publicRuntime(runtime); + if (runtime.state === "closing" || runtime.state === "stopped") + throw new Error("session runtime is closing"); + const adapter = runtime.adapter; + if (!adapter) throw new Error("Pi is not currently running"); + const quickDir = runtime.sessionDir; + const sessionDir = sessionDirectoryFor(sessionRoot, runtime.worktreePath); + const previous = { + sessionDir: runtime.sessionDir, + sessionPath: runtime.sessionPath, + sessionId: runtime.sessionId, + sessionName: runtime.sessionName, + sessionLease: runtime.sessionPath + ? runtimesBySessionPath.get(runtime.sessionPath) === runtime + : false, + managed: managedWorktrees.has(runtime.worktreePath), + }; + let directoryPromotion; + let previousReference; + let reservedSessionPath; + let manifestStateChanged = false; + try { + await mkdir(sessionDir, { + recursive: true, + mode: SESSION_DIRECTORY_MODE, + }); + await chmod(sessionDir, SESSION_DIRECTORY_MODE); + if (runtime.sessionPath) { + reservedSessionPath = await projectQuickSessionPath( + quickDir, + sessionDir, + runtime.sessionPath, + ); + reserveSessionPath(reservedSessionPath, runtime); + } + directoryPromotion = await promoteQuickSessionDirectory( + quickDir, + sessionDir, + ); + runtime.sessionDir = sessionDir; + if (reservedSessionPath) runtime.sessionPath = reservedSessionPath; + const state = await adapter.send({ type: "get_state" }); + await updateRuntimeIdentity(runtime, state, { + adapter, + persist: false, + indexSession: false, + }); + // A changed identity still needs a lease through manifest commit. + if (runtime.sessionPath && runtime.sessionPath !== reservedSessionPath) + reserveSessionPath(runtime.sessionPath, runtime); + if ( + previous.sessionPath && + previous.sessionPath !== runtime.sessionPath && + runtimesBySessionPath.get(previous.sessionPath) === runtime + ) + runtimesBySessionPath.delete(previous.sessionPath); + runtime.ephemeral = false; + managedWorktrees.add(runtime.worktreePath); + manifestStateChanged = true; + try { + previousReference = await readFile( + path.join(sessionDir, SESSION_REFERENCE_FILE), + "utf8", + ); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + await persistSessionReference( + sessionDir, + runtime.sessionPath, + runtime.worktreePath, + ); + await persistWorkspace(); + if (runtime.sessionPath) + runtimesBySessionPath.set(runtime.sessionPath, runtime); + if (reservedSessionPath) + releaseSessionReservation(reservedSessionPath, runtime); + if (runtime.sessionPath !== reservedSessionPath) + releaseSessionReservation(runtime.sessionPath, runtime); + await directoryPromotion.finalize(); + publishWorkspace("runtime_promoted", runtime, { + runtime: publicRuntime(runtime), + }); + return publicRuntime(runtime); + } catch (error) { + if (directoryPromotion) + await restoreSessionReference(sessionDir, previousReference).catch( + () => {}, + ); + if (reservedSessionPath) + releaseSessionReservation(reservedSessionPath, runtime); + if (runtime.sessionPath !== reservedSessionPath) + releaseSessionReservation(runtime.sessionPath, runtime); + if ( + runtime.sessionPath && + runtimesBySessionPath.get(runtime.sessionPath) === runtime + ) + runtimesBySessionPath.delete(runtime.sessionPath); + runtime.sessionDir = previous.sessionDir; + runtime.sessionPath = previous.sessionPath; + runtime.sessionId = previous.sessionId; + runtime.sessionName = previous.sessionName; + runtime.ephemeral = true; + if (previous.sessionLease && previous.sessionPath) + runtimesBySessionPath.set(previous.sessionPath, runtime); + if (previous.managed) managedWorktrees.add(runtime.worktreePath); + else managedWorktrees.delete(runtime.worktreePath); + if (manifestStateChanged) await persistWorkspace().catch(() => {}); + if (directoryPromotion) await directoryPromotion.rollback(); + throw error; + } + }); + const tracked = promotion.finally(() => { + runtime.promoting = false; + runtime.promotionPromise = undefined; + }); + runtime.operationQueue = tracked.catch(() => {}); + runtime.promotionPromise = tracked; + return tracked; + } + async function refreshAfterLegacySessionChange( runtime, response, @@ -1208,13 +1442,19 @@ export function createAgentRegistry({ async createQuickRuntime(worktreePath) { return openRuntime({ worktreePath, ephemeral: true }); }, + promoteQuickRuntime, async openSessionRuntime(worktreePath, sessionPath) { return openRuntime({ worktreePath, sessionPath }); }, closeSessionRuntime: closeRuntime, async closeQuickRuntime(runtimeId) { const runtime = getRuntime(runtimeId); - if (!runtime.ephemeral) throw new Error("runtime is not a quick runtime"); + if (runtime.promoting) { + await runtime.promotionPromise; + return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath }; + } + if (!runtime.ephemeral) + return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath }; return closeRuntime(runtimeId); }, async getModelCatalog() { @@ -1478,7 +1718,8 @@ export function createAgentRegistry({ }, async route(agentId, operation, payload = {}) { const runtime = getAgent(agentId); - if (operation === "abort") return runtime.adapter.send({ type: "abort" }); + if (operation === "abort" && !runtime.promoting) + return runtime.adapter.send({ type: "abort" }); return enqueue(runtime, async () => { if (operation === "switch_session") { if (runtime.state === "streaming") diff --git a/src/bridge/service.js b/src/bridge/service.js index 4daf5ea..8f8f705 100644 --- a/src/bridge/service.js +++ b/src/bridge/service.js @@ -54,6 +54,10 @@ export async function startBridgeService({ request.payload.worktreePath, ), }; + case "promote_quick_runtime": + return { + runtime: await registry.promoteQuickRuntime(request.payload.runtimeId), + }; case "open_session_runtime": return { runtime: await registry.openSessionRuntime( diff --git a/src/protocol/index.js b/src/protocol/index.js index fe122a6..a1c5813 100644 --- a/src/protocol/index.js +++ b/src/protocol/index.js @@ -14,6 +14,7 @@ const requestOperations = new Map([ ["get_model_catalog", { agent: false, payload: "none" }], ["create_session_runtime", { agent: false, payload: "worktree" }], ["create_quick_runtime", { agent: false, payload: "worktree" }], + ["promote_quick_runtime", { agent: false, payload: "runtime" }], ["open_session_runtime", { agent: false, payload: "worktreeSession" }], ["close_session_runtime", { agent: false, payload: "runtime" }], ["close_quick_runtime", { agent: false, payload: "runtime" }], diff --git a/test/protocol.test.js b/test/protocol.test.js index 3528512..3636071 100644 --- a/test/protocol.test.js +++ b/test/protocol.test.js @@ -125,6 +125,7 @@ test("accepts additive multi-session runtime operations and numeric workspace cu { op: "get_workspace_summary" }, { op: "create_session_runtime", payload: { worktreePath } }, { op: "create_quick_runtime", payload: { worktreePath } }, + { op: "promote_quick_runtime", payload: { runtimeId: "runtime-1" } }, { op: "open_session_runtime", payload: { worktreePath, sessionPath } }, { op: "close_session_runtime", payload: { runtimeId: "runtime-1" } }, { op: "close_quick_runtime", payload: { runtimeId: "runtime-1" } }, diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js index 629082e..e9e8205 100644 --- a/test/quick-pane-ui.test.js +++ b/test/quick-pane-ui.test.js @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -test("Quick Pane polls snapshots, renders answers, and gates manual transcript handoff", async () => { +test("Quick Pane polls snapshots, renders answers, and promotes runtime without a handoff", async () => { const source = await readFile( new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url), "utf8", @@ -13,7 +13,12 @@ test("Quick Pane polls snapshots, renders answers, and gates manual transcript h assert.match(source, /className="quick-pane-notice escalation"/); assert.match(source, /className="quick-pane-assistant-footer"/); assert.match(source, /Continue in full session/); - assert.match(source, /Quick Pane transcript:/); + assert.match( + source, + /await invoke<\{ runtime: Runtime \}>\("promote_quick_runtime", \{\s+runtimeId: current\.runtimeId,/, + ); + assert.doesNotMatch(source, /Quick Pane transcript:/); + assert.doesNotMatch(source, /create_session_runtime/); assert.doesNotMatch(source, /maximumSeconds|Quick time budget/); }); @@ -166,6 +171,27 @@ test("Quick Pane dismiss and mutually exclusive window visibility stay native-se assert.ok(quickClearMain >= 0 && quickClearMain < showQuick); }); +test("native promotion owns visibility and Main consumes pending runtime after startup refresh", async () => { + const [nativeSource, workspaceSource] = await Promise.all([ + readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"), + readFile(new URL("../ui/src/workspace/useWorkspace.ts", import.meta.url), "utf8"), + ]); + const promotion = nativeSource.slice( + nativeSource.indexOf("fn show_main_for_promoted_runtime"), + nativeSource.indexOf("fn toggle_quick_pane"), + ); + assert.match(promotion, /get_or_create_main_with_visibility\(app, false\)/); + assert.ok(promotion.indexOf("quick_pane\n .hide()") < promotion.indexOf("window\n .show()")); + assert.ok(promotion.indexOf('visibility.hidden("quick-pane")') < promotion.indexOf("window\n .show()")); + assert.match(promotion, /\*pending = Some\(runtime_id\)/); + assert.match(nativeSource, /fn get_pending_promoted_runtime/); + assert.match(nativeSource, /fn consume_pending_promoted_runtime/); + assert.match(workspaceSource, /await promotedListenerReady/); + assert.match(workspaceSource, /await refresh\(loaded\)/); + assert.match(workspaceSource, /get_pending_promoted_runtime/); + assert.match(workspaceSource, /consume_pending_promoted_runtime/); +}); + 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), diff --git a/test/quick-runtime-promotion.test.js b/test/quick-runtime-promotion.test.js new file mode 100644 index 0000000..74869c2 --- /dev/null +++ b/test/quick-runtime-promotion.test.js @@ -0,0 +1,473 @@ +import assert from "node:assert/strict"; +import { + lstat, + mkdir, + mkdtemp, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + createAgentRegistry, + promoteQuickSessionDirectory, + sessionDirectoryPath, +} from "../src/bridge/agent-registry.js"; +import { createWorkspaceStore } from "../src/bridge/workspace-store.js"; + +function adapters() { + const calls = []; + return { + calls, + startAdapter(options) { + const adapter = { + sent: [], + stopped: false, + state: {}, + async send(command) { + this.sent.push(command); + return { + type: "response", + command: command.type, + success: true, + data: command.type === "get_state" ? this.state : {}, + }; + }, + respondToExtension() {}, + async stop() { + this.stopped = true; + }, + }; + calls.push({ options, adapter }); + return adapter; + }, + }; +} + +test("promotes an active quick runtime in place and makes stale quick cleanup harmless", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-")); + const home = join(root, "home"); + await mkdir(home); + const fixture = adapters(); + const registry = createAgentRegistry({ + homeWorktree: home, + sessionRoot: join(root, "sessions"), + startAdapter: fixture.startAdapter, + }); + await registry.start(); + const quick = await registry.createQuickRuntime(home); + const quickAdapter = fixture.calls.at(-1).adapter; + await registry.route(quick.agentId, "prompt", { message: "keep working" }); + const events = []; + const subscription = registry.subscribeWorkspace(0, (event) => + events.push(event), + ); + const [promoted, repeated] = await Promise.all([ + registry.promoteQuickRuntime(quick.runtimeId), + registry.promoteQuickRuntime(quick.runtimeId), + ]); + assert.equal(promoted.runtimeId, quick.runtimeId); + assert.equal(repeated.agentId, quick.agentId); + assert.equal(fixture.calls.at(-1).adapter, quickAdapter); + assert.equal(quickAdapter.stopped, false); + assert.equal(promoted.sessionPath, undefined); + assert.ok(events.some((event) => event.type === "runtime_promoted")); + assert.equal( + registry + .getWorkspace() + .directories[0].runtimes.some( + (runtime) => runtime.runtimeId === quick.runtimeId, + ), + true, + ); + assert.match( + await readFile(join(root, "sessions", "bridge-workspace-v2.json"), "utf8"), + new RegExp(quick.runtimeId), + ); + assert.match( + await readFile( + join( + sessionDirectoryPath(join(root, "sessions"), home), + "bridge-agent.json", + ), + "utf8", + ), + /"worktreePath"/, + ); + await registry.closeQuickRuntime(quick.runtimeId); + assert.equal(quickAdapter.stopped, false); + await registry.route(quick.agentId, "abort"); + subscription.unsubscribe(); + await registry.stop(); +}); + +test("promotion refreshes active JSONL identity without replacing runtime state", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-quick-identity-")); + const home = join(root, "home"); + await mkdir(home); + const fixture = adapters(); + const registry = createAgentRegistry({ + homeWorktree: home, + sessionRoot: join(root, "sessions"), + startAdapter: fixture.startAdapter, + }); + await registry.start(); + const quick = await registry.createQuickRuntime(home); + const call = fixture.calls.at(-1); + const quickPath = join(call.options.sessionDir, "active.jsonl"); + await writeFile(quickPath, '{"type":"session"}\n'); + call.adapter.state = { sessionFile: quickPath, sessionId: "session-active" }; + call.options.onEvent({ + type: "queue", + data: { event: { pendingMessageCount: 2 } }, + }); + call.options.onEvent({ + type: "extension_ui_request", + data: { event: { id: "extension-1", method: "confirm" } }, + }); + await registry.route(quick.agentId, "set_model", { + provider: "test", + modelId: "model-1", + }); + await registry.route(quick.agentId, "set_thinking_level", { level: "high" }); + + const promoted = await registry.promoteQuickRuntime(quick.runtimeId); + const snapshot = await registry.getSessionRuntimeSnapshot(quick.runtimeId); + assert.equal(promoted.runtimeId, quick.runtimeId); + assert.equal(promoted.agentId, quick.agentId); + assert.equal(promoted.sessionId, "session-active"); + assert.match(promoted.sessionPath, /active\.jsonl$/); + assert.equal(snapshot.runtime.queueCount, 2); + assert.equal(snapshot.extensions[0].id, "extension-1"); + assert.deepEqual( + call.adapter.sent.filter((command) => + ["set_model", "set_thinking_level"].includes(command.type), + ), + [ + { type: "set_model", provider: "test", modelId: "model-1" }, + { type: "set_thinking_level", level: "high" }, + ], + ); + assert.equal(fixture.calls.at(-1).adapter, call.adapter); + await registry.stop(); +}); + +test("session-directory migration rolls back move and symlink failures for retry", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-quick-directory-rollback-")); + const quickDir = join(root, "quick"); + const sessionDir = join(root, "session"); + await mkdir(quickDir); + await writeFile(join(quickDir, "active.jsonl"), "history"); + let renameCalls = 0; + await assert.rejects( + promoteQuickSessionDirectory(quickDir, sessionDir, { + renameFile: async (...args) => { + renameCalls += 1; + if (renameCalls === 2) throw new Error("move failed"); + return rename(...args); + }, + }), + ); + assert.equal( + await readFile(join(quickDir, "active.jsonl"), "utf8"), + "history", + ); + await assert.rejects( + promoteQuickSessionDirectory(quickDir, sessionDir, { + makeSymlink: async () => { + throw new Error("symlink failed"); + }, + }), + ); + assert.equal( + await readFile(join(quickDir, "active.jsonl"), "utf8"), + "history", + ); + const transaction = await promoteQuickSessionDirectory(quickDir, sessionDir); + await transaction.finalize(); + assert.equal( + await readFile(join(sessionDir, "active.jsonl"), "utf8"), + "history", + ); +}); + +test("promotion queues abort and close until identity persistence completes", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-race-")); + const home = join(root, "home"); + await mkdir(home); + const fixture = adapters(); + const registry = createAgentRegistry({ + homeWorktree: home, + sessionRoot: join(root, "sessions"), + startAdapter: fixture.startAdapter, + }); + await registry.start(); + const quick = await registry.createQuickRuntime(home); + const call = fixture.calls.at(-1); + const quickPath = join(call.options.sessionDir, "active.jsonl"); + await writeFile(quickPath, "history"); + call.adapter.state = { sessionFile: quickPath, sessionId: "session-race" }; + const send = call.adapter.send.bind(call.adapter); + let releaseIdentity; + let blockIdentity = true; + call.adapter.send = async (command) => { + if (command.type !== "get_state" || !blockIdentity) return send(command); + return new Promise((resolve) => { + releaseIdentity = () => { + blockIdentity = false; + resolve({ + type: "response", + command: "get_state", + success: true, + data: call.adapter.state, + }); + }; + }); + }; + const promotion = registry.promoteQuickRuntime(quick.runtimeId); + while (!releaseIdentity) await new Promise((resolve) => setImmediate(resolve)); + const abort = registry.route(quick.agentId, "abort"); + const close = registry.closeSessionRuntime(quick.runtimeId); + assert.notEqual(call.adapter.sent.at(-1).type, "abort"); + releaseIdentity(); + await Promise.all([promotion, abort, close]); + assert.ok(call.adapter.sent.some((command) => command.type === "abort")); + assert.equal(call.adapter.stopped, true); + await registry.stop(); +}); + +test("promotion reserves moved JSONL before blocking get_state and releases on rollback", async () => { + const root = await mkdtemp( + join(tmpdir(), "pi-quick-promote-get-state-lease-"), + ); + const home = join(root, "home"); + const sessionRoot = join(root, "sessions"); + await mkdir(home); + const fixture = adapters(); + const startAdapter = (options) => { + const adapter = fixture.startAdapter(options); + if (options.sessionPath) adapter.state = { sessionFile: options.sessionPath }; + return adapter; + }; + const registry = createAgentRegistry({ + homeWorktree: home, + sessionRoot, + startAdapter, + }); + await registry.start(); + const quick = await registry.createQuickRuntime(home); + const call = fixture.calls.at(-1); + const quickPath = join(call.options.sessionDir, "active.jsonl"); + const promotedPath = join( + sessionDirectoryPath(sessionRoot, home), + "active.jsonl", + ); + await writeFile(quickPath, "history"); + call.adapter.state = { + sessionFile: quickPath, + sessionId: "session-get-state-lease", + }; + call.options.onEvent({ type: "agent_state", data: { state: "idle" } }); + while ( + !(await registry.getSessionRuntimeSnapshot(quick.runtimeId)).runtime + .sessionPath + ) + await new Promise((resolve) => setImmediate(resolve)); + + const send = call.adapter.send.bind(call.adapter); + let rejectState; + call.adapter.send = async (command) => { + if (command.type !== "get_state") return send(command); + return new Promise((_, reject) => { + rejectState = () => reject(new Error("get_state failed")); + }); + }; + const promotion = registry.promoteQuickRuntime(quick.runtimeId); + while (!rejectState) await new Promise((resolve) => setImmediate(resolve)); + assert.equal((await lstat(call.options.sessionDir)).isSymbolicLink(), true); + assert.equal(await readFile(promotedPath, "utf8"), "history"); + const childrenBeforeOpen = fixture.calls.length; + await assert.rejects( + registry.openSessionRuntime(home, promotedPath), + /already open/, + ); + assert.equal(fixture.calls.length, childrenBeforeOpen); + + rejectState(); + await assert.rejects(promotion, /get_state failed/); + await writeFile(promotedPath, "history"); + await registry.openSessionRuntime(home, promotedPath); + assert.equal(fixture.calls.length, childrenBeforeOpen + 1); + await registry.stop(); +}); + +test("promotion reserves its canonical JSONL before reference and manifest I/O", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-lease-")); + const home = join(root, "home"); + const sessionRoot = join(root, "sessions"); + await mkdir(home); + const fixture = adapters(); + let quick; + let releasePromotionSave; + const registry = createAgentRegistry({ + homeWorktree: home, + sessionRoot, + startAdapter: fixture.startAdapter, + workspaceStoreFactory(rootPath) { + const store = createWorkspaceStore(rootPath); + return { + ...store, + save(next) { + if ( + !next.runtimes.some((runtime) => runtime.runtimeId === quick?.runtimeId) + ) + return store.save(next); + return new Promise((resolve, reject) => { + releasePromotionSave = () => store.save(next).then(resolve, reject); + }); + }, + }; + }, + }); + await registry.start(); + quick = await registry.createQuickRuntime(home); + const call = fixture.calls.at(-1); + const quickPath = join(call.options.sessionDir, "active.jsonl"); + await writeFile(quickPath, "history"); + call.adapter.state = { sessionFile: quickPath, sessionId: "session-lease" }; + const promotedPath = join( + sessionDirectoryPath(sessionRoot, home), + "active.jsonl", + ); + const promotion = registry.promoteQuickRuntime(quick.runtimeId); + while (!releasePromotionSave) + await new Promise((resolve) => setImmediate(resolve)); + await assert.rejects( + registry.openSessionRuntime(home, promotedPath), + /already open/, + ); + releasePromotionSave(); + await promotion; + await registry.stop(); +}); + +test("concurrent manifest persistence retains promoted runtime for restart", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-quick-promote-persist-")); + const home = join(root, "home"); + const sessionRoot = join(root, "sessions"); + await mkdir(home); + const fixture = adapters(); + let releasePromotionSave; + let holdPromotionSave = true; + let quick; + const registry = createAgentRegistry({ + homeWorktree: home, + sessionRoot, + startAdapter: fixture.startAdapter, + workspaceStoreFactory(rootPath) { + const store = createWorkspaceStore(rootPath); + return { + ...store, + save(next) { + if ( + !holdPromotionSave || + !next.runtimes.some((runtime) => runtime.runtimeId === quick?.runtimeId) + ) + return store.save(next); + holdPromotionSave = false; + return new Promise((resolve, reject) => { + releasePromotionSave = () => store.save(next).then(resolve, reject); + }); + }, + }; + }, + }); + await registry.start(); + quick = await registry.createQuickRuntime(home); + const call = fixture.calls.at(-1); + const quickPath = join(call.options.sessionDir, "active.jsonl"); + await writeFile(quickPath, "history"); + call.adapter.state = { sessionFile: quickPath, sessionId: "session-persist" }; + const promotion = registry.promoteQuickRuntime(quick.runtimeId); + while (!releasePromotionSave) + await new Promise((resolve) => setImmediate(resolve)); + const concurrentOpen = registry.createSessionRuntime(home); + releasePromotionSave(); + await Promise.all([promotion, concurrentOpen]); + const manifest = JSON.parse( + await readFile(join(sessionRoot, "bridge-workspace-v2.json"), "utf8"), + ); + assert.ok( + manifest.runtimes.some((runtime) => runtime.runtimeId === quick.runtimeId), + ); + await registry.stop(); + + const restored = adapters(); + const originalStart = restored.startAdapter; + restored.startAdapter = (options) => { + const adapter = originalStart(options); + if (options.sessionPath) adapter.state = { sessionFile: options.sessionPath }; + return adapter; + }; + const restarted = createAgentRegistry({ + homeWorktree: home, + sessionRoot, + startAdapter: restored.startAdapter, + }); + await restarted.start(); + assert.ok( + restarted + .getWorkspace() + .directories.flatMap((directory) => directory.runtimes) + .some( + (runtime) => + runtime.runtimeId === quick.runtimeId && runtime.state !== "failed", + ), + ); + await restarted.stop(); +}); + +test("promotion reference and manifest failures roll back quick runtime for retry", async () => { + const root = await mkdtemp(join(tmpdir(), "pi-quick-persist-rollback-")); + const home = join(root, "home"); + await mkdir(home); + const fixture = adapters(); + const sessionRoot = join(root, "sessions"); + const registry = createAgentRegistry({ + homeWorktree: home, + sessionRoot, + startAdapter: fixture.startAdapter, + }); + await registry.start(); + const quick = await registry.createQuickRuntime(home); + const quickDir = fixture.calls.at(-1).options.sessionDir; + await writeFile(join(quickDir, "active.jsonl"), "history"); + const sessionDir = sessionDirectoryPath(sessionRoot, home); + const referencePath = join(sessionDir, "bridge-agent.json"); + await mkdir(referencePath); + await assert.rejects(registry.promoteQuickRuntime(quick.runtimeId)); + assert.equal( + await readFile(join(quickDir, "active.jsonl"), "utf8"), + "history", + ); + assert.equal((await lstat(quickDir)).isDirectory(), true); + await rm(referencePath, { recursive: true }); + const workspacePath = join(sessionRoot, "bridge-workspace-v2.json"); + await rm(workspacePath); + await mkdir(workspacePath); + await assert.rejects(registry.promoteQuickRuntime(quick.runtimeId)); + assert.equal( + await readFile(join(quickDir, "active.jsonl"), "utf8"), + "history", + ); + assert.equal((await lstat(quickDir)).isDirectory(), true); + await rm(workspacePath, { recursive: true }); + assert.equal( + (await registry.promoteQuickRuntime(quick.runtimeId)).runtimeId, + quick.runtimeId, + ); + await registry.stop(); +}); diff --git a/ui/src-tauri/src/bridge.rs b/ui/src-tauri/src/bridge.rs index 9fed851..31a85bc 100644 --- a/ui/src-tauri/src/bridge.rs +++ b/ui/src-tauri/src/bridge.rs @@ -228,6 +228,17 @@ pub async fn create_quick_runtime( ) .await } +pub async fn promote_quick_runtime( + socket_path: &str, + runtime_id: &str, +) -> Result { + typed_request( + socket_path, + "promote_quick_runtime", + Some(json!({ "runtimeId": runtime_id })), + ) + .await +} pub async fn open_session_runtime( socket_path: &str, worktree_path: &str, diff --git a/ui/src-tauri/src/lib.rs b/ui/src-tauri/src/lib.rs index 1793c4a..654bd93 100644 --- a/ui/src-tauri/src/lib.rs +++ b/ui/src-tauri/src/lib.rs @@ -41,6 +41,7 @@ impl ManagedWindowVisibility { } // One mutex serializes cross-window transitions, avoiding nested visibility-lock ordering. struct WindowVisibility(Mutex); +struct PendingPromotedRuntime(Mutex>); struct WorkspaceSubscription { generation: Arc, task: Mutex>>, @@ -96,7 +97,10 @@ fn should_hide_close_request(label: &str) -> bool { label == "main" || label == "quick-pane" } -fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String> { +fn get_or_create_main_with_visibility( + app: &AppHandle, + visible_when_created: bool, +) -> Result<(WebviewWindow, bool), String> { if let Some(window) = app.get_webview_window("main") { return Ok((window, false)); } @@ -109,11 +113,16 @@ fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String> .ok_or_else(|| "Main window configuration is missing".to_owned())?; let window = WebviewWindowBuilder::from_config(app, config) .map_err(|error| error.to_string())? + .visible(visible_when_created) .build() .map_err(|error| error.to_string())?; Ok((window, true)) } +fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String> { + get_or_create_main_with_visibility(app, true) +} + fn apply_main_window_action(app: &AppHandle, args: &[String]) { let (window, created) = match get_or_create_main(app) { Ok(result) => result, @@ -174,6 +183,44 @@ fn get_or_create_quick_pane(app: &AppHandle) -> Result<(WebviewWindow, bool), St Ok((window, true)) } +fn show_main_for_promoted_runtime(app: &AppHandle, runtime_id: String) -> Result<(), String> { + // A recreated Main starts hidden, so Quick is never visible with it. + let (window, created) = get_or_create_main_with_visibility(app, false)?; + let state = app + .try_state::() + .ok_or_else(|| "Window visibility state is unavailable".to_owned())?; + let pending = app + .try_state::() + .ok_or_else(|| "Promoted runtime state is unavailable".to_owned())?; + let mut visibility = state + .0 + .lock() + .map_err(|_| "Could not update window visibility".to_owned())?; + let mut pending = pending + .0 + .lock() + .map_err(|_| "Could not queue promoted runtime".to_owned())?; + if created { + visibility.main = false; + } + if let Some(quick_pane) = app.get_webview_window("quick-pane") { + quick_pane + .hide() + .map_err(|error| format!("Could not hide Quick Pane before promotion: {error}"))?; + } + visibility.hidden("quick-pane"); + // Retain this on show failure; a later Main startup can consume it. + *pending = Some(runtime_id); + visibility.main = false; + window + .show() + .map_err(|error| format!("Could not show Main after hiding Quick Pane: {error}"))?; + visibility.main_shown(); + let _ = window.unminimize(); + let _ = window.set_focus(); + Ok(()) +} + fn toggle_quick_pane(app: &AppHandle) { let (window, created) = match get_or_create_quick_pane(app) { Ok(result) => result, @@ -333,6 +380,45 @@ async fn create_quick_runtime(worktree_path: String) -> Result Result { + let result = bridge::promote_quick_runtime(&socket_path()?, &runtime_id).await?; + show_main_for_promoted_runtime(&app, result.runtime.runtime_id.clone())?; + let _ = app.emit("workspace-promoted-runtime", json!({ "runtimeId": result.runtime.runtime_id })); + Ok(result) +} + +#[tauri::command] +fn get_pending_promoted_runtime( + pending: State<'_, PendingPromotedRuntime>, +) -> Result, String> { + pending + .0 + .lock() + .map(|pending| pending.clone()) + .map_err(|_| "Could not read promoted runtime".to_owned()) +} + +#[tauri::command] +fn consume_pending_promoted_runtime( + runtime_id: String, + pending: State<'_, PendingPromotedRuntime>, +) -> Result { + let mut pending = pending + .0 + .lock() + .map_err(|_| "Could not consume promoted runtime".to_owned())?; + if pending.as_deref() == Some(runtime_id.as_str()) { + *pending = None; + Ok(true) + } else { + Ok(false) + } +} + #[tauri::command] async fn open_session_runtime( worktree_path: String, @@ -582,6 +668,7 @@ pub fn run() { main: true, quick_pane: false, }))) + .manage(PendingPromotedRuntime(Mutex::new(None))) .manage(WorkspaceSubscription { generation: Arc::new(bridge::SubscriptionGeneration::default()), task: Mutex::new(None), @@ -628,6 +715,9 @@ pub fn run() { dismiss_quick_pane, create_session_runtime, create_quick_runtime, + promote_quick_runtime, + get_pending_promoted_runtime, + consume_pending_promoted_runtime, open_session_runtime, close_session_runtime, close_quick_runtime, @@ -742,6 +832,16 @@ mod tests { ); } + #[test] + fn promoted_runtime_pending_id_clears_only_after_matching_acknowledgement() { + let pending = PendingPromotedRuntime(Mutex::new(Some("runtime-a".to_owned()))); + let mut value = pending.0.lock().unwrap(); + assert_ne!(value.as_deref(), Some("runtime-b")); + assert_eq!(value.as_deref(), Some("runtime-a")); + *value = None; + assert_eq!(value.as_deref(), None); + } + #[test] fn dismissed_quick_pane_reopens_with_one_toggle() { let mut visibility = ManagedWindowVisibility { diff --git a/ui/src/components/ConversationWorkspace.tsx b/ui/src/components/ConversationWorkspace.tsx index 29a3551..ea8791b 100644 --- a/ui/src/components/ConversationWorkspace.tsx +++ b/ui/src/components/ConversationWorkspace.tsx @@ -337,7 +337,7 @@ export function ConversationWorkspace({ pending={isModelUpdating || locked} /> void onThinking(level)} diff --git a/ui/src/components/ThinkingSelector.test.tsx b/ui/src/components/ThinkingSelector.test.tsx index 2a244d1..7003b2f 100644 --- a/ui/src/components/ThinkingSelector.test.tsx +++ b/ui/src/components/ThinkingSelector.test.tsx @@ -6,7 +6,15 @@ it("uses selected model thinking capabilities and reconciles unavailable values" const onChange = vi.fn(); render( , @@ -15,7 +23,9 @@ it("uses selected model thinking capabilities and reconciles unavailable values" const selector = screen.getByRole("combobox", { name: "Thinking" }); expect(selector).toHaveValue("off"); expect( - within(selector).getAllByRole("option").map((option) => option.textContent), + within(selector) + .getAllByRole("option") + .map((option) => option.textContent), ).toEqual(["Off", "High"]); fireEvent.change(selector, { target: { value: "high" } }); expect(onChange).toHaveBeenCalledWith("high"); diff --git a/ui/src/components/components.test.tsx b/ui/src/components/components.test.tsx index 0dc673d..d1215b0 100644 --- a/ui/src/components/components.test.tsx +++ b/ui/src/components/components.test.tsx @@ -178,6 +178,35 @@ describe("multi-session navigation", () => { await user.click(screen.getByText("Tool result · read")); expect(details).toHaveAttribute("open"); }); + it("uses omitted-reasoning state model metadata when catalog lacks current model", () => { + const runtime = runtimeView(summary("thinking")); + runtime.state = { + model: { + provider: "provider", + id: "current", + thinkingLevelMap: { high: null, xhigh: "xhigh" }, + }, + }; + render( + , + ); + expect( + Array.from( + screen + .getByRole("combobox", { name: "Thinking" }) + .querySelectorAll("option"), + ).map((option) => option.value), + ).toEqual(["off", "minimal", "low", "medium", "xhigh"]); + }); it("packs session status between model controls and the composer", () => { const runtime = runtimeView(summary("status", "streaming")); runtime.summary.queueCount = 2; diff --git a/ui/src/model-catalog.test.ts b/ui/src/model-catalog.test.ts index b9f3414..004553e 100644 --- a/ui/src/model-catalog.test.ts +++ b/ui/src/model-catalog.test.ts @@ -6,29 +6,60 @@ import { } from "./model-catalog"; describe("thinking model catalog metadata", () => { - it("uses catalog thinking levels and reconciles removed selections", () => { - const model = { - provider: "provider", - id: "reasoning", - thinkingLevelMap: { off: "off", low: "low", high: "high" }, - }; - expect(thinkingLevelOptions(model)).toEqual([ + it("matches Pi reasoning defaults, null exclusions, and explicit max", () => { + expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]); + expect(thinkingLevelOptions({ reasoning: false })).toEqual([ { value: "off", label: "Off" }, + ]); + expect(thinkingLevelOptions({})).toEqual([ + { value: "off", label: "Off" }, + { value: "minimal", label: "Minimal" }, { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, { value: "high", label: "High" }, ]); - expect(reconcileThinkingLevel(model, "high")).toBe("high"); - expect(reconcileThinkingLevel(model, "medium")).toBe("off"); + const mapped = { + thinkingLevelMap: { + off: null, + low: null, + xhigh: "xhigh", + max: "max", + }, + }; + expect(thinkingLevelOptions(mapped)).toEqual([ + { value: "minimal", label: "Minimal" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "XHigh" }, + { value: "max", label: "Max" }, + ]); + expect(reconcileThinkingLevel(mapped, "off")).toBe("minimal"); + expect(reconcileThinkingLevel(mapped, "xhigh")).toBe("xhigh"); + const unavailable = { + thinkingLevelMap: { + off: null, + minimal: null, + low: null, + medium: null, + high: null, + xhigh: null, + }, + }; + expect(reconcileThinkingLevel(unavailable, "high")).toBe("off"); }); - it("keeps unknown and dynamically configured local models at Off", () => { - expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]); - const [dynamic] = catalogModels([], [ - { provider: "localai", modelId: "local/unknown" }, - ]); - expect(dynamic.thinkingLevelMap).toEqual({ off: "off" }); + it("treats dynamically configured models with omitted reasoning as capable", () => { + const [dynamic] = catalogModels( + [], + [{ provider: "localai", modelId: "local/unknown" }], + ); + expect(dynamic.thinkingLevelMap).toBeUndefined(); expect(thinkingLevelOptions(dynamic)).toEqual([ { value: "off", label: "Off" }, + { value: "minimal", label: "Minimal" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, ]); }); }); diff --git a/ui/src/model-catalog.ts b/ui/src/model-catalog.ts index 73849a6..f4369c0 100644 --- a/ui/src/model-catalog.ts +++ b/ui/src/model-catalog.ts @@ -3,8 +3,21 @@ import type { Model } from "./workspace/types"; export type ModelIdentity = { provider: string; modelId: string }; export type ThinkingLevelOption = { value: string; label: string }; +const DEFAULT_THINKING_LEVELS = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", +] as const; const OFF_THINKING_LEVEL: ThinkingLevelOption = { value: "off", label: "Off" }; +function thinkingLevelLabel(level: string) { + if (level === "xhigh") return "XHigh"; + return level[0].toUpperCase() + level.slice(1); +} + export function modelIdentity(model: Model): ModelIdentity | undefined { if (!model.provider || !model.id) return undefined; return { provider: model.provider, modelId: model.id }; @@ -49,22 +62,29 @@ export function reconcileModelIdentity( } export function thinkingLevelOptions(model?: Model): ThinkingLevelOption[] { - const map = model?.thinkingLevelMap; - if (!map || typeof map !== "object") return [OFF_THINKING_LEVEL]; - const options = Object.keys(map) - .filter((level) => level.trim() && typeof map[level] === "string") - .map((level) => ({ - value: level, - label: level === "off" ? "Off" : level[0].toUpperCase() + level.slice(1), - })); - return options.length ? options : [OFF_THINKING_LEVEL]; + if (!model || model.reasoning === false) return [OFF_THINKING_LEVEL]; + const map = model.thinkingLevelMap; + const levels = [ + ...DEFAULT_THINKING_LEVELS.filter((level) => { + if (level === "xhigh") return map?.xhigh !== undefined && map.xhigh !== null; + return map?.[level] !== null; + }), + ...(map?.max == null ? [] : ["max"]), + ]; + return levels.length + ? levels.map((value) => ({ value, label: thinkingLevelLabel(value) })) + : [OFF_THINKING_LEVEL]; } -export function reconcileThinkingLevel(model: Model | undefined, level: string) { +export function reconcileThinkingLevel( + model: Model | undefined, + level: string, +) { const options = thinkingLevelOptions(model); return options.some((option) => option.value === level) ? level - : options.find((option) => option.value === "off")?.value ?? options[0].value; + : (options.find((option) => option.value === "off")?.value ?? + options[0].value); } export function catalogModels( @@ -85,8 +105,6 @@ export function catalogModels( available.unshift({ provider: identity.provider, id: identity.modelId, - // Configured/local models are not catalog-confirmed. Do not imply thinking support. - thinkingLevelMap: { off: "off" }, }); keys.add(key); } diff --git a/ui/src/quick-pane/QuickPane.test.tsx b/ui/src/quick-pane/QuickPane.test.tsx index c15f036..782f2f8 100644 --- a/ui/src/quick-pane/QuickPane.test.tsx +++ b/ui/src/quick-pane/QuickPane.test.tsx @@ -389,7 +389,8 @@ describe("QuickPane", () => { provider: "provider", id: "quick/model", name: "Quick Model", - thinkingLevelMap: { off: "off", high: "high" }, + reasoning: true, + thinkingLevelMap: { minimal: null, low: null, medium: null, high: "high" }, }, ]; bridgeMock([thinkingModels], () => @@ -663,23 +664,13 @@ describe("QuickPane", () => { expect(response.scrollTop).toBe(100); }); - it("shows manual continuation after a completed normal answer and hands off transcript", async () => { + it("promotes completed quick runtime without copying a handoff", async () => { bridgeMock([defaultModels], () => snapshotWithTranscript([ { role: "user", content: "Inspect" }, { role: "assistant", content: "Completed answer" }, ]), ); - const baseImplementation = mocks.invoke.getMockImplementation(); - mocks.invoke.mockImplementation( - (command: string, payload?: Record) => { - if (command === "create_session_runtime") - return Promise.resolve({ - runtime: { agentId: "agent-full", runtimeId: "runtime-full" }, - }); - return baseImplementation?.(command, payload); - }, - ); render(); await preparedTrigger(); @@ -690,19 +681,26 @@ describe("QuickPane", () => { expect( continueButton.closest(".quick-pane-assistant-footer"), ).not.toBeNull(); + + const submittedPromptCount = mocks.invoke.mock.calls.filter( + ([command]) => command === "submit_prompt", + ).length; + await userEvent.click(continueButton); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith("promote_quick_runtime", { + runtimeId: "runtime-1", + }), + ); + expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane"); expect(mocks.invoke).not.toHaveBeenCalledWith( "create_session_runtime", expect.anything(), ); - - await userEvent.click(continueButton); - await waitFor(() => - expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", { - agentId: "agent-full", - message: - "Inspect\n\nQuick Pane handoff: Continue this chat in a full session.\n\nQuick Pane transcript:\nUser: Inspect\n\nAssistant: Completed answer", - }), - ); + expect( + mocks.invoke.mock.calls.filter( + ([command]) => command === "submit_prompt", + ), + ).toHaveLength(submittedPromptCount); }); it("keeps unlimited follow-ups on one runtime and renders snapshot messages in order", async () => { @@ -988,10 +986,6 @@ describe("QuickPane", () => { const baseImplementation = mocks.invoke.getMockImplementation(); mocks.invoke.mockImplementation( (command: string, payload?: Record) => { - if (command === "create_session_runtime") - return Promise.resolve({ - runtime: { agentId: "agent-full", runtimeId: "runtime-full" }, - }); if (command === "submit_prompt" && payload?.agentId === "agent-1") submittedPrompts.push(String(payload.message)); return baseImplementation?.(command, payload); @@ -1011,18 +1005,18 @@ describe("QuickPane", () => { await screen.findByText("Second runtime failed."), ).toBeInTheDocument(); expect(screen.getByText("First answer")).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Continue" })); - - await waitFor(() => - expect(mocks.invoke).toHaveBeenCalledWith("create_session_runtime", { - worktreePath: "/repo", + await userEvent.click( + within(screen.getByRole("status")).getByRole("button", { + name: "Continue", }), ); - expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", { - agentId: "agent-full", - message: - "Second\n\nQuick Pane handoff: Quick runtime failed. Continue in a full session if needed.\n\nQuick Pane transcript:\nUser: First\n\nAssistant: First answer\n\nUser: Second", - }); + + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith("promote_quick_runtime", { + runtimeId: "runtime-1", + }), + ); + expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane"); }); it("maps the exact submitted payload to the entered multiline request despite delimiter collisions", async () => { diff --git a/ui/src/quick-pane/QuickPane.tsx b/ui/src/quick-pane/QuickPane.tsx index d567dc4..3b317f6 100644 --- a/ui/src/quick-pane/QuickPane.tsx +++ b/ui/src/quick-pane/QuickPane.tsx @@ -213,6 +213,7 @@ export default function QuickPane() { const [submissionStarting, setSubmissionStarting] = useState(false); const [requestActive, setRequestActive] = useState(false); const [chatReplacing, setChatReplacing] = useState(false); + const [promoting, setPromoting] = useState(false); const [thinkingLevel, setThinkingLevel] = useState("medium"); const [models, setModels] = useState([]); const [catalogStatus, setCatalogStatus] = useState< @@ -229,7 +230,6 @@ export default function QuickPane() { const [catalogNotice, setCatalogNotice] = useState(); const [runtime, setRuntime] = useState(); const [messages, setMessages] = useState([]); - const [answer, setAnswer] = useState(""); const [responseComplete, setResponseComplete] = useState(false); const [escalation, setEscalation] = useState(); const [extension, setExtension] = useState(); @@ -634,7 +634,6 @@ export default function QuickPane() { setRequestActive(false); setSubmissionStarting(false); setMessages([]); - setAnswer(""); setResponseComplete(false); setEscalation(undefined); setExtension(undefined); @@ -894,8 +893,6 @@ export default function QuickPane() { activeTool: snapshot.runtime.activeTool, hasAssistantText: !!text, }); - if (promptSubmittedRuntimeRef.current === runtime.runtimeId) - setAnswer(result); setExtension(snapshot.extensions[0]); if ( complete && @@ -983,7 +980,6 @@ export default function QuickPane() { }; promptSubmittedRuntimeRef.current = undefined; completionHandledRef.current = false; - setAnswer(""); setResponseComplete(false); setError(undefined); setEscalation(undefined); @@ -1050,9 +1046,7 @@ export default function QuickPane() { } } - const handoffRequest = - request.trim() || submittedRequestRef.current?.request || ""; - const canContinue = !invalid && !!handoffRequest; + const canContinue = !invalid && !!runtime && !promoting; const completedAssistantAnswer = responseComplete && messages.some((message) => message.role === "assistant"); @@ -1069,35 +1063,22 @@ export default function QuickPane() { responseComplete || submittedRequestRef.current !== undefined; const hasTranscript = messages.length > 0 || runActive || responseComplete; - const transcriptHandoff = messages - .map( - (message) => - `${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`, - ) - .join("\n\n"); async function continueFull() { - if (invalid || !handoffRequest) - return setError(invalid ?? "Enter a request."); + const current = runtimeRef.current; + if (invalid || !current) return setError(invalid ?? "Quick runtime is unavailable."); + setPromoting(true); try { - const result = await invoke<{ runtime: Runtime }>( - "create_session_runtime", - { - worktreePath: settings.workspace, - }, - ); - await invoke("set_model", { - agentId: result.runtime.agentId, - provider: settings.followUpProvider, - modelId: settings.followUpModel, + await invoke<{ runtime: Runtime }>("promote_quick_runtime", { + runtimeId: current.runtimeId, }); - await invoke("submit_prompt", { - agentId: result.runtime.agentId, - message: `${handoffRequest}\n\nQuick Pane handoff: ${escalation ?? "Continue this chat in a full session."}\n\nQuick Pane transcript:\n${transcriptHandoff || answer}`, - }); - setNotice("Full session started with follow-up model."); + cleanupQueueRef.current.delete(current.runtimeId); + clearRuntimeIfCurrent(current.runtimeId); + await dismissPane(); } catch (reason) { - setError(`Could not start full session: ${String(reason)}`); + setError(`Could not continue in full session: ${String(reason)}`); + } finally { + setPromoting(false); } } @@ -1187,6 +1168,16 @@ export default function QuickPane() { />
+ {runtime && ( + + )}