From d70c7eb61d4257215b18f2c1770f0d2c35ab8d83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:32:53 -0700 Subject: [PATCH] fix(native-chat): a new chat joins only a re-delivery of the same user action "The same request" was recognised by its content (agent, workspace, trimmed text, sent or drafted), which split one action whose text changes between deliveries ("Fix with AI" re-fetches logs) and merged two different actions that happen to carry the same text. Each user action now mints one request id where it is handled (the click, menu pick, notes send, shortcut, Fix with AI press, quick command; a worktree create uses its creation id; programmatic starts mint once at their entry) and passes it through the launch plan, its verdict and the structured launch options, where it is required. A new start joins a chat only when its first attempt carries the same id: a double click or a caller retrying its own call makes one chat and sends the first delivery's text once; any other action opens its own chat, whatever its text. An empty chat that is still starting is still claimed once by the first action with text, and then belongs to that action's id. Resume launches keep joining their conversation's launch. The + menu and new-tab search no longer grey out an agent while one of its chats starts: every pick is a new action and opens its own chat. The id lives in memory only; nothing reads it after a reload (a restored launch is reached by its session id through Retry, never joined). --- .../dashboard/launch-dashboard-agent.test.ts | 1 + .../dashboard/launch-dashboard-agent.ts | 2 + .../FloatingTerminalWindowControls.test.tsx | 1 + .../FloatingTerminalWindowControls.tsx | 2 + ...se-native-chat-provisional-launch.test.tsx | 6 +- .../ai-vault-session-launch-actions.ts | 8 +- ...ault-session-resume-in-chat-launch.test.ts | 6 +- .../ai-vault-session-resume-in-chat-launch.ts | 5 +- .../runSourceControlAgentActionStart.ts | 2 + ...ol-launch-agent-args-applicability.test.ts | 14 +- ...control-launch-agent-args-applicability.ts | 6 +- .../source-control/ai/recovery-launch.ts | 2 + .../sync/use-git-history-commit-actions.ts | 2 + .../folder-workspace-composer-submit.ts | 2 + .../QuickLaunchButton.launch-status.test.tsx | 55 +++--- .../components/tab-bar/QuickLaunchButton.tsx | 24 +-- .../TabBarCreateEntry.keyboard.test.tsx | 30 --- .../components/tab-bar/TabBarCreateEntry.tsx | 26 +-- .../use-tab-bar-create-menu-controller.ts | 2 + .../terminal-agent-session-fork.ts | 2 + .../components/use-terminal-create-actions.ts | 2 + .../composer-state/full-creation-execution.ts | 2 + .../quick-creation-execution.ts | 6 +- .../agent-launch-caller-permissions.test.ts | 43 +++- .../lib/agent-launch-caller-placement.test.ts | 18 +- ...ent-launch-caller-profiles-test-harness.ts | 3 +- ...ent-launch-caller-prompt-transport.test.ts | 12 +- .../lib/agent-launch-caller-routing.test.ts | 44 ++++- .../src/lib/agent-launch-request-id.ts | 10 + ...t-launch-route-connection-fallback.test.ts | 15 +- .../src/lib/agent-launch-route-input.test.ts | 2 +- .../src/lib/agent-session-launch-plan.test.ts | 22 ++- .../src/lib/agent-session-launch-plan.ts | 15 ++ .../lib/empty-workspace-default-agent-chat.ts | 3 + .../src/lib/fix-checks-agent-launch.ts | 2 + .../lib/launch-agent-in-new-tab-cwd.test.ts | 3 + ...h-agent-in-new-tab-host-resolution.test.ts | 7 +- ...unch-agent-in-new-tab-main-surface.test.ts | 7 +- .../launch-agent-in-new-tab-placement.test.ts | 3 +- ...aunch-agent-in-new-tab-structured-route.ts | 1 + ...launch-agent-in-new-tab-structured.test.ts | 1 + ...aunch-agent-in-new-tab-web-runtime.test.ts | 2 + ...h-agent-in-new-tab-windows-quoting.test.ts | 14 +- .../src/lib/launch-agent-in-new-tab.test.ts | 60 +++--- .../src/lib/launch-agent-in-new-tab.ts | 39 ++-- .../lib/launch-agent-session-continuation.ts | 2 + ...launch-agent-structured-chat-guard.test.ts | 69 +++++-- ...nch-work-item-direct-agent-routing.test.ts | 6 +- ...unch-work-item-direct-route-preparation.ts | 4 + .../src/lib/launch-work-item-direct.ts | 4 +- .../src/lib/new-agent-prompt-outcome.test.ts | 1 + .../src/lib/onboarding-folder-agent-launch.ts | 3 + .../lib/run-quick-command-in-new-tab.test.ts | 3 + .../src/lib/run-quick-command-in-new-tab.ts | 2 + ...structured-agent-launch-settlement.test.ts | 42 +++- ...agent-session-launch-after-failure.test.ts | 22 ++- ...structured-agent-session-launch-callers.ts | 3 + ...-agent-session-launch-cancellation.test.ts | 5 +- ...ed-agent-session-launch-close-race.test.ts | 14 +- ...t-session-launch-different-request.test.ts | 185 ++++++++++++++---- ...-agent-session-launch-exited-owner.test.ts | 2 +- ...structured-agent-session-launch-holders.ts | 36 ++-- ...agent-session-launch-join-delivery.test.ts | 9 +- ...tured-agent-session-launch-options.test.ts | 20 +- ...structured-agent-session-launch-request.ts | 41 ++-- ...ent-session-launch-resume-identity.test.ts | 29 ++- .../structured-agent-session-launch-status.ts | 23 +-- ...sion-launch-unconfirmed-or-retried.test.ts | 31 ++- .../structured-agent-session-launch.test.ts | 95 ++++++--- .../lib/structured-agent-session-launch.ts | 16 +- ...red-agent-session-paired-admission.test.ts | 3 +- ...ructured-agent-session-paired-admission.ts | 1 + .../worktree-creation-structured-session.ts | 2 + 73 files changed, 792 insertions(+), 415 deletions(-) create mode 100644 src/renderer/src/lib/agent-launch-request-id.ts diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts index a86e9901835..f72a249b9c7 100644 --- a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts @@ -40,6 +40,7 @@ describe('launchDashboardAgent', () => { expect(mocks.getKnownWorktreeById).toHaveBeenCalledWith('folder:docs', 'ssh:docs') expect(mocks.setActiveWorktree).toHaveBeenCalledWith('folder:docs', 'ssh:docs') expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({ + requestId: expect.any(String), agent: 'codex', worktreeId: 'folder:docs', launchSource: 'unknown' diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts index be3b644e5a6..43ff278bd7c 100644 --- a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts @@ -1,4 +1,5 @@ import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { useAppStore } from '@/store' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' @@ -15,6 +16,7 @@ export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentA state.setActiveWorktree(worktreeId, executionHostId) return ( launchAgentInNewTab({ + requestId: newAgentLaunchRequestId(), agent, worktreeId, launchSource: 'unknown' diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx index 2a16207c5f4..da9710b965d 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx @@ -174,6 +174,7 @@ describe('FloatingTerminalWindowControls default-agent launch', () => { clickLaunch() expect(mocks.launchAgentInNewTab).toHaveBeenCalledExactlyOnceWith({ + requestId: expect.any(String), agent: 'claude', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, launchSource: 'shortcut' diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 3889a5f342f..28fa197c050 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { @@ -67,6 +68,7 @@ export function FloatingTerminalWindowControls({ // Floating resolves the terminal-backed lane: a chat view over a PTY when the chat default is // on, never a structured session. const result = launchAgentInNewTab({ + requestId: newAgentLaunchRequestId(), agent: defaultAgent, worktreeId: FLOATING_TERMINAL_WORKTREE_ID, launchSource: 'shortcut' diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index a2055b79e06..3ef80d69955 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -127,8 +127,8 @@ describe('a chat pane over its own launch', () => { it('keeps the selection its create seeded when a pick in another chat saves a new one', () => { saveSelection('gpt-5.5') - startStructuredAgentLaunch('wt-first', 'codex') - const second = startStructuredAgentLaunch('wt-second', 'codex') + startStructuredAgentLaunch('wt-first', 'codex', { requestId: 'request-1' }) + const second = startStructuredAgentLaunch('wt-second', 'codex', { requestId: 'request-2' }) const { result, rerender } = renderLaunchedChat('wt-second', second.sessionId) expect(currentModel(result.current.optionSnapshot)).toBe('gpt-5.5') @@ -156,7 +156,7 @@ describe('a chat pane over its own launch', () => { }) : new Promise(() => {}) ) - const launch = startStructuredAgentLaunch('wt-refused', 'codex') + const launch = startStructuredAgentLaunch('wt-refused', 'codex', { requestId: 'request-3' }) const { sessionId } = launch const { result, rerender } = renderLaunchedChat('wt-refused', sessionId) await act(async () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 577b700a98c..d9e2b3e1d4d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -16,6 +16,7 @@ import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuati import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { activateAiVaultResumeWorkspace, resumeAiVaultSessionInNewChat @@ -161,7 +162,12 @@ export function useAiVaultSessionLaunchActions({ ) return } - void resumeAiVaultSessionInNewChat(session, session.agent, worktreeId) + void resumeAiVaultSessionInNewChat( + session, + session.agent, + worktreeId, + newAgentLaunchRequestId() + ) }, [activeWorktree?.id, activeWorktreeId] ) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts index f0c307ae0ca..0dbc81ae4a4 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts @@ -83,7 +83,7 @@ describe('resumeAiVaultSessionInNewChat', () => { return { sessionId: 'session-1', tab: { id: 'agent-session:session-1' }, settlement } }) - await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1') + await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click') expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith( expect.objectContaining({ @@ -104,7 +104,7 @@ describe('resumeAiVaultSessionInNewChat', () => { settlement: Promise.resolve({ kind: 'failed', error }) }) - await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1') + await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click') await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith( 'Another chat is already holding this conversation.' @@ -119,7 +119,7 @@ describe('resumeAiVaultSessionInNewChat', () => { settlement: Promise.resolve({ kind: 'visibility-unknown', sessionId: 'session-1' }) }) - await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1') + await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click') await Promise.resolve() expect(mocks.toastError).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts index dbf98a13cfb..b309dd31de3 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.ts @@ -7,6 +7,7 @@ import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-cod import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { prepareAiVaultSessionForResume } from '@/lib/ai-vault-session-resume-preparation' import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' +import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { activateAndRevealFolderWorkspace, activateAndRevealWorktree @@ -28,7 +29,8 @@ export function activateAiVaultResumeWorkspace(workspaceId: string): boolean { export async function resumeAiVaultSessionInNewChat( session: AiVaultSession, agent: AgentSessionHandleProvider, - worktreeId: string + worktreeId: string, + requestId: AgentLaunchRequestId ): Promise { try { // Codex rows can live under a shared legacy home; the same preparation the terminal resume @@ -36,6 +38,7 @@ export async function resumeAiVaultSessionInNewChat( const preparedSession = await prepareAiVaultSessionForResume(session) const plan = adoptAgentSessionLaunchVerdict({ route: 'structured-native-chat', + requestId, agent, worktreeId, resumeFrom: { providerSessionId: preparedSession.sessionId } diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index 2f5c044ab1d..c01d43da511 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -1,5 +1,6 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -102,6 +103,7 @@ export async function runSourceControlAgentActionStart({ } } else if (worktreeId) { const result = launchAgentInNewTab({ + requestId: newAgentLaunchRequestId(), agent: selectedAgent, worktreeId, groupId: groupId ?? worktreeId, diff --git a/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts b/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts index b13bf2cac5a..44e844bc4d6 100644 --- a/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - planAgentSessionLaunch: vi.fn(), + resolveAgentSessionLaunchRoute: vi.fn(), getState: vi.fn(() => ({})) })) vi.mock('@/lib/agent-session-launch-plan', () => ({ - planAgentSessionLaunch: mocks.planAgentSessionLaunch + resolveAgentSessionLaunchRoute: mocks.resolveAgentSessionLaunchRoute })) vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) @@ -15,7 +15,7 @@ import { sourceControlLaunchAppliesAgentArgs } from './source-control-launch-age describe('sourceControlLaunchAppliesAgentArgs', () => { beforeEach(() => { vi.clearAllMocks() - mocks.planAgentSessionLaunch.mockReturnValue({ route: 'terminal-tui' }) + mocks.resolveAgentSessionLaunchRoute.mockReturnValue('terminal-tui') }) it('applies arguments when the user launches into a terminal by default', () => { @@ -28,7 +28,7 @@ describe('sourceControlLaunchAppliesAgentArgs', () => { }) it('drops arguments only when this launch would really be a structured session', () => { - mocks.planAgentSessionLaunch.mockReturnValue({ route: 'structured-native-chat' }) + mocks.resolveAgentSessionLaunchRoute.mockReturnValue('structured-native-chat') expect( sourceControlLaunchAppliesAgentArgs({ agent: 'codex', @@ -39,7 +39,7 @@ describe('sourceControlLaunchAppliesAgentArgs', () => { it('keeps arguments for a chat-by-default user whose launch falls back to a terminal', () => { // A remote host, an agent without a structured session, or a floating workspace all land here. - mocks.planAgentSessionLaunch.mockReturnValue({ route: 'legacy-native-chat' }) + mocks.resolveAgentSessionLaunchRoute.mockReturnValue('legacy-native-chat') expect( sourceControlLaunchAppliesAgentArgs({ agent: 'codex', @@ -55,7 +55,7 @@ describe('sourceControlLaunchAppliesAgentArgs', () => { worktreeId: 'wt-1' }) ).toBe(true) - expect(mocks.planAgentSessionLaunch).not.toHaveBeenCalled() + expect(mocks.resolveAgentSessionLaunchRoute).not.toHaveBeenCalled() }) it('names the repo and its host when the workspace does not exist yet', () => { @@ -64,7 +64,7 @@ describe('sourceControlLaunchAppliesAgentArgs', () => { repoId: 'repo-1', executionHostId: 'ssh:build-box' }) - expect(mocks.planAgentSessionLaunch).toHaveBeenCalledWith( + expect(mocks.resolveAgentSessionLaunchRoute).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ workspace: { diff --git a/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.ts b/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.ts index ea16d595ae3..20a53621097 100644 --- a/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.ts +++ b/src/renderer/src/components/right-sidebar/source-control-launch-agent-args-applicability.ts @@ -4,7 +4,7 @@ import { workspaceKindForWorktreeId, type ProspectiveWorkspace } from '@/lib/agent-launch-route-input' -import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' +import { resolveAgentSessionLaunchRoute } from '@/lib/agent-session-launch-plan' import { useAppStore } from '@/store' export type SourceControlLaunchAgentArgsApplicabilityInput = { @@ -47,9 +47,9 @@ export function sourceControlLaunchAppliesAgentArgs( return true } return ( - planAgentSessionLaunch(useAppStore.getState(), { + resolveAgentSessionLaunchRoute(useAppStore.getState(), { agent: input.agent, workspace: prospectiveWorkspace(input) - }).route !== 'structured-native-chat' + }) !== 'structured-native-chat' ) } diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index 4a055a613ee..dd2979ce031 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -2,6 +2,7 @@ import { toast } from 'sonner' import type { AppState } from '@/store' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { getConnectionId } from '@/lib/connection-context' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { @@ -156,6 +157,7 @@ export async function launchSourceControlRecoveryAgentWithDefault({ return false } const result = launchAgentInNewTab({ + requestId: newAgentLaunchRequestId(), agent, worktreeId: activeWorktreeId, groupId: activeGroupId ?? activeWorktreeId, diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts index bd755652350..36d4fec9313 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control/sync/use-git-history-commit-actions.ts @@ -9,6 +9,7 @@ import { import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { resolveDefaultAgentForNewTab } from '@/lib/agent-tab-shortcuts' import { translate } from '@/i18n/i18n' import type { GitHistoryItem } from '../../../../../../shared/git-history' @@ -280,6 +281,7 @@ export function useGitHistoryCommitActions({ `Run \`git show --no-ext-diff ${item.id}\` to inspect the full diff, then summarize what changed and why at a high level, calling out the most important files and any risks.` ].join('\n') launchAgentInNewTab({ + requestId: newAgentLaunchRequestId(), agent, worktreeId: activeWorktreeId, prompt: explainPrompt, diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index c4857877e11..40d65b759e2 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -18,6 +18,7 @@ import { toFolderWorkspaceLinkedTask } from './folder-workspace-composer-helpers' import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { getNewWorkspaceProjectGroupHostId } from '@/lib/new-workspace-project-options' import { useAppStore } from '@/store' @@ -132,6 +133,7 @@ export async function submitFolderWorkspaceCreate({ quickAgent && linkedWorkItem ? resolveFolderWorkspaceLaunchDraft(linkedWorkItem, note) : null const plan = quickAgent ? planAgentSessionLaunch(useAppStore.getState(), { + requestId: newAgentLaunchRequestId(), agent: quickAgent, workspace: { kind: 'folder', diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx index 1f06c3307c3..370ca704b87 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx @@ -4,11 +4,7 @@ import type { ReactNode } from 'react' import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredLaunchState } from '@/lib/structured-agent-session-launch-registry' -import { - BLANK_STRUCTURED_LAUNCH_REQUEST, - structuredLaunchRequest, - type StructuredLaunchAttempt -} from '@/lib/structured-agent-session-launch-request' +import type { StructuredLaunchAttempt } from '@/lib/structured-agent-session-launch-request' vi.mock('@/hooks/useDetectedAgents', () => ({ useDetectedAgents: () => ({ detectedIds: ['claude', 'codex'] }) @@ -69,7 +65,8 @@ function registerLaunch( outcome: 'pending' | 'failed', attempt: StructuredLaunchAttempt = { kind: 'first', - request: BLANK_STRUCTURED_LAUNCH_REQUEST, + requestId: `${agent}-pick`, + blank: true, stagedEntry: null } ): void { @@ -114,14 +111,15 @@ function agentRowDisabled(label: string): string | null | undefined { ?.getAttribute('aria-disabled') } -describe('QuickLaunchAgentMenuItems launch status', () => { +describe('QuickLaunchAgentMenuItems launches', () => { beforeEach(() => { localStorage.clear() resetStructuredAgentLaunchRegistryForTests() }) afterEach(cleanup) - it('keeps an agent whose chat failed to start launchable while a starting one waits', () => { + // Each pick is its own request, so a chat starting, failing or retrying never blocks one. + it('keeps every agent launchable while chats start, fail or retry', () => { registerLaunch('claude', 'pending') registerLaunch('codex', 'failed') @@ -132,52 +130,43 @@ describe('QuickLaunchAgentMenuItems launch status', () => { onFocusTerminal={vi.fn()} /> ) - - expect(agentRowDisabled('Claude')).toBe('true') + expect(agentRowDisabled('Claude')).toBe('false') expect(agentRowDisabled('Codex')).toBe('false') - }) + cleanup() - // A pick then opens a new chat; only a new start's own create would be joined. - it("keeps an agent launchable while a failed chat's Retry is in flight", () => { - registerLaunch('claude', 'pending') registerLaunch('codex', 'pending', { kind: 'retry' }) - render( ) - - expect(agentRowDisabled('Claude')).toBe('true') expect(agentRowDisabled('Codex')).toBe('false') }) - // A pick joins only a start of the same request; any other opens its own chat. - it('disables an agent only for the request its starting chat carries', () => { - registerLaunch('codex', 'pending', { - kind: 'first', - request: structuredLaunchRequest({ prompt: 'review notes' }), - stagedEntry: null + it('gives each pick its own request, so a second pick opens its own chat', () => { + launchMock.mockReset() + launchMock.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'codex-session' } }) - const menu = (prompt?: string) => ( + render( ) + const codexRow = document.querySelector('[title="Launch Codex in a new terminal"]')! + fireEvent.click(codexRow) + fireEvent.click(codexRow) - render(menu()) - expect(agentRowDisabled('Codex')).toBe('false') - cleanup() - render(menu('other notes')) - expect(agentRowDisabled('Codex')).toBe('false') - cleanup() - render(menu('review notes')) - expect(agentRowDisabled('Codex')).toBe('true') + const requestIds = launchMock.mock.calls.map(([args]) => args.requestId) + expect(requestIds).toHaveLength(2) + expect(requestIds[0]).toEqual(expect.any(String)) + expect(requestIds[1]).not.toBe(requestIds[0]) }) // Why: the notes menu holds what it sent until this result, so a second send leaves them out. diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index a5eda32b0e6..3f15fe43418 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react' -import { Loader2, Settings as SettingsIcon } from 'lucide-react' +import { Settings as SettingsIcon } from 'lucide-react' import { toast } from 'sonner' import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' @@ -8,7 +8,7 @@ import { useAgentDetectionTargetForWorktree } from '@/hooks/useAgentDetectionTar import { useDetectedAgents } from '@/hooks/useDetectedAgents' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' -import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import type { TuiAgent } from '../../../../shared/tui-agent' import type { LaunchSource } from '../../../../shared/telemetry-events' import { @@ -16,8 +16,6 @@ import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection' import { translate } from '@/i18n/i18n' -import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' -import { structuredLaunchRequest } from '@/lib/structured-agent-session-launch-request' import { newAgentPromptOutcome } from '@/lib/new-agent-prompt-outcome' export type QuickLaunchAgentMenuItemsProps = { @@ -126,13 +124,6 @@ function QuickLaunchAgentMenuItemsInner({ const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') - // One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run - // inside the agent list's render loop. Only a start of this menu's own request is joined. - const launchRequest = structuredLaunchRequest({ prompt, promptDelivery }) - const structuredLaunchStatusByAgent = { - claude: useStructuredAgentLaunchStatus(worktreeId, 'claude', launchRequest), - codex: useStructuredAgentLaunchStatus(worktreeId, 'codex', launchRequest) - } const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -147,6 +138,7 @@ function QuickLaunchAgentMenuItemsInner({ const entry = getCatalogEntry(agent) const label = entry?.label ?? agent const result = launchAgentInNewTab({ + requestId: newAgentLaunchRequestId(), agent, worktreeId, groupId, @@ -236,14 +228,12 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent - const isStructuredLaunchPending = - isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending' const showsDefaultAgentShortcut = newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( runLaunch(agent)} className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" title={translate( @@ -252,11 +242,7 @@ function QuickLaunchAgentMenuItemsInner({ { value0: label } )} > - {isStructuredLaunchPending ? ( -