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.