From e5e741d97d560cc80ed322be95c0b45cab88687f Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 13 Oct 2026 07:12:30 -0700 Subject: [PATCH] fix: leave a busy tab alone when handing it a proposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-point branch was written for Preview, which the reader presses on purpose, and now also runs for every proposal of an item whose page is open. Unasked for, two of the things it does are not wanted: `navigate` rebuilds the tab's url without the version the reader pinned, and `select` with `setCollapsed` pulls the panel off whatever tab they were reading. So the automatic path declines a pinned tab outright, leaving the pin and the call on the card, and moves the panel only when that tab is the one already on screen. An explicit Preview press keeps the behaviour it had. A hand-off still remounts the page, so text typed into its form is lost. That needs the page to report unsent edits upward, and is left out. Tests the routing gate: a deployed run is handed over, a test run and a chat-enabled flow are not — neither has a page with a form that could answer the call. Co-Authored-By: Claude Opus 5 (1M context) --- .../copilot/chat/AIChatManager.test.ts | 48 +++++++++++++++++++ .../sessions/sessionRuntime.svelte.ts | 22 +++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 19d432bd37..d8056e778f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -330,6 +330,54 @@ describe('AIChatManager run form', () => { }) }) + // Only a deployed item has a page with a form to confirm on: a test run executes the + // draft being written, and a chat-enabled flow's page is a conversation. Routing either + // there would park the call on a page that can never answer it. + it('hands a deployed run to a page already open, and nothing else', async () => { + const manager = new AIChatManager() + const routed: any[] = [] + manager.openRunOnDeployedPage = (a) => { + routed.push(a) + return true + } + + void manager.requestRunArgs('call_run', { + path: 'f/a/b', + kind: 'run', + runnableKind: 'flow', + summary: 'Plain flow', + schema: {}, + args: {} + }) + void manager.requestRunArgs('call_test', { + path: 'f/a/b', + kind: 'test', + schema: {}, + args: {} + }) + void manager.requestRunArgs('call_chat', { + path: 'f/a/c', + kind: 'run', + conversational: true, + schema: {}, + args: {} + }) + + expect(routed).toEqual([ + { + toolCallId: 'call_run', + kind: 'flow', + path: 'f/a/b', + summary: 'Plain flow', + carries: true, + // Never opens a tab of its own: the reader did not ask for one here. + onlyIfAlreadyOpen: true + } + ]) + // Routed after the form is installed, so the page finds the call the moment it mounts. + expect(manager.isRunFormPending('call_run')).toBe(true) + }) + // Stopping the turn is the form's other way out, and it settles cards through // settledToolDisplay rather than through #settleRunForm. it('settles a stopped form on what it held too', () => { diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 55c2fad668..08c710f9cc 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -614,7 +614,11 @@ function createRuntime(session: Session): SessionRuntime { return manager.pendingRunFormArgs(toolCallId) ?? {} }, adopt: (jobId, args, schema) => manager.handleRunFormJob(toolCallId, jobId, args, schema), - decline: () => manager.handleRunFormCancel(toolCallId) + decline: () => manager.handleRunFormCancel(toolCallId), + // The same claim the card's own form takes, so the two surfaces of one call cannot + // both be mid-submit. + beginSubmit: () => manager.beginRunFormSubmit(toolCallId), + endSubmit: () => manager.endRunFormSubmit(toolCallId) } } @@ -662,12 +666,22 @@ function createRuntime(session: Session): SessionRuntime { return slot.kind === 'viewer' && slot.viewerKind === kind && slot.path === path }) if (open) { + // A tab pinned to a version is one the reader put somewhere deliberately, and + // `navigate` below rebuilds its url without the pin. Worth it when the reader asked + // for this page; not worth silently moving them off a version they chose, so an + // unasked-for hand-off declines and leaves the call on the card. + if (onlyIfAlreadyOpen && new URL(open.url, 'http://x').searchParams.get('version')) { + return false + } previewTabs.navigate(viewTarget, open.id) // `navigate` re-points a tab in place and nothing more, while the call is about to // tell the reader the page is open. `open` would have brought it forward and shown - // the panel; this path has to do both itself. - previewTabs.select(open.id) - previewTabs.setCollapsed(false) + // the panel; this path has to do both itself — except when nobody asked, where + // taking the panel off the tab the reader is on is the thing to avoid. + if (!onlyIfAlreadyOpen || previewTabs.activeId === open.id) { + previewTabs.select(open.id) + previewTabs.setCollapsed(false) + } } else { // Only the item's own page carries a form for this call, so a caller routing a call // that was not asked to open anything gets a no rather than a new tab.