From c4751e2d9955dabc5b3789078a90a6595e52f33d Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 17:41:22 -0700
Subject: [PATCH 01/16] fix(native-chat): start a new chat after an earlier
start failed
A chat whose start the host refused stayed registered under its agent and
workspace, so the + menu and new-tab search kept that agent disabled with a
spinner, and any later "new chat" for that agent (for example "Send notes to
> New agent") restarted the failed chat instead and dropped its own prompt.
Only launches in flight now hold the agent/workspace slot that later starts
join. The registry keeps every launch by session id and derives the holder,
so a failed chat keeps its own Retry while a new start makes a new chat.
---
.../QuickLaunchButton.launch-status.test.tsx | 117 ++++++++
...agent-session-launch-after-failure.test.ts | 267 ++++++++++++++++++
...tructured-agent-session-launch-registry.ts | 53 ++--
.../structured-agent-session-launch-status.ts | 24 +-
.../structured-agent-session-launch.test.ts | 10 +-
.../lib/structured-agent-session-launch.ts | 11 +-
6 files changed, 438 insertions(+), 44 deletions(-)
create mode 100644 src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx
create mode 100644 src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts
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
new file mode 100644
index 00000000000..ed8b3a0cc58
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx
@@ -0,0 +1,117 @@
+// @vitest-environment happy-dom
+
+import type { ReactNode } from 'react'
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { StructuredLaunchState } from '@/lib/structured-agent-session-launch-registry'
+
+vi.mock('@/hooks/useDetectedAgents', () => ({
+ useDetectedAgents: () => ({ detectedIds: ['claude', 'codex'] })
+}))
+vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
+vi.mock('@/store', () => {
+ const state = {
+ settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] },
+ worktreesByRepo: {},
+ repos: [],
+ openSettingsPage: vi.fn(),
+ openSettingsTarget: vi.fn()
+ }
+ const useAppStore = Object.assign((selector: (s: typeof state) => unknown) => selector(state), {
+ getState: () => state
+ })
+ return { useAppStore }
+})
+vi.mock('@/lib/agent-catalog', () => ({
+ getAgentCatalog: () => [
+ { id: 'claude', label: 'Claude' },
+ { id: 'codex', label: 'Codex' }
+ ],
+ AgentIcon: ({ agent }: { agent: string }) => {agent}
+}))
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenuItem: ({ children, disabled, title }: { children: ReactNode } & DivProps) => (
+
+ {children}
+
+ ),
+ DropdownMenuShortcut: ({ children }: { children: ReactNode }) => {children}
+}))
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string, values?: Record) =>
+ fallback.replace('{{value0}}', values?.value0 ?? '')
+}))
+vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: vi.fn() }))
+
+type DivProps = { disabled?: boolean; title?: string }
+
+import { QuickLaunchAgentMenuItems } from './QuickLaunchButton'
+import {
+ resetStructuredAgentLaunchRegistryForTests,
+ setStructuredLaunchState
+} from '@/lib/structured-agent-session-launch-registry'
+
+const WORKTREE_ID = 'worktree-1'
+
+function registerLaunch(agent: 'claude' | 'codex', outcome: 'pending' | 'failed'): void {
+ const sessionId = `${agent}-session`
+ setStructuredLaunchState({
+ identity: `${agent}:${WORKTREE_ID}`,
+ intent: {
+ worktreeId: WORKTREE_ID,
+ sessionId,
+ agent,
+ params: {
+ envelope: {
+ sessionId,
+ clientOperationId: `operation-${sessionId}`,
+ expectedRuntimeFence: null,
+ payloadFingerprint: `fingerprint-${sessionId}`
+ },
+ worktree: `id:${WORKTREE_ID}`,
+ agent
+ }
+ },
+ promptDelivery: undefined,
+ callers: {
+ outcome,
+ entries: new Set(),
+ promptDeliveryResults: new Set(),
+ onSettled: () => undefined
+ },
+ promise: new Promise(() => undefined),
+ visibilityUnknown: false,
+ cancelled: false,
+ selection: { held: {} }
+ } satisfies StructuredLaunchState)
+}
+
+function agentRowDisabled(label: string): string | null | undefined {
+ return document
+ .querySelector(`[title="Launch ${label} in a new terminal"]`)
+ ?.getAttribute('aria-disabled')
+}
+
+describe('QuickLaunchAgentMenuItems launch status', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ resetStructuredAgentLaunchRegistryForTests()
+ })
+ afterEach(cleanup)
+
+ it('keeps an agent whose chat failed to start launchable while a starting one waits', () => {
+ registerLaunch('claude', 'pending')
+ registerLaunch('codex', 'failed')
+
+ render(
+
+ )
+
+ expect(agentRowDisabled('Claude')).toBe('true')
+ expect(agentRowDisabled('Codex')).toBe('false')
+ })
+})
diff --git a/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts b/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts
new file mode 100644
index 00000000000..a12e5c5e793
--- /dev/null
+++ b/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts
@@ -0,0 +1,267 @@
+// @vitest-environment happy-dom
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
+import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
+
+const mocks = vi.hoisted(() => ({
+ abandonIntent: vi.fn(),
+ callStructuredAgentSession: vi.fn(),
+ createIntent: vi.fn(),
+ retryIntent: vi.fn(),
+ restoreIntent: vi.fn(),
+ launch: vi.fn(),
+ seedDraft: vi.fn(),
+ clearDraft: vi.fn()
+}))
+
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } }))
+
+vi.mock('@/lib/launch-structured-agent-session', () => {
+ class StructuredAgentSessionCreateRefusalError extends Error {}
+ return {
+ createStructuredAgentSessionLaunchIntent: mocks.createIntent,
+ retryStructuredAgentSessionLaunchIntent: mocks.retryIntent,
+ restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent,
+ abandonStructuredAgentSessionLaunchIntent: mocks.abandonIntent,
+ launchStructuredAgentSession: mocks.launch,
+ StructuredAgentSessionCreateRefusalError
+ }
+})
+
+vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
+ refreshLocalStructuredSessionTabs: vi.fn()
+}))
+
+vi.mock('@/runtime/structured-agent-session-client', () => ({
+ callStructuredAgentSession: mocks.callStructuredAgentSession
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => ({
+ unifiedTabsByWorktree: {},
+ seedNativeChatLaunchDraft: mocks.seedDraft,
+ clearNativeChatLaunchDraft: mocks.clearDraft
+ }),
+ subscribe: () => () => undefined
+ }
+}))
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+vi.mock('@/lib/agent-catalog', () => ({
+ getAgentLabel: () => 'Codex',
+ getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }]
+}))
+
+import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
+import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
+import {
+ cancelStructuredAgentLaunch,
+ getStructuredAgentLaunchStatus,
+ getStructuredAgentSessionLaunchLifecycle,
+ retryStructuredAgentSessionLaunch,
+ startStructuredAgentLaunch
+} from './structured-agent-session-launch'
+import { readOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
+import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
+import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
+
+type Receipt = { sessionId: string; fence: number }
+
+const WORKTREE_ID = 'wt-after-failure'
+
+function launchIntent(sessionId: string): StructuredAgentSessionLaunchIntent {
+ return {
+ worktreeId: WORKTREE_ID,
+ sessionId,
+ agent: 'codex',
+ params: {
+ envelope: {
+ sessionId,
+ clientOperationId: `operation-${sessionId}`,
+ expectedRuntimeFence: null,
+ payloadFingerprint: `fingerprint-${sessionId}`
+ },
+ worktree: `id:${WORKTREE_ID}`,
+ agent: 'codex'
+ }
+ }
+}
+
+function publishedSnapshot(...sessionIds: string[]): RuntimeMobileSessionTabsResult {
+ return {
+ worktree: WORKTREE_ID,
+ publicationEpoch: 'epoch-1',
+ snapshotVersion: 1,
+ activeGroupId: null,
+ activeTabId: null,
+ activeTabType: null,
+ tabs: sessionIds.map((sessionId) => ({
+ type: 'agent-session' as const,
+ id: `tab-${sessionId}`,
+ title: 'Codex',
+ sessionId,
+ agent: 'codex' as const,
+ isActive: false
+ }))
+ }
+}
+
+async function flushLaunchSettlement(): Promise {
+ for (let i = 0; i < 20; i += 1) {
+ await Promise.resolve()
+ }
+}
+
+const failed = launchIntent('session-failed')
+const fresh = launchIntent('session-new')
+
+async function refuseFirstLaunch(): Promise {
+ mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh)
+ mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
+ startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'first task' })
+ await flushLaunchSettlement()
+ expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, failed.sessionId)).toBe('failed')
+}
+
+describe('a new launch after a failed one', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ localStorage.clear()
+ resetStructuredAgentLaunchPersistenceForTests()
+ resetStructuredAgentLaunchRegistryForTests()
+ mocks.retryIntent.mockImplementation((intent: StructuredAgentSessionLaunchIntent) => ({
+ ...intent,
+ params: {
+ ...intent.params,
+ envelope: { ...intent.params.envelope, clientOperationId: 'retried-operation' }
+ }
+ }))
+ mocks.restoreIntent.mockImplementation((args: { sessionId: string }) =>
+ launchIntent(args.sessionId)
+ )
+ vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
+ publishedSnapshot(failed.sessionId, fresh.sessionId)
+ ])
+ mocks.callStructuredAgentSession.mockResolvedValue({
+ ok: true,
+ page: { fence: 1 },
+ value: { submission: { dispatchState: 'accepted' } }
+ })
+ })
+
+ it('opens a new chat that sends its own prompt instead of restarting the failed one', async () => {
+ await refuseFirstLaunch()
+ // Why: the + menu and new-tab search disable an agent only while its launch reads pending.
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('idle')
+
+ mocks.launch.mockResolvedValueOnce({ sessionId: fresh.sessionId, fence: 1 })
+ const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'review notes',
+ promptDelivery: 'submit-after-ready'
+ })
+
+ expect(notes.sessionId).toBe(fresh.sessionId)
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('pending')
+ await expect(notes.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expect(mocks.retryIntent).not.toHaveBeenCalled()
+ expect(mocks.callStructuredAgentSession).toHaveBeenCalledWith(
+ { kind: 'local' },
+ 'agentSession.send',
+ expect.objectContaining({
+ envelope: expect.objectContaining({ sessionId: fresh.sessionId }),
+ body: expect.objectContaining({ blocks: [{ type: 'text', text: 'review notes' }] })
+ })
+ )
+ // The failed chat keeps its own preserved prompt for its own Retry.
+ expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, failed.sessionId)).toBe('failed')
+ expect(readOutbox(failed.sessionId)).toEqual([
+ expect.objectContaining({
+ body: expect.objectContaining({ blocks: [{ type: 'text', text: 'first task' }] })
+ })
+ ])
+ })
+
+ it('retries the failed chat beside an in-flight new launch without taking it over', async () => {
+ await refuseFirstLaunch()
+ let resolveFresh!: (receipt: Receipt) => void
+ let rejectRetry!: (error: unknown) => void
+ mocks.launch
+ .mockImplementationOnce(() => new Promise((resolve) => (resolveFresh = resolve)))
+ .mockImplementationOnce(
+ () => new Promise((_resolve, reject) => (rejectRetry = reject))
+ )
+ const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+
+ expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, failed.sessionId)).toBe(true)
+ expect(mocks.launch.mock.calls[2]?.[0]).toMatchObject({ sessionId: failed.sessionId })
+ // A third start joins the new launch, not the retried chat.
+ expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex').sessionId).toBe(fresh.sessionId)
+ expect(mocks.createIntent).toHaveBeenCalledTimes(2)
+
+ // Closing the retried chat leaves the new launch registered and starting.
+ rejectRetry(new StructuredAgentSessionCreateRefusalError('unsupported'))
+ await flushLaunchSettlement()
+ expect(cancelStructuredAgentLaunch(WORKTREE_ID, failed.sessionId)).toBe(true)
+ expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, fresh.sessionId)).toBe('pending')
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('pending')
+
+ resolveFresh({ sessionId: fresh.sessionId, fence: 1 })
+ await expect(next.launchResult).resolves.toEqual({ sessionId: fresh.sessionId, fence: 1 })
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('idle')
+ })
+
+ it('restores a failed chat after reload without unregistering a newer launch', async () => {
+ await refuseFirstLaunch()
+ // Reload: the registry is memory; the failed record is what survives.
+ resetStructuredAgentLaunchRegistryForTests()
+ let resolveFresh!: (receipt: Receipt) => void
+ mocks.launch
+ .mockImplementationOnce(() => new Promise((resolve) => (resolveFresh = resolve)))
+ .mockResolvedValueOnce({ sessionId: failed.sessionId, fence: 1 })
+ const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+
+ expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, failed.sessionId)).toBe(true)
+ await flushLaunchSettlement()
+
+ expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, failed.sessionId)).toBeNull()
+ expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, fresh.sessionId)).toBe('pending')
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('pending')
+ resolveFresh({ sessionId: fresh.sessionId, fence: 1 })
+ await expect(next.launchResult).resolves.toEqual({ sessionId: fresh.sessionId, fence: 1 })
+ })
+
+ it('reads a failed resume as not starting, so resuming again opens a new chat', async () => {
+ const resumeFrom = { providerSessionId: 'provider-1' }
+ mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh)
+ mocks.launch
+ .mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
+ .mockResolvedValueOnce({ sessionId: fresh.sessionId, fence: 1 })
+ startStructuredAgentLaunch(WORKTREE_ID, 'codex', { resumeFrom })
+ await flushLaunchSettlement()
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('idle')
+
+ expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex', { resumeFrom }).sessionId).toBe(
+ fresh.sessionId
+ )
+ })
+
+ it('keeps an unconfirmed launch coalescing and reading unknown', async () => {
+ mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh)
+ mocks.launch.mockRejectedValue(new Error('offline'))
+ vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
+ startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+ await flushLaunchSettlement()
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('unknown')
+
+ expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex').sessionId).toBe(failed.sessionId)
+ expect(mocks.createIntent).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts
index 2f444446e03..96ef5d1ed45 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts
@@ -41,19 +41,17 @@ export type StructuredAgentSessionLaunchLifecycle =
| 'published'
| 'cancelled'
-const pendingStructuredLaunchesByIdentity = new Map()
const structuredLaunchesBySessionId = new Map()
const structuredLaunchListeners = new Set<() => void>()
export function resetStructuredAgentLaunchRegistryForTests(): void {
- pendingStructuredLaunchesByIdentity.clear()
structuredLaunchesBySessionId.clear()
structuredLaunchListeners.clear()
resetStructuredAgentLaunchCancellationForTests()
}
export function notifyStructuredLaunchListeners(): void {
- for (const state of pendingStructuredLaunchesByIdentity.values()) {
+ for (const state of structuredLaunchesBySessionId.values()) {
persistStructuredLaunchState(state)
}
for (const listener of structuredLaunchListeners) {
@@ -78,8 +76,26 @@ export function structuredLaunchIdentity(
: `${agent}:${worktreeId}`
}
-export function getStructuredLaunchState(identity: string): StructuredLaunchState | undefined {
- return pendingStructuredLaunchesByIdentity.get(identity)
+// Why: coalescing stops two creates racing for one chat. A failed launch is not racing; it gives up
+// its identity, so a new start makes a new chat while the failed one keeps its own Retry.
+function holdsLaunchIdentity(state: StructuredLaunchState): boolean {
+ const lifecycle = launchStateLifecycle(state)
+ return lifecycle !== 'failed' && lifecycle !== 'cancelled'
+}
+
+export function structuredLaunchesHoldingIdentity(
+ matches: (identity: string) => boolean
+): StructuredLaunchState[] {
+ return [...structuredLaunchesBySessionId.values()].filter(
+ (state) => matches(state.identity) && holdsLaunchIdentity(state)
+ )
+}
+
+/** The launch a new start for `identity` joins; the newest wins if a retried chat holds it too. */
+export function getJoinableStructuredLaunchState(
+ identity: string
+): StructuredLaunchState | undefined {
+ return structuredLaunchesHoldingIdentity((candidate) => candidate === identity).at(-1)
}
export function getStructuredLaunchStateBySessionId(
@@ -89,19 +105,15 @@ export function getStructuredLaunchStateBySessionId(
}
export function setStructuredLaunchState(state: StructuredLaunchState): void {
- pendingStructuredLaunchesByIdentity.set(state.identity, state)
structuredLaunchesBySessionId.set(state.intent.sessionId, state)
persistStructuredLaunchState(state)
}
export function deleteStructuredLaunchStateIfCurrent(state: StructuredLaunchState): boolean {
- if (pendingStructuredLaunchesByIdentity.get(state.identity) !== state) {
+ if (structuredLaunchesBySessionId.get(state.intent.sessionId) !== state) {
return false
}
- pendingStructuredLaunchesByIdentity.delete(state.identity)
- if (structuredLaunchesBySessionId.get(state.intent.sessionId) === state) {
- structuredLaunchesBySessionId.delete(state.intent.sessionId)
- }
+ structuredLaunchesBySessionId.delete(state.intent.sessionId)
deleteStructuredAgentLaunchRecord(state.intent.sessionId)
return true
}
@@ -132,7 +144,7 @@ export function getPersistedStructuredAgentLaunchRecord(
}
export function structuredLaunchStates(): IterableIterator {
- return pendingStructuredLaunchesByIdentity.values()
+ return structuredLaunchesBySessionId.values()
}
function launchStateLifecycle(state: StructuredLaunchState): StructuredAgentSessionLaunchLifecycle {
@@ -321,20 +333,3 @@ export function retireAbsentStructuredAgentSessionLaunchCancellationTombstones(
}
return changed
}
-
-export function getStructuredAgentLaunchStatus(
- worktreeId: string,
- agent: AgentSessionHandleProvider
-): StructuredAgentLaunchStatus {
- // Any launch for this pair, including adopted conversations, means a chat is starting here.
- const states = [
- getStructuredLaunchState(structuredLaunchIdentity(worktreeId, agent)),
- ...[...pendingStructuredLaunchesByIdentity.entries()]
- .filter(([identity]) => identity.startsWith(`${agent}:${worktreeId}:resume:`))
- .map(([, state]) => state)
- ].filter((state): state is StructuredLaunchState => Boolean(state))
- if (states.length === 0) {
- return 'idle'
- }
- return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending'
-}
diff --git a/src/renderer/src/lib/structured-agent-session-launch-status.ts b/src/renderer/src/lib/structured-agent-session-launch-status.ts
index 6ae37ff6553..f57a939da84 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-status.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-status.ts
@@ -1,14 +1,32 @@
import { useSyncExternalStore } from 'react'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import {
- getStructuredAgentLaunchStatus,
- subscribeStructuredAgentLaunchStatus
+ structuredLaunchesHoldingIdentity,
+ structuredLaunchIdentity,
+ subscribeStructuredAgentLaunchStatus,
+ type StructuredAgentLaunchStatus
} from './structured-agent-session-launch-registry'
+export function getStructuredAgentLaunchStatus(
+ worktreeId: string,
+ agent: AgentSessionHandleProvider
+): StructuredAgentLaunchStatus {
+ // Any launch holding an identity for this pair, adopted conversations included, is starting here.
+ // A failed one is not: it waits on its own Retry, so a new launch may go ahead.
+ const identity = structuredLaunchIdentity(worktreeId, agent)
+ const states = structuredLaunchesHoldingIdentity(
+ (candidate) => candidate === identity || candidate.startsWith(`${identity}:resume:`)
+ )
+ if (states.length === 0) {
+ return 'idle'
+ }
+ return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending'
+}
+
export function useStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
-): ReturnType {
+): StructuredAgentLaunchStatus {
return useSyncExternalStore(
subscribeStructuredAgentLaunchStatus,
() => getStructuredAgentLaunchStatus(worktreeId, agent),
diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts
index 33bbc737530..038b8af14f7 100644
--- a/src/renderer/src/lib/structured-agent-session-launch.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts
@@ -630,7 +630,7 @@ describe('startStructuredAgentLaunch', () => {
startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchSettlement()
- startStructuredAgentLaunch(worktreeId, 'codex')
+ expect(retryStructuredAgentSessionLaunch(worktreeId, first.sessionId)).toBe(true)
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledOnce()
@@ -661,11 +661,9 @@ describe('startStructuredAgentLaunch', () => {
await flushLaunchSettlement()
expect(readOutbox(intent.sessionId)).toEqual([])
- const retry = startStructuredAgentLaunch(worktreeId, 'codex', {
- prompt: 'only once',
- promptDelivery: 'draft'
- })
- await expect(retry.launchResult).resolves.toEqual({ sessionId: intent.sessionId, fence: 1 })
+ expect(retryStructuredAgentSessionLaunch(worktreeId, intent.sessionId)).toBe(true)
+ await flushLaunchSettlement()
+ expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, intent.sessionId)).toBeNull()
expect(readOutbox(intent.sessionId)).toEqual([])
expect(mocks.seedDraft).toHaveBeenCalledOnce()
diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts
index 10a37d46c4c..ac659f0a669 100644
--- a/src/renderer/src/lib/structured-agent-session-launch.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch.ts
@@ -30,8 +30,8 @@ import { trackStructuredLaunchFailureToast } from './structured-agent-session-la
import { structuredLaunchFailure } from './structured-agent-session-launch-failure'
import {
deleteStructuredLaunchStateIfCurrent,
+ getJoinableStructuredLaunchState,
getStructuredAgentSessionLaunchLifecycle,
- getStructuredLaunchState,
getStructuredLaunchStateBySessionId,
markStructuredAgentSessionLaunchCancelled,
notifyStructuredLaunchListeners,
@@ -45,7 +45,6 @@ import { applyStructuredLaunchHeldOptions } from './structured-agent-session-lau
export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt }
export {
- getStructuredAgentLaunchStatus,
getStructuredAgentSessionLaunchLifecycle,
getStructuredAgentSessionLaunchResumes,
hasStructuredAgentSessionLaunchCancellationTombstone,
@@ -58,7 +57,7 @@ export {
type StructuredAgentLaunchStatus,
type StructuredAgentSessionLaunchLifecycle
} from './structured-agent-session-launch-registry'
-export { useStructuredAgentLaunchStatus } from './structured-agent-session-launch-status'
+export * from './structured-agent-session-launch-status'
export { useStructuredAgentSessionLaunchSelection } from './structured-agent-session-launch-options'
type StructuredLaunchStateResult = {
@@ -198,14 +197,14 @@ function structuredAgentLaunchState(
options: StructuredAgentLaunchOptions
): StructuredLaunchStateResult {
const identity = structuredLaunchIdentity(worktreeId, agent, options.resumeFrom)
- const existing = getStructuredLaunchState(identity)
+ const existing = getJoinableStructuredLaunchState(identity)
if (existing) {
- const retrying = existing.visibilityUnknown || existing.callers.outcome === 'failed'
+ const retrying = existing.visibilityUnknown
if (retrying) {
restartStructuredLaunchState(existing)
}
const joined = joinLaunchDelivery(options, existing.promptDelivery)
- // Why: failed launches keep their draft/outbox, so a retry must not stage the same prompt twice.
+ // Why: an unconfirmed launch keeps its draft/outbox, so a recheck must not stage it twice.
const text = retrying ? '' : outboxPromptText(joined)
const stagedPrompt = text
? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text)
From 8ca0065288f7ba812e5cbb2c7301fc58a5cf002c Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 3 Oct 2026 21:51:54 -0700
Subject: [PATCH 02/16] test: import the launch status from its own module
---
.../src/lib/structured-agent-session-paired-admission.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts b/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts
index 40f522bd2ed..5c14f640640 100644
--- a/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts
@@ -29,7 +29,7 @@ import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent
import { beginDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing'
import type { AiVaultSession } from '../../../shared/ai-vault-types'
import { resumeAiVaultSessionInNewChat } from '@/components/right-sidebar/ai-vault-session-resume-in-chat-launch'
-import { getStructuredAgentLaunchStatus } from './structured-agent-session-launch-registry'
+import { getStructuredAgentLaunchStatus } from './structured-agent-session-launch-status'
import { getStructuredAgentSessionLaunchSelection } from './structured-agent-session-launch-options'
import { peekWebSessionFocusIntent } from '@/runtime/web-session-focus-intent'
From 04ebe48fef368d8976611567b07039c35057f8bd Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 3 Oct 2026 22:12:59 -0700
Subject: [PATCH 03/16] test: name the owning host on the launch intents the
new tests build
---
.../components/tab-bar/QuickLaunchButton.launch-status.test.tsx | 2 ++
.../lib/structured-agent-session-launch-after-failure.test.ts | 2 ++
2 files changed, 4 insertions(+)
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 ed8b3a0cc58..cc939a070e7 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
@@ -60,6 +60,8 @@ function registerLaunch(agent: 'claude' | 'codex', outcome: 'pending' | 'failed'
intent: {
worktreeId: WORKTREE_ID,
sessionId,
+ executionHostId: 'local',
+ target: { kind: 'local' },
agent,
params: {
envelope: {
diff --git a/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts b/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts
index a12e5c5e793..8a2118a1777 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts
@@ -78,6 +78,8 @@ function launchIntent(sessionId: string): StructuredAgentSessionLaunchIntent {
return {
worktreeId: WORKTREE_ID,
sessionId,
+ executionHostId: 'local',
+ target: { kind: 'local' },
agent: 'codex',
params: {
envelope: {
From 2535607575ec5e49839f0fd2180009426454bdd5 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 3 Oct 2026 23:15:33 -0700
Subject: [PATCH 04/16] fix(native-chat): a new start never joins an
unconfirmed or retried chat
A new start for an agent joined a chat whose create answer was lost, or a
failed chat whose Retry was in flight, and dropped its own text: the
source-control "Fix with AI" action reported success with nothing sent,
"Send notes to > New agent" sent nothing, and after a reload the text became
an unsent draft in the old chat.
Only a new start's own first create now coalesces later starts (double click,
two callers at once). Each attempt records whether it is that first create or
a Retry/re-check of an existing chat; an unconfirmed or retried blank chat
keeps its own Retry and a new start opens a new chat with its text. A resume
still re-checks or joins its conversation's launch, since the host refuses a
second adoption.
---
.../QuickLaunchButton.launch-status.test.tsx | 24 +-
...launch-agent-structured-chat-guard.test.ts | 6 +-
...agent-session-launch-after-failure.test.ts | 12 -
...structured-agent-session-launch-callers.ts | 7 +-
...-agent-session-launch-cancellation.test.ts | 2 +
...tructured-agent-session-launch-registry.ts | 15 +-
.../structured-agent-session-launch-reload.ts | 3 +-
.../structured-agent-session-launch-status.ts | 2 +-
...sion-launch-unconfirmed-or-retried.test.ts | 314 ++++++++++++++++++
.../structured-agent-session-launch.test.ts | 54 ++-
.../lib/structured-agent-session-launch.ts | 4 +-
11 files changed, 384 insertions(+), 59 deletions(-)
create mode 100644 src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts
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 cc939a070e7..7f42eb8d7dc 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
@@ -53,7 +53,11 @@ import {
const WORKTREE_ID = 'worktree-1'
-function registerLaunch(agent: 'claude' | 'codex', outcome: 'pending' | 'failed'): void {
+function registerLaunch(
+ agent: 'claude' | 'codex',
+ outcome: 'pending' | 'failed',
+ attempt: 'first' | 'retry' = 'first'
+): void {
const sessionId = `${agent}-session`
setStructuredLaunchState({
identity: `${agent}:${WORKTREE_ID}`,
@@ -77,6 +81,7 @@ function registerLaunch(agent: 'claude' | 'codex', outcome: 'pending' | 'failed'
promptDelivery: undefined,
callers: {
outcome,
+ attempt,
entries: new Set(),
promptDeliveryResults: new Set(),
onSettled: () => undefined
@@ -116,4 +121,21 @@ describe('QuickLaunchAgentMenuItems launch status', () => {
expect(agentRowDisabled('Claude')).toBe('true')
expect(agentRowDisabled('Codex')).toBe('false')
})
+
+ // 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', 'retry')
+
+ render(
+
+ )
+
+ expect(agentRowDisabled('Claude')).toBe('true')
+ expect(agentRowDisabled('Codex')).toBe('false')
+ })
})
diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts
index 25f66d4af23..b3df1dbeabf 100644
--- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts
+++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts
@@ -471,7 +471,7 @@ describe('structured chat adoption guard on the launch path', () => {
await vi.waitFor(() => expect(mockToastError).not.toHaveBeenCalled())
})
- it('does not create a sibling when post-create visibility proof is unknown', async () => {
+ it('re-checks a chat whose visibility proof is unknown through its own Retry, with its own intent', async () => {
store.unifiedTabsByWorktree = {}
const firstIntent = structuredLaunchIntent('wt-1', 'codex-session-1')
const secondIntent = structuredLaunchIntent('wt-1', 'codex-session-2')
@@ -519,7 +519,9 @@ describe('structured chat adoption guard on the launch path', () => {
)
expect(mockToastError).not.toHaveBeenCalled()
- launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
+ // A new launch would open a new chat; the unconfirmed one is re-checked by its own Retry.
+ const { retryStructuredAgentSessionLaunch } = await import('./structured-agent-session-launch')
+ expect(retryStructuredAgentSessionLaunch('wt-1', firstIntent.sessionId)).toBe(true)
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(3))
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(1)
diff --git a/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts b/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts
index 8a2118a1777..d7d3686eb2d 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts
@@ -254,16 +254,4 @@ describe('a new launch after a failed one', () => {
fresh.sessionId
)
})
-
- it('keeps an unconfirmed launch coalescing and reading unknown', async () => {
- mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh)
- mocks.launch.mockRejectedValue(new Error('offline'))
- vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
- startStructuredAgentLaunch(WORKTREE_ID, 'codex')
- await flushLaunchSettlement()
- expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('unknown')
-
- expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex').sessionId).toBe(failed.sessionId)
- expect(mocks.createIntent).toHaveBeenCalledOnce()
- })
})
diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts
index b3ce31f1a6b..b3c95e477a4 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts
@@ -24,14 +24,19 @@ export type StructuredLaunchCaller = {
export type StructuredLaunchCallerGroup = {
outcome: 'pending' | 'published' | 'failed' | 'unknown' | 'cancelled'
+ /** 'retry': a Retry or re-check of a chat that already exists, not a new start's own create. */
+ attempt: 'first' | 'retry'
entries: Set
promptDeliveryResults: Set>
onSettled: () => void
}
-export function createStructuredLaunchCallerGroup(): StructuredLaunchCallerGroup {
+export function createStructuredLaunchCallerGroup(
+ attempt: StructuredLaunchCallerGroup['attempt']
+): StructuredLaunchCallerGroup {
return {
outcome: 'pending',
+ attempt,
entries: new Set(),
promptDeliveryResults: new Set(),
onSettled: () => {}
diff --git a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts b/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts
index f63b985586c..e5dbccf5f71 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts
@@ -82,6 +82,7 @@ describe('structured launch cancellation retirement', () => {
promptDelivery: 'auto-submit',
callers: {
outcome: 'pending',
+ attempt: 'first',
entries: new Set(),
promptDeliveryResults: new Set(),
onSettled: () => undefined
@@ -161,6 +162,7 @@ describe('structured launch cancellation retirement', () => {
promptDelivery: 'auto-submit',
callers: {
outcome: 'pending',
+ attempt: 'first',
entries: new Set(),
promptDeliveryResults: new Set(),
onSettled: () => undefined
diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts
index 9f28447cf22..697e013ec5e 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts
@@ -78,11 +78,18 @@ export function structuredLaunchIdentity(
: `${agent}:${worktreeId}`
}
-// Why: coalescing stops two creates racing for one chat. A failed launch is not racing; it gives up
-// its identity, so a new start makes a new chat while the failed one keeps its own Retry.
+// Why: coalescing stops two new starts racing for one chat. A failed or unconfirmed launch, or a
+// Retry/re-check of one, is not that race: a new start opens a new chat carrying its own text, and
+// that chat keeps its own Retry. A resume keeps holding: the host refuses a second adoption.
function holdsLaunchIdentity(state: StructuredLaunchState): boolean {
const lifecycle = launchStateLifecycle(state)
- return lifecycle !== 'failed' && lifecycle !== 'cancelled'
+ if (lifecycle === 'failed' || lifecycle === 'cancelled') {
+ return false
+ }
+ if (state.intent.params.resumeFrom) {
+ return true
+ }
+ return state.callers.attempt === 'first' && lifecycle !== 'visibility-unknown'
}
export function structuredLaunchesHoldingIdentity(
@@ -93,7 +100,7 @@ export function structuredLaunchesHoldingIdentity(
)
}
-/** The launch a new start for `identity` joins; the newest wins if a retried chat holds it too. */
+/** The launch a new start for `identity` joins; the newest wins if a retried resume holds it too. */
export function getJoinableStructuredLaunchState(
identity: string
): StructuredLaunchState | undefined {
diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.ts
index 3ac9394a89c..ff7bd3021d9 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-reload.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-reload.ts
@@ -42,7 +42,8 @@ export function restorePersistedStructuredLaunchState(
}
throw error
}
- const callers: StructuredLaunchCallerGroup = createStructuredLaunchCallerGroup()
+ // Only a Retry or re-check restarts a restored launch.
+ const callers: StructuredLaunchCallerGroup = createStructuredLaunchCallerGroup('retry')
const state: StructuredLaunchState = {
identity: structuredLaunchIdentity(worktreeId, record.agent, record.resumeFrom),
intent,
diff --git a/src/renderer/src/lib/structured-agent-session-launch-status.ts b/src/renderer/src/lib/structured-agent-session-launch-status.ts
index f57a939da84..cb5445c8cdc 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-status.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-status.ts
@@ -12,7 +12,7 @@ export function getStructuredAgentLaunchStatus(
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
// Any launch holding an identity for this pair, adopted conversations included, is starting here.
- // A failed one is not: it waits on its own Retry, so a new launch may go ahead.
+ // A failed chat is not, nor an unconfirmed or retried blank one: a new launch opens its own chat.
const identity = structuredLaunchIdentity(worktreeId, agent)
const states = structuredLaunchesHoldingIdentity(
(candidate) => candidate === identity || candidate.startsWith(`${identity}:resume:`)
diff --git a/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts b/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts
new file mode 100644
index 00000000000..95edf978637
--- /dev/null
+++ b/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts
@@ -0,0 +1,314 @@
+// @vitest-environment happy-dom
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
+import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
+
+const mocks = vi.hoisted(() => ({
+ abandonIntent: vi.fn(),
+ callStructuredAgentSession: vi.fn(),
+ createIntent: vi.fn(),
+ retryIntent: vi.fn(),
+ restoreIntent: vi.fn(),
+ launch: vi.fn(),
+ seedDraft: vi.fn(),
+ clearDraft: vi.fn(),
+ toastError: vi.fn()
+}))
+
+vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } }))
+
+vi.mock('@/lib/launch-structured-agent-session', () => {
+ class StructuredAgentSessionCreateRefusalError extends Error {}
+ return {
+ createStructuredAgentSessionLaunchIntent: mocks.createIntent,
+ retryStructuredAgentSessionLaunchIntent: mocks.retryIntent,
+ restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent,
+ abandonStructuredAgentSessionLaunchIntent: mocks.abandonIntent,
+ launchStructuredAgentSession: mocks.launch,
+ StructuredAgentSessionCreateRefusalError
+ }
+})
+
+vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
+ refreshLocalStructuredSessionTabs: vi.fn()
+}))
+
+vi.mock('@/runtime/structured-agent-session-client', () => ({
+ callStructuredAgentSession: mocks.callStructuredAgentSession
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => ({
+ unifiedTabsByWorktree: {},
+ seedNativeChatLaunchDraft: mocks.seedDraft,
+ clearNativeChatLaunchDraft: mocks.clearDraft
+ }),
+ subscribe: () => () => undefined
+ }
+}))
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+vi.mock('@/lib/agent-catalog', () => ({
+ getAgentLabel: () => 'Codex',
+ getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }]
+}))
+
+vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() }))
+
+// The structured route hands the launch's own delivery result back unchanged.
+vi.mock('@/lib/launch-agent-in-new-tab', async () => {
+ const launch = await import('./structured-agent-session-launch')
+ return {
+ launchAgentInNewTab: (args: {
+ worktreeId: string
+ prompt: string
+ promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
+ }) => {
+ const started = launch.startStructuredAgentLaunch(args.worktreeId, 'codex', {
+ prompt: args.prompt,
+ promptDelivery: args.promptDelivery
+ })
+ return {
+ surface: { kind: 'agent-session', sessionId: started.sessionId },
+ ...(started.promptDeliveryResult
+ ? { promptDeliveryResult: started.promptDeliveryResult }
+ : {})
+ }
+ }
+ }
+})
+
+import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
+import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
+import { runSourceControlAgentActionStart } from '@/components/right-sidebar/runSourceControlAgentActionStart'
+import {
+ getStructuredAgentLaunchStatus,
+ getStructuredAgentSessionLaunchLifecycle,
+ retryStructuredAgentSessionLaunch,
+ startStructuredAgentLaunch
+} from './structured-agent-session-launch'
+import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
+import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
+
+const WORKTREE_ID = 'wt-unconfirmed-or-retried'
+
+function launchIntent(sessionId: string): StructuredAgentSessionLaunchIntent {
+ return {
+ worktreeId: WORKTREE_ID,
+ sessionId,
+ executionHostId: 'local',
+ target: { kind: 'local' },
+ agent: 'codex',
+ params: {
+ envelope: {
+ sessionId,
+ clientOperationId: `operation-${sessionId}`,
+ expectedRuntimeFence: null,
+ payloadFingerprint: `fingerprint-${sessionId}`
+ },
+ worktree: `id:${WORKTREE_ID}`,
+ agent: 'codex'
+ }
+ }
+}
+
+function publishedSnapshot(sessionId: string): RuntimeMobileSessionTabsResult {
+ return {
+ worktree: WORKTREE_ID,
+ publicationEpoch: 'epoch-1',
+ snapshotVersion: 1,
+ activeGroupId: null,
+ activeTabId: null,
+ activeTabType: null,
+ tabs: [
+ {
+ type: 'agent-session',
+ id: `tab-${sessionId}`,
+ title: 'Codex',
+ sessionId,
+ agent: 'codex',
+ isActive: false
+ }
+ ]
+ }
+}
+
+async function flushLaunchSettlement(): Promise {
+ for (let i = 0; i < 20; i += 1) {
+ await Promise.resolve()
+ }
+}
+
+const unconfirmed = launchIntent('session-unconfirmed')
+const fresh = launchIntent('session-new')
+const resumeFrom = { providerSessionId: 'provider-1' }
+
+/** The create's answer is lost and inventory never shows the chat: it stays unconfirmed. */
+async function leaveFirstLaunchUnconfirmed(options: { resume?: boolean } = {}): Promise {
+ mocks.createIntent
+ .mockReturnValueOnce(
+ options.resume
+ ? { ...unconfirmed, params: { ...unconfirmed.params, resumeFrom } }
+ : unconfirmed
+ )
+ .mockReturnValueOnce(fresh)
+ startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'first task',
+ ...(options.resume ? { resumeFrom } : {})
+ })
+ await flushLaunchSettlement()
+ expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, unconfirmed.sessionId)).toBe(
+ 'visibility-unknown'
+ )
+}
+
+function expectSentTo(sessionId: string, text: string): void {
+ expect(mocks.callStructuredAgentSession).toHaveBeenCalledWith(
+ { kind: 'local' },
+ 'agentSession.send',
+ expect.objectContaining({
+ envelope: expect.objectContaining({ sessionId }),
+ body: expect.objectContaining({ blocks: [{ type: 'text', text }] })
+ })
+ )
+}
+
+describe('a new start beside an unconfirmed or retried chat', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ localStorage.clear()
+ resetStructuredAgentLaunchPersistenceForTests()
+ resetStructuredAgentLaunchRegistryForTests()
+ mocks.retryIntent.mockImplementation((intent: StructuredAgentSessionLaunchIntent) => intent)
+ mocks.restoreIntent.mockImplementation((args: { sessionId: string }) =>
+ launchIntent(args.sessionId)
+ )
+ mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) =>
+ intent.sessionId === fresh.sessionId
+ ? Promise.resolve({ sessionId: fresh.sessionId, fence: 1 })
+ : Promise.reject(new Error('response lost'))
+ )
+ vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
+ publishedSnapshot(fresh.sessionId)
+ ])
+ mocks.callStructuredAgentSession.mockResolvedValue({
+ ok: true,
+ page: { fence: 1 },
+ value: { submission: { dispatchState: 'accepted' } }
+ })
+ })
+
+ it('starts a source-control agent in a new chat and reports success only once its text is sent', async () => {
+ await leaveFirstLaunchUnconfirmed()
+ const onLaunched = vi.fn()
+
+ const started = await runSourceControlAgentActionStart({
+ selectedAgent: 'codex',
+ trimmedCommandInput: 'Fix the failing check',
+ agentArgs: '',
+ agentArgsApply: false,
+ commandTemplate: '{basePrompt}',
+ saveTargetValue: 'none',
+ actionId: 'resolveComments',
+ settings: null,
+ repo: null,
+ worktreeId: WORKTREE_ID,
+ promptDelivery: 'submit-after-ready',
+ launchSource: 'source_control_recovery',
+ onLaunched,
+ onClose: vi.fn()
+ })
+
+ expect(started).toBe(true)
+ expect(onLaunched).toHaveBeenCalledOnce()
+ expect(mocks.createIntent).toHaveBeenCalledTimes(2)
+ expectSentTo(fresh.sessionId, 'Fix the failing check')
+ expect(mocks.toastError).not.toHaveBeenCalled()
+ })
+
+ it('sends notes to a new chat while another chat is unconfirmed', async () => {
+ await leaveFirstLaunchUnconfirmed()
+
+ const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'review notes',
+ promptDelivery: 'submit-after-ready'
+ })
+
+ expect(notes.sessionId).toBe(fresh.sessionId)
+ await expect(notes.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expectSentTo(fresh.sessionId, 'review notes')
+ // The unconfirmed chat is left for its own re-check.
+ expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, unconfirmed.sessionId)).toBe(
+ 'visibility-unknown'
+ )
+ })
+
+ it("opens a new chat with its text, not a draft, while a restored chat's Retry is in flight", async () => {
+ const failed = launchIntent('session-failed')
+ mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh)
+ mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
+ startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'first task' })
+ await flushLaunchSettlement()
+ // Reload: the registry is memory; the failed record is what survives.
+ resetStructuredAgentLaunchRegistryForTests()
+ mocks.launch.mockImplementationOnce(() => new Promise(() => undefined))
+ expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, failed.sessionId)).toBe(true)
+ mocks.seedDraft.mockClear()
+
+ const next = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'fix it',
+ promptDelivery: 'submit-after-ready'
+ })
+
+ expect(next.sessionId).toBe(fresh.sessionId)
+ await expect(next.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expectSentTo(fresh.sessionId, 'fix it')
+ expect(mocks.seedDraft).not.toHaveBeenCalled()
+ })
+
+ it('opens a new chat from a + menu pick while another chat is unconfirmed', async () => {
+ await leaveFirstLaunchUnconfirmed()
+ // Why: the + menu disables an agent only while a pick would join a start in flight.
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('idle')
+
+ const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+
+ expect(pick.sessionId).toBe(fresh.sessionId)
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('pending')
+ // No re-check of the unconfirmed chat: only its own two create attempts and the new one.
+ expect(mocks.launch).toHaveBeenCalledTimes(3)
+ await expect(pick.launchResult).resolves.toEqual({ sessionId: fresh.sessionId, fence: 1 })
+ })
+
+ it('still coalesces two new starts racing for one chat', async () => {
+ mocks.createIntent.mockReturnValueOnce(fresh)
+ mocks.launch.mockImplementationOnce(() => new Promise(() => undefined))
+
+ const first = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'one' })
+ const second = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'two' })
+
+ expect(second.sessionId).toBe(first.sessionId)
+ expect(mocks.createIntent).toHaveBeenCalledOnce()
+ })
+
+ it('still re-checks an unconfirmed resume instead of adopting its conversation twice', async () => {
+ await leaveFirstLaunchUnconfirmed({ resume: true })
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex')).toBe('unknown')
+
+ expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex', { resumeFrom }).sessionId).toBe(
+ unconfirmed.sessionId
+ )
+ expect(mocks.createIntent).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts
index 0f4c7459148..84763f9e0e8 100644
--- a/src/renderer/src/lib/structured-agent-session-launch.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts
@@ -496,7 +496,7 @@ describe('startStructuredAgentLaunch', () => {
})
})
- it('keeps one launch identity per worktree while the outcome is unknown', async () => {
+ it('opens a new chat for a new start while an earlier outcome is unknown', async () => {
const worktreeId = 'wt-unknown-different-prompts'
const intent = launchIntent(worktreeId)
mocks.createIntent.mockReturnValueOnce(intent)
@@ -505,10 +505,16 @@ describe('startStructuredAgentLaunch', () => {
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
await flushLaunchSettlement()
- startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
+ const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
await flushLaunchSettlement()
- expect(mocks.createIntent).toHaveBeenCalledOnce()
+ expect(mocks.createIntent).toHaveBeenCalledTimes(2)
+ expect(second.sessionId).not.toBe(intent.sessionId)
+ expect(readOutbox(second.sessionId)).toEqual([
+ expect.objectContaining({
+ body: expect.objectContaining({ blocks: [{ type: 'text', text: 'second prompt' }] })
+ })
+ ])
})
it('reconciles a host commit when the create reply is lost', async () => {
@@ -572,20 +578,22 @@ describe('startStructuredAgentLaunch', () => {
expect(toast.error).not.toHaveBeenCalled()
})
- it('keeps an unresolved identity reserved until inventory reconciles it', async () => {
+ // Why a resume: the host refuses a second adoption of the conversation, so a new resume re-checks.
+ it('keeps an unresolved resume identity reserved until inventory reconciles it', async () => {
const worktreeId = 'wt-still-unknown'
+ const resumeFrom = { providerSessionId: 'provider-still-unknown' }
const intent = launchIntent(worktreeId)
- mocks.createIntent.mockReturnValueOnce(intent)
+ mocks.createIntent.mockReturnValueOnce({ ...intent, params: { ...intent.params, resumeFrom } })
mocks.launch.mockRejectedValue(new Error('offline'))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([])
- startStructuredAgentLaunch(worktreeId, 'codex')
+ startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom })
await flushLaunchSettlement()
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
- startStructuredAgentLaunch(worktreeId, 'codex')
+ startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom })
await flushLaunchSettlement()
expect(mocks.createIntent).toHaveBeenCalledOnce()
@@ -607,8 +615,10 @@ describe('startStructuredAgentLaunch', () => {
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
- const retry = startStructuredAgentLaunch(worktreeId, 'codex')
- await expect(retry.launchResult).resolves.toEqual({ sessionId: intent.sessionId, fence: 1 })
+ // The chat's own Retry re-checks it; a new start would open a new chat instead.
+ expect(retryStructuredAgentSessionLaunch(worktreeId, intent.sessionId)).toBe(true)
+ await flushLaunchSettlement()
+ expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, intent.sessionId)).toBeNull()
expect(readOutbox(intent.sessionId)).toEqual([
expect.objectContaining({
@@ -670,32 +680,6 @@ describe('startStructuredAgentLaunch', () => {
expect(toast.error).not.toHaveBeenCalled()
})
- it('does not stage the preserved launch prompt again on retry', async () => {
- const worktreeId = 'wt-refused-prompt-retry'
- const intent = launchIntent(worktreeId, 'session-refused-prompt-retry')
- mocks.createIntent.mockReturnValueOnce(intent)
- mocks.launch
- .mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported'))
- .mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
- vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
- publishedSnapshot(worktreeId, intent.sessionId)
- ])
-
- startStructuredAgentLaunch(worktreeId, 'codex', {
- prompt: 'only once',
- promptDelivery: 'draft'
- })
- await flushLaunchSettlement()
- expect(readOutbox(intent.sessionId)).toEqual([])
-
- expect(retryStructuredAgentSessionLaunch(worktreeId, intent.sessionId)).toBe(true)
- await flushLaunchSettlement()
- expect(getStructuredAgentSessionLaunchLifecycle(worktreeId, intent.sessionId)).toBeNull()
-
- expect(readOutbox(intent.sessionId)).toEqual([])
- expect(mocks.seedDraft).toHaveBeenCalledOnce()
- })
-
// A paired server's create seeds from its settings at create time, which its probe reports.
it("shows the seed a paired server's probe reports on a retry, not the first admission's", async () => {
const worktreeId = 'wt-paired-retry-seed'
diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts
index ee1efe8d456..a5acbd512ed 100644
--- a/src/renderer/src/lib/structured-agent-session-launch.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch.ts
@@ -124,7 +124,7 @@ function adoptPairedHostSeed(
}
function resetStructuredLaunchCallers(state: StructuredLaunchState): void {
- state.callers = createStructuredLaunchCallerGroup()
+ state.callers = createStructuredLaunchCallerGroup('retry')
state.callers.onSettled = () => maybeCleanupLaunchState(state)
}
@@ -195,7 +195,7 @@ function structuredAgentLaunchState(
? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text)
: null
launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options)
- const callers = createStructuredLaunchCallerGroup()
+ const callers = createStructuredLaunchCallerGroup('first')
const state: StructuredLaunchState = {
identity,
intent,
From 628f65d04d2fa49dcdb43a6539546f8218b912a5 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:20:42 -0700
Subject: [PATCH 05/16] fix(native-chat): a different new request always opens
its own chat
A new start used to join any blank chat still in its first create or still
sending its opening text, so a second "Fix with AI" for another check, or
notes sent to a new agent, landed in that first chat. Each first attempt now
keeps the request it was started with (its text and whether it is sent or
drafted). Only a repeat of that request (a double click, a retried call)
joins it, and it shares the text already staged, so the text is sent once.
Any other request opens a new chat with its own text. The + menu, new-tab
search and send-notes menus disable an agent only when their own pick would
join. Resume launches are unchanged.
---
.../QuickLaunchButton.launch-status.test.tsx | 39 ++-
.../components/tab-bar/QuickLaunchButton.tsx | 8 +-
.../components/tab-bar/TabBarCreateEntry.tsx | 5 +-
...structured-agent-session-launch-callers.ts | 6 +-
...-agent-session-launch-cancellation.test.ts | 5 +-
...ed-agent-session-launch-close-race.test.ts | 6 +-
...t-session-launch-different-request.test.ts | 303 ++++++++++++++++++
...agent-session-launch-join-delivery.test.ts | 13 +-
...tructured-agent-session-launch-registry.ts | 41 ++-
.../structured-agent-session-launch-reload.ts | 2 +-
...structured-agent-session-launch-request.ts | 51 +++
.../structured-agent-session-launch-status.ts | 13 +-
...sion-launch-unconfirmed-or-retried.test.ts | 4 +-
.../structured-agent-session-launch.test.ts | 72 +++--
.../lib/structured-agent-session-launch.ts | 23 +-
15 files changed, 512 insertions(+), 79 deletions(-)
create mode 100644 src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
create mode 100644 src/renderer/src/lib/structured-agent-session-launch-request.ts
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 7f42eb8d7dc..ebeed7630cd 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,6 +4,11 @@ import type { ReactNode } from 'react'
import { cleanup, 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'
vi.mock('@/hooks/useDetectedAgents', () => ({
useDetectedAgents: () => ({ detectedIds: ['claude', 'codex'] })
@@ -56,7 +61,11 @@ const WORKTREE_ID = 'worktree-1'
function registerLaunch(
agent: 'claude' | 'codex',
outcome: 'pending' | 'failed',
- attempt: 'first' | 'retry' = 'first'
+ attempt: StructuredLaunchAttempt = {
+ kind: 'first',
+ request: BLANK_STRUCTURED_LAUNCH_REQUEST,
+ stagedEntry: null
+ }
): void {
const sessionId = `${agent}-session`
setStructuredLaunchState({
@@ -125,7 +134,7 @@ describe('QuickLaunchAgentMenuItems launch status', () => {
// 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', 'retry')
+ 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
+ })
+ const menu = (prompt?: string) => (
+
+ )
+
+ 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')
+ })
})
diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
index 026944575c8..b065595fb65 100644
--- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
+++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
@@ -17,6 +17,7 @@ import {
} 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'
export type QuickLaunchAgentMenuItemsProps = {
worktreeId: string
@@ -119,10 +120,11 @@ function QuickLaunchAgentMenuItemsInner({
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.
+ // 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'),
- codex: useStructuredAgentLaunchStatus(worktreeId, 'codex')
+ claude: useStructuredAgentLaunchStatus(worktreeId, 'claude', launchRequest),
+ codex: useStructuredAgentLaunchStatus(worktreeId, 'codex', launchRequest)
}
const openAgentSettings = useCallback(() => {
diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx
index d0766a7b1cf..0e876e994da 100644
--- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx
+++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx
@@ -34,6 +34,7 @@ import {
} from './tab-create-entry-copy'
import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empty-options'
import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch'
+import { BLANK_STRUCTURED_LAUNCH_REQUEST } from '@/lib/structured-agent-session-launch-request'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { TabEntryActionClassification } from './tab-create-entry-classifier'
@@ -65,8 +66,8 @@ function TabBarCreateEntrySession({
// One hook per structured provider: the launch registry is keyed by agent, and hooks cannot run
// inside the option render loop.
const structuredLaunchStatusByAgent = {
- claude: useStructuredAgentLaunchStatus(worktreeId, 'claude'),
- codex: useStructuredAgentLaunchStatus(worktreeId, 'codex')
+ claude: useStructuredAgentLaunchStatus(worktreeId, 'claude', BLANK_STRUCTURED_LAUNCH_REQUEST),
+ codex: useStructuredAgentLaunchStatus(worktreeId, 'codex', BLANK_STRUCTURED_LAUNCH_REQUEST)
}
const isStructuredLaunchPending = (agent: TuiAgent): boolean =>
isAgentSessionHandleProvider(agent) && structuredLaunchStatusByAgent[agent] === 'pending'
diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts
index b3c95e477a4..0015fbd8f75 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts
@@ -4,6 +4,7 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structur
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
import type { ExecutionHostId } from '../../../shared/execution-host'
+import type { StructuredLaunchAttempt } from './structured-agent-session-launch-request'
export type StructuredAgentLaunchOptions = {
prompt?: string
@@ -24,15 +25,14 @@ export type StructuredLaunchCaller = {
export type StructuredLaunchCallerGroup = {
outcome: 'pending' | 'published' | 'failed' | 'unknown' | 'cancelled'
- /** 'retry': a Retry or re-check of a chat that already exists, not a new start's own create. */
- attempt: 'first' | 'retry'
+ attempt: StructuredLaunchAttempt
entries: Set
promptDeliveryResults: Set>
onSettled: () => void
}
export function createStructuredLaunchCallerGroup(
- attempt: StructuredLaunchCallerGroup['attempt']
+ attempt: StructuredLaunchAttempt
): StructuredLaunchCallerGroup {
return {
outcome: 'pending',
diff --git a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts b/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts
index e5dbccf5f71..4f55e062309 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { suppressCancelledStructuredSessionTabs } from '@/runtime/structured-agent-session-tab-retirement'
import type { StructuredLaunchState } from './structured-agent-session-launch-registry'
+import { BLANK_STRUCTURED_LAUNCH_REQUEST } from './structured-agent-session-launch-request'
import {
hasStructuredAgentSessionLaunchCancellationTombstone,
markStructuredAgentSessionLaunchCancelled,
@@ -82,7 +83,7 @@ describe('structured launch cancellation retirement', () => {
promptDelivery: 'auto-submit',
callers: {
outcome: 'pending',
- attempt: 'first',
+ attempt: { kind: 'first', request: BLANK_STRUCTURED_LAUNCH_REQUEST, stagedEntry: null },
entries: new Set(),
promptDeliveryResults: new Set(),
onSettled: () => undefined
@@ -162,7 +163,7 @@ describe('structured launch cancellation retirement', () => {
promptDelivery: 'auto-submit',
callers: {
outcome: 'pending',
- attempt: 'first',
+ attempt: { kind: 'first', request: BLANK_STRUCTURED_LAUNCH_REQUEST, stagedEntry: null },
entries: new Set(),
promptDeliveryResults: new Set(),
onSettled: () => undefined
diff --git a/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts b/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts
index 9c27384d5a9..f63575080de 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts
@@ -177,7 +177,7 @@ describe('a close that races a structured launch', () => {
expect(toast.error).not.toHaveBeenCalled()
})
- it('discards every coalesced prompt when a close cancels the launch', async () => {
+ it("discards a repeated request's one staged prompt when a close cancels the launch", async () => {
const worktreeId = 'wt-close-coalesced-prompts'
const intent = launchIntent(worktreeId)
let resolveRefresh!: (snapshots: RuntimeMobileSessionTabsResult[]) => void
@@ -188,9 +188,9 @@ describe('a close that races a structured launch', () => {
)
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
- startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
+ startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce())
- expect(readOutbox(intent.sessionId)).toHaveLength(2)
+ expect(readOutbox(intent.sessionId)).toHaveLength(1)
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true)
expect(readOutbox(intent.sessionId)).toEqual([])
diff --git a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
new file mode 100644
index 00000000000..7a7ae7b3d3a
--- /dev/null
+++ b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
@@ -0,0 +1,303 @@
+// @vitest-environment happy-dom
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
+import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
+
+const mocks = vi.hoisted(() => ({
+ abandonIntent: vi.fn(),
+ callStructuredAgentSession: vi.fn(),
+ createIntent: vi.fn(),
+ retryIntent: vi.fn(),
+ restoreIntent: vi.fn(),
+ launch: vi.fn(),
+ seedDraft: vi.fn(),
+ clearDraft: vi.fn()
+}))
+
+vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } }))
+
+vi.mock('@/lib/launch-structured-agent-session', () => {
+ class StructuredAgentSessionCreateRefusalError extends Error {}
+ return {
+ createStructuredAgentSessionLaunchIntent: mocks.createIntent,
+ retryStructuredAgentSessionLaunchIntent: mocks.retryIntent,
+ restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent,
+ abandonStructuredAgentSessionLaunchIntent: mocks.abandonIntent,
+ launchStructuredAgentSession: mocks.launch,
+ StructuredAgentSessionCreateRefusalError
+ }
+})
+
+vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
+ refreshLocalStructuredSessionTabs: vi.fn()
+}))
+
+vi.mock('@/runtime/structured-agent-session-client', () => ({
+ callStructuredAgentSession: mocks.callStructuredAgentSession
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => ({
+ unifiedTabsByWorktree: {},
+ seedNativeChatLaunchDraft: mocks.seedDraft,
+ clearNativeChatLaunchDraft: mocks.clearDraft
+ }),
+ subscribe: () => () => undefined
+ }
+}))
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+vi.mock('@/lib/agent-catalog', () => ({
+ getAgentLabel: () => 'Codex',
+ getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }]
+}))
+
+import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
+import {
+ getStructuredAgentLaunchStatus,
+ startStructuredAgentLaunch
+} from './structured-agent-session-launch'
+import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
+import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
+import { structuredLaunchRequest } from './structured-agent-session-launch-request'
+
+const WORKTREE_ID = 'wt-different-request'
+
+function launchIntent(sessionId: string): StructuredAgentSessionLaunchIntent {
+ return {
+ worktreeId: WORKTREE_ID,
+ sessionId,
+ executionHostId: 'local',
+ target: { kind: 'local' },
+ agent: 'codex',
+ params: {
+ envelope: {
+ sessionId,
+ clientOperationId: `operation-${sessionId}`,
+ expectedRuntimeFence: null,
+ payloadFingerprint: `fingerprint-${sessionId}`
+ },
+ worktree: `id:${WORKTREE_ID}`,
+ agent: 'codex'
+ }
+ }
+}
+
+function publishedSnapshot(...sessionIds: string[]): RuntimeMobileSessionTabsResult {
+ return {
+ worktree: WORKTREE_ID,
+ publicationEpoch: 'epoch-1',
+ snapshotVersion: 1,
+ activeGroupId: null,
+ activeTabId: null,
+ activeTabType: null,
+ tabs: sessionIds.map((sessionId) => ({
+ type: 'agent-session',
+ id: `tab-${sessionId}`,
+ title: 'Codex',
+ sessionId,
+ agent: 'codex',
+ isActive: false
+ }))
+ }
+}
+
+const first = launchIntent('session-first')
+const second = launchIntent('session-second')
+
+/** Every `agentSession.send` as [session, text]. */
+function sends(): [string, string][] {
+ return mocks.callStructuredAgentSession.mock.calls
+ .filter((call) => call[1] === 'agentSession.send')
+ .map((call) => [call[2].envelope.sessionId, call[2].body.blocks[0].text])
+}
+
+describe('a different new request while the first chat is still starting', () => {
+ let resolveFirstLaunch!: (receipt: { sessionId: string; fence: number }) => void
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ localStorage.clear()
+ resetStructuredAgentLaunchPersistenceForTests()
+ resetStructuredAgentLaunchRegistryForTests()
+ mocks.createIntent.mockReturnValueOnce(first).mockReturnValueOnce(second)
+ mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) =>
+ intent.sessionId === first.sessionId
+ ? new Promise((resolve) => (resolveFirstLaunch = resolve))
+ : Promise.resolve({ sessionId: intent.sessionId, fence: 1 })
+ )
+ vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
+ publishedSnapshot(first.sessionId, second.sessionId)
+ ])
+ mocks.callStructuredAgentSession.mockResolvedValue({
+ ok: true,
+ value: { submission: { dispatchState: 'accepted' } }
+ })
+ })
+
+ it('opens a new chat with its own text while the first create is in flight', async () => {
+ const checkA = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'Fix check A',
+ promptDelivery: 'submit-after-ready'
+ })
+ const checkB = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'Fix check B',
+ promptDelivery: 'submit-after-ready'
+ })
+
+ expect(checkB.sessionId).toBe(second.sessionId)
+ await expect(checkB.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
+ await expect(checkA.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expect(sends()).toEqual([
+ [second.sessionId, 'Fix check B'],
+ [first.sessionId, 'Fix check A']
+ ])
+ })
+
+ it('sends notes to a new chat while a blank chat from the + menu is starting', async () => {
+ const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+ // The notes menu stays enabled: its pick would not join the blank chat.
+ expect(
+ getStructuredAgentLaunchStatus(
+ WORKTREE_ID,
+ 'codex',
+ structuredLaunchRequest({ prompt: 'review notes' })
+ )
+ ).toBe('idle')
+ expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex', structuredLaunchRequest({}))).toBe(
+ 'pending'
+ )
+
+ const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'review notes',
+ promptDelivery: 'submit-after-ready'
+ })
+
+ expect(notes.sessionId).not.toBe(blank.sessionId)
+ await expect(notes.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expect(sends()).toEqual([[second.sessionId, 'review notes']])
+ })
+
+ it('opens a new chat with its own text while the first chat is still sending its text', async () => {
+ let resolveFirstSend!: (result: unknown) => void
+ mocks.callStructuredAgentSession.mockImplementationOnce(
+ () => new Promise((resolve) => (resolveFirstSend = resolve))
+ )
+ const checkA = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'Fix check A',
+ promptDelivery: 'submit-after-ready'
+ })
+ resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
+ await vi.waitFor(() => expect(sends()).toEqual([[first.sessionId, 'Fix check A']]))
+
+ const checkB = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'Fix check B',
+ promptDelivery: 'submit-after-ready'
+ })
+
+ expect(checkB.sessionId).toBe(second.sessionId)
+ await expect(checkB.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ resolveFirstSend({ ok: true, value: { submission: { dispatchState: 'accepted' } } })
+ await expect(checkA.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expect(sends()).toEqual([
+ [first.sessionId, 'Fix check A'],
+ [second.sessionId, 'Fix check B']
+ ])
+ })
+})
+
+describe('the same request repeated while the first chat is still starting', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ localStorage.clear()
+ resetStructuredAgentLaunchPersistenceForTests()
+ resetStructuredAgentLaunchRegistryForTests()
+ mocks.createIntent.mockReturnValueOnce(first).mockReturnValueOnce(second)
+ vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
+ publishedSnapshot(first.sessionId)
+ ])
+ mocks.callStructuredAgentSession.mockResolvedValue({
+ ok: true,
+ value: { submission: { dispatchState: 'accepted' } }
+ })
+ })
+
+ it('makes one chat from a double click with no text', () => {
+ mocks.launch.mockImplementation(() => new Promise(() => undefined))
+
+ const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+ const again = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ promptDelivery: 'submit-after-ready'
+ })
+
+ expect(again.sessionId).toBe(pick.sessionId)
+ expect(mocks.createIntent).toHaveBeenCalledOnce()
+ })
+
+ it('makes one chat and sends its text once while the create is in flight', async () => {
+ let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
+ mocks.launch.mockImplementation(() => new Promise((resolve) => (resolveLaunch = resolve)))
+ const onFirstDelivered = vi.fn()
+ const onRepeatDelivered = vi.fn()
+
+ const click = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'Fix check A',
+ promptDelivery: 'submit-after-ready',
+ onPromptDelivered: onFirstDelivered
+ })
+ const repeat = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'Fix check A',
+ promptDelivery: 'submit-after-ready',
+ onPromptDelivered: onRepeatDelivered
+ })
+ resolveLaunch({ sessionId: first.sessionId, fence: 1 })
+
+ expect(repeat.sessionId).toBe(click.sessionId)
+ for (const caller of [click, repeat]) {
+ await expect(caller.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ }
+ expect(sends()).toEqual([[first.sessionId, 'Fix check A']])
+ expect(onFirstDelivered).toHaveBeenCalledOnce()
+ expect(onRepeatDelivered).toHaveBeenCalledOnce()
+ })
+
+ it('seeds a repeated draft once', () => {
+ mocks.launch.mockImplementation(() => new Promise(() => undefined))
+
+ const click = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'PR context',
+ promptDelivery: 'draft'
+ })
+ const repeat = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'PR context',
+ promptDelivery: 'draft'
+ })
+
+ expect(repeat.sessionId).toBe(click.sessionId)
+ expect(mocks.seedDraft).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts
index c597c552262..b8efed9d8fa 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts
@@ -137,7 +137,9 @@ describe('coalesced launch delivery mode', () => {
established: StructuredAgentLaunchOptions
joining: StructuredAgentLaunchOptions
}) {
- const intent = launchIntent(args.worktreeId, args.sessionId)
+ const base = launchIntent(args.worktreeId, args.sessionId)
+ const { resumeFrom } = args.established
+ const intent = resumeFrom ? { ...base, params: { ...base.params, resumeFrom } } : base
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockImplementation(
@@ -155,13 +157,14 @@ describe('coalesced launch delivery mode', () => {
return { intent, joiner }
}
- it('keeps a joiner as a draft when the launch it joined established no mode', async () => {
- // The onboarding folder launch establishes an identity carrying neither prompt nor mode.
+ // Only a resume joins a launch started by a different request; a new start opens its own chat.
+ it('keeps a resume joiner as a draft when the launch it joined established no mode', async () => {
+ const resumeFrom = { providerSessionId: 'provider-unset-delivery' }
const { intent, joiner } = await coalesce({
worktreeId: 'wt-unset-delivery-mode',
sessionId: 'unset-delivery-session',
- established: {},
- joining: { prompt: 'PR context', promptDelivery: 'draft' }
+ established: { resumeFrom },
+ joining: { resumeFrom, prompt: 'PR context', promptDelivery: 'draft' }
})
// Why: an unset established mode must not read as submit; the joiner never consented to send.
diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts
index 697e013ec5e..8c419ba4ce7 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts
@@ -9,6 +9,10 @@ import type {
StructuredAgentLaunchOptions,
StructuredLaunchCallerGroup
} from './structured-agent-session-launch-callers'
+import {
+ joinsFirstLaunchAttempt,
+ type StructuredLaunchRequest
+} from './structured-agent-session-launch-request'
import {
deleteStructuredAgentLaunchRecord,
hasStructuredAgentLaunchCancellationTombstonePersisted,
@@ -78,10 +82,13 @@ export function structuredLaunchIdentity(
: `${agent}:${worktreeId}`
}
-// Why: coalescing stops two new starts racing for one chat. A failed or unconfirmed launch, or a
-// Retry/re-check of one, is not that race: a new start opens a new chat carrying its own text, and
-// that chat keeps its own Retry. A resume keeps holding: the host refuses a second adoption.
-function holdsLaunchIdentity(state: StructuredLaunchState): boolean {
+// Why: coalescing stops a repeat of one request (a double click) racing into two chats. A different
+// request, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new start
+// opens a new chat carrying its own text. A resume keeps holding: the host refuses a second adoption.
+function holdsLaunchIdentity(
+ state: StructuredLaunchState,
+ request?: StructuredLaunchRequest
+): boolean {
const lifecycle = launchStateLifecycle(state)
if (lifecycle === 'failed' || lifecycle === 'cancelled') {
return false
@@ -89,22 +96,26 @@ function holdsLaunchIdentity(state: StructuredLaunchState): boolean {
if (state.intent.params.resumeFrom) {
return true
}
- return state.callers.attempt === 'first' && lifecycle !== 'visibility-unknown'
-}
-
-export function structuredLaunchesHoldingIdentity(
- matches: (identity: string) => boolean
-): StructuredLaunchState[] {
- return [...structuredLaunchesBySessionId.values()].filter(
- (state) => matches(state.identity) && holdsLaunchIdentity(state)
+ return (
+ lifecycle !== 'visibility-unknown' && joinsFirstLaunchAttempt(state.callers.attempt, request)
)
}
-/** The launch a new start for `identity` joins; the newest wins if a retried resume holds it too. */
+export function structuredLaunchesHoldingIdentity(
+ matches: (identity: string) => boolean,
+ request?: StructuredLaunchRequest
+): StructuredLaunchState[] {
+ return [...structuredLaunchesBySessionId.values()].filter(
+ (state) => matches(state.identity) && holdsLaunchIdentity(state, request)
+ )
+}
+
+/** The launch a new start of `request` joins; the newest wins if a retried resume holds it too. */
export function getJoinableStructuredLaunchState(
- identity: string
+ identity: string,
+ request: StructuredLaunchRequest
): StructuredLaunchState | undefined {
- return structuredLaunchesHoldingIdentity((candidate) => candidate === identity).at(-1)
+ return structuredLaunchesHoldingIdentity((candidate) => candidate === identity, request).at(-1)
}
export function getStructuredLaunchStateBySessionId(
diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.ts
index ff7bd3021d9..35379324faa 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-reload.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-reload.ts
@@ -43,7 +43,7 @@ export function restorePersistedStructuredLaunchState(
throw error
}
// Only a Retry or re-check restarts a restored launch.
- const callers: StructuredLaunchCallerGroup = createStructuredLaunchCallerGroup('retry')
+ const callers: StructuredLaunchCallerGroup = createStructuredLaunchCallerGroup({ kind: 'retry' })
const state: StructuredLaunchState = {
identity: structuredLaunchIdentity(worktreeId, record.agent, record.resumeFrom),
intent,
diff --git a/src/renderer/src/lib/structured-agent-session-launch-request.ts b/src/renderer/src/lib/structured-agent-session-launch-request.ts
new file mode 100644
index 00000000000..adefefd11cc
--- /dev/null
+++ b/src/renderer/src/lib/structured-agent-session-launch-request.ts
@@ -0,0 +1,51 @@
+import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox'
+
+/** What a new start asks its chat to receive. Callers carry no request id, so this is what tells a
+ * repeat of one request (a double click, a retried call) from a different request. */
+export type StructuredLaunchRequest = { text: string; draft: boolean }
+
+/** A new start's own create keeps its request and the text it staged; a Retry or re-check of an
+ * existing chat is no request of its own. */
+export type StructuredLaunchAttempt =
+ | {
+ kind: 'first'
+ request: StructuredLaunchRequest
+ stagedEntry: StructuredAgentSessionOutboxEntry | null
+ }
+ | { kind: 'retry' }
+
+/** A pick that carries no text, as the + menu and new-tab search make. */
+export const BLANK_STRUCTURED_LAUNCH_REQUEST: StructuredLaunchRequest = { text: '', draft: false }
+
+export function structuredLaunchRequest(options: {
+ prompt?: string
+ promptDelivery?: string
+}): StructuredLaunchRequest {
+ const text = options.prompt?.trim() ?? ''
+ // Without text the delivery mode carries nothing: two blank starts are one request.
+ return { text, draft: text !== '' && options.promptDelivery === 'draft' }
+}
+
+/** The first attempt `request` repeats, whose text is already staged or seeded. */
+export function repeatedStructuredLaunchAttempt(
+ attempt: StructuredLaunchAttempt,
+ request: StructuredLaunchRequest
+): Extract | undefined {
+ return attempt.kind === 'first' &&
+ attempt.request.text === request.text &&
+ attempt.request.draft === request.draft
+ ? attempt
+ : undefined
+}
+
+/** Only a new start's own create is joined, and only by a repeat of its request. Without `request`,
+ * any new start's own create counts. */
+export function joinsFirstLaunchAttempt(
+ attempt: StructuredLaunchAttempt,
+ request?: StructuredLaunchRequest
+): boolean {
+ return (
+ attempt.kind === 'first' &&
+ (!request || repeatedStructuredLaunchAttempt(attempt, request) !== undefined)
+ )
+}
diff --git a/src/renderer/src/lib/structured-agent-session-launch-status.ts b/src/renderer/src/lib/structured-agent-session-launch-status.ts
index cb5445c8cdc..7c69dd17d7e 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-status.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-status.ts
@@ -6,16 +6,20 @@ import {
subscribeStructuredAgentLaunchStatus,
type StructuredAgentLaunchStatus
} from './structured-agent-session-launch-registry'
+import type { StructuredLaunchRequest } from './structured-agent-session-launch-request'
+/** With `request`, only the launches a start of it would join: a different request opens its own. */
export function getStructuredAgentLaunchStatus(
worktreeId: string,
- agent: AgentSessionHandleProvider
+ agent: AgentSessionHandleProvider,
+ request?: StructuredLaunchRequest
): StructuredAgentLaunchStatus {
// Any launch holding an identity for this pair, adopted conversations included, is starting here.
// A failed chat is not, nor an unconfirmed or retried blank one: a new launch opens its own chat.
const identity = structuredLaunchIdentity(worktreeId, agent)
const states = structuredLaunchesHoldingIdentity(
- (candidate) => candidate === identity || candidate.startsWith(`${identity}:resume:`)
+ (candidate) => candidate === identity || candidate.startsWith(`${identity}:resume:`),
+ request
)
if (states.length === 0) {
return 'idle'
@@ -25,11 +29,12 @@ export function getStructuredAgentLaunchStatus(
export function useStructuredAgentLaunchStatus(
worktreeId: string,
- agent: AgentSessionHandleProvider
+ agent: AgentSessionHandleProvider,
+ request?: StructuredLaunchRequest
): StructuredAgentLaunchStatus {
return useSyncExternalStore(
subscribeStructuredAgentLaunchStatus,
- () => getStructuredAgentLaunchStatus(worktreeId, agent),
+ () => getStructuredAgentLaunchStatus(worktreeId, agent, request),
() => 'idle'
)
}
diff --git a/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts b/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts
index 95edf978637..d1f1a6abcbd 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts
@@ -291,12 +291,12 @@ describe('a new start beside an unconfirmed or retried chat', () => {
await expect(pick.launchResult).resolves.toEqual({ sessionId: fresh.sessionId, fence: 1 })
})
- it('still coalesces two new starts racing for one chat', async () => {
+ it('still coalesces a repeat of one request racing for one chat', async () => {
mocks.createIntent.mockReturnValueOnce(fresh)
mocks.launch.mockImplementationOnce(() => new Promise(() => undefined))
const first = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'one' })
- const second = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'two' })
+ const second = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { prompt: 'one' })
expect(second.sessionId).toBe(first.sessionId)
expect(mocks.createIntent).toHaveBeenCalledOnce()
diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts
index 84763f9e0e8..67e50982e35 100644
--- a/src/renderer/src/lib/structured-agent-session-launch.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts
@@ -425,7 +425,7 @@ describe('startStructuredAgentLaunch', () => {
expect(toast.error).not.toHaveBeenCalled()
})
- it('delivers a prompt from a coalesced caller after the shared launch settles', async () => {
+ it("delivers a repeated request's prompt once, to both callers, after the shared launch settles", async () => {
const worktreeId = 'wt-coalesced-prompt'
const intent = launchIntent(worktreeId)
let resolveLaunch: (receipt: { sessionId: string; fence: number }) => void = () => {}
@@ -442,16 +442,23 @@ describe('startStructuredAgentLaunch', () => {
ok: true,
value: { submission: { dispatchState: 'accepted' } }
})
- startStructuredAgentLaunch(worktreeId, 'codex')
+ const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
- await expect(second.promptDeliveryResult).resolves.toEqual({
- delivered: true,
- failureNotified: false
- })
+ for (const caller of [first, second]) {
+ await expect(caller.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ }
await flushLaunchSettlement()
+ expect(second.sessionId).toBe(first.sessionId)
+ expect(
+ mocks.callStructuredAgentSession.mock.calls.filter((call) => call[1] === 'agentSession.send')
+ ).toHaveLength(1)
+
expect(mocks.callStructuredAgentSession).toHaveBeenCalledWith(
{ kind: 'local' },
'agentSession.send',
@@ -480,12 +487,12 @@ describe('startStructuredAgentLaunch', () => {
() => new Promise((resolve) => (resolveDelivery = resolve))
)
- startStructuredAgentLaunch(worktreeId, 'codex')
+ startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
const coalesced = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
await vi.waitFor(() => expect(mocks.callStructuredAgentSession).toHaveBeenCalledOnce())
- startStructuredAgentLaunch(worktreeId, 'codex')
+ startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
expect(mocks.createIntent).toHaveBeenCalledOnce()
expect(mocks.launch).toHaveBeenCalledOnce()
@@ -763,7 +770,7 @@ describe('startStructuredAgentLaunch', () => {
storageFailure.mockRestore()
})
- it('reports every coalesced prompt as undelivered after refusal', async () => {
+ it("reports a repeated request's prompt as undelivered to both callers after refusal", async () => {
const worktreeId = 'wt-refused-coalesced-prompts'
const intent = launchIntent(worktreeId)
let rejectLaunch!: (error: unknown) => void
@@ -773,8 +780,8 @@ describe('startStructuredAgentLaunch', () => {
)
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
- const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
- expect(readOutbox(intent.sessionId)).toHaveLength(2)
+ const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'first prompt' })
+ expect(readOutbox(intent.sessionId)).toHaveLength(1)
rejectLaunch(new StructuredAgentSessionCreateRefusalError('unsupported'))
await expect(first.launchResult).rejects.toBeInstanceOf(
@@ -788,41 +795,44 @@ describe('startStructuredAgentLaunch', () => {
delivered: false,
failureNotified: true
})
- expect(readOutbox(intent.sessionId)).toHaveLength(2)
+ expect(readOutbox(intent.sessionId)).toHaveLength(1)
})
- it('delivers a coalesced caller the way the launch it joined already decided', async () => {
+ it('opens a new chat for the same text asked to be sent instead of drafted', async () => {
const worktreeId = 'wt-coalesced-delivery-mode'
- const intent = launchIntent(worktreeId, 'coalesced-delivery-session')
- let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
- mocks.createIntent.mockReturnValueOnce(intent)
- mocks.launch.mockImplementation(
- () =>
- new Promise<{ sessionId: string; fence: number }>((resolve) => (resolveLaunch = resolve))
+ const drafted = launchIntent(worktreeId, 'coalesced-delivery-session')
+ const sent = launchIntent(worktreeId, 'sent-delivery-session')
+ mocks.createIntent.mockReturnValueOnce(drafted).mockReturnValueOnce(sent)
+ mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) =>
+ Promise.resolve({ sessionId: intent.sessionId, fence: 1 })
)
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
- publishedSnapshot(worktreeId, intent.sessionId)
+ publishedSnapshot(worktreeId, drafted.sessionId),
+ publishedSnapshot(worktreeId, sent.sessionId)
])
+ mocks.callStructuredAgentSession.mockResolvedValue({
+ ok: true,
+ value: { submission: { dispatchState: 'accepted' } }
+ })
startStructuredAgentLaunch(worktreeId, 'codex', {
prompt: 'PR #1 context',
promptDelivery: 'draft'
})
- const joiner = startStructuredAgentLaunch(worktreeId, 'codex', {
+ const other = startStructuredAgentLaunch(worktreeId, 'codex', {
prompt: 'PR #1 context',
promptDelivery: 'auto-submit'
})
- resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
- await flushLaunchSettlement()
- // Why: the first caller's seed is already in the composer, so submitting the joiner's copy
- // would show the user the text AND send it.
- expect(readOutbox(intent.sessionId)).toEqual([])
- expect(joiner.promptDeliveryResult).toBeUndefined()
- expect(
- mocks.callStructuredAgentSession.mock.calls.some((call) => call[1] === 'agentSession.send')
- ).toBe(false)
- expect(mocks.seedDraft).toHaveBeenLastCalledWith(
+ // Why: a draft and a send are different requests; neither lands in the other's chat.
+ expect(other.sessionId).toBe(sent.sessionId)
+ await expect(other.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expect(readOutbox(drafted.sessionId)).toEqual([])
+ expect(mocks.seedDraft).toHaveBeenCalledOnce()
+ expect(mocks.seedDraft).toHaveBeenCalledWith(
expect.objectContaining({
tabId: 'structured-agent-session-coalesced-delivery-session',
text: 'PR #1 context'
diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts
index a5acbd512ed..52fdf8e70b4 100644
--- a/src/renderer/src/lib/structured-agent-session-launch.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch.ts
@@ -40,6 +40,10 @@ import {
import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload'
import { applyStructuredLaunchHeldOptions } from './structured-agent-session-launch-options'
import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking'
+import {
+ repeatedStructuredLaunchAttempt,
+ structuredLaunchRequest
+} from './structured-agent-session-launch-request'
export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt }
export {
@@ -124,7 +128,7 @@ function adoptPairedHostSeed(
}
function resetStructuredLaunchCallers(state: StructuredLaunchState): void {
- state.callers = createStructuredLaunchCallerGroup('retry')
+ state.callers = createStructuredLaunchCallerGroup({ kind: 'retry' })
state.callers.onSettled = () => maybeCleanupLaunchState(state)
}
@@ -154,19 +158,22 @@ function structuredAgentLaunchState(
options: StructuredAgentLaunchOptions
): StructuredLaunchStateResult {
const identity = structuredLaunchIdentity(worktreeId, agent, options.resumeFrom)
- const existing = getJoinableStructuredLaunchState(identity)
+ const request = structuredLaunchRequest(options)
+ const existing = getJoinableStructuredLaunchState(identity, request)
if (existing) {
+ // A repeat (a double click) shares the text the first click staged, so it is sent once.
+ const repeat = repeatedStructuredLaunchAttempt(existing.callers.attempt, request)
const retrying = existing.visibilityUnknown
if (retrying) {
restartStructuredLaunchState(existing)
}
const joined = joinLaunchDelivery(options, existing.promptDelivery)
// Why: an unconfirmed launch keeps its draft/outbox, so a recheck must not stage it twice.
- const text = retrying ? '' : outboxPromptText(joined)
+ const text = retrying || repeat ? '' : outboxPromptText(joined)
const stagedPrompt = text
? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text)
- : null
- if (!retrying) {
+ : (repeat?.stagedEntry ?? null)
+ if (!retrying && !repeat) {
launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined)
}
const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined
@@ -195,7 +202,11 @@ function structuredAgentLaunchState(
? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text)
: null
launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options)
- const callers = createStructuredLaunchCallerGroup('first')
+ const callers = createStructuredLaunchCallerGroup({
+ kind: 'first',
+ request,
+ stagedEntry: stagedPrompt
+ })
const state: StructuredLaunchState = {
identity,
intent,
From 9279746b167f3b7eb87a6f4e09074ba9cf5bd41e Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:40:43 -0700
Subject: [PATCH 06/16] fix(native-chat): an empty chat still starting takes
the first text sent to it
A blank chat that is still starting (a + pick, the empty-workspace default
chat) is empty, so the first request with text, such as notes sent to a new
agent, now goes into it instead of opening a second chat beside it. That
request becomes the chat's own request: an identical repeat joins and is sent
once, and any other request opens a new chat.
Move the logic that decides which launch a start joins out of the launch
registry into its own module, so the registry stays under the line limit
once #24904 lands beside it. Pin that a repeat arriving while the opening
text is still sending is sent once.
---
...t-session-launch-different-request.test.ts | 111 +++++++++++++-----
...structured-agent-session-launch-holders.ts | 69 +++++++++++
...tructured-agent-session-launch-registry.ts | 44 +------
.../structured-agent-session-launch-status.ts | 4 +-
.../structured-agent-session-launch.test.ts | 30 +++--
.../lib/structured-agent-session-launch.ts | 13 +-
6 files changed, 188 insertions(+), 83 deletions(-)
create mode 100644 src/renderer/src/lib/structured-agent-session-launch-holders.ts
diff --git a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
index 7a7ae7b3d3a..153908aa0bd 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
@@ -166,33 +166,6 @@ describe('a different new request while the first chat is still starting', () =>
])
})
- it('sends notes to a new chat while a blank chat from the + menu is starting', async () => {
- const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
- // The notes menu stays enabled: its pick would not join the blank chat.
- expect(
- getStructuredAgentLaunchStatus(
- WORKTREE_ID,
- 'codex',
- structuredLaunchRequest({ prompt: 'review notes' })
- )
- ).toBe('idle')
- expect(getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex', structuredLaunchRequest({}))).toBe(
- 'pending'
- )
-
- const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
- prompt: 'review notes',
- promptDelivery: 'submit-after-ready'
- })
-
- expect(notes.sessionId).not.toBe(blank.sessionId)
- await expect(notes.promptDeliveryResult).resolves.toEqual({
- delivered: true,
- failureNotified: false
- })
- expect(sends()).toEqual([[second.sessionId, 'review notes']])
- })
-
it('opens a new chat with its own text while the first chat is still sending its text', async () => {
let resolveFirstSend!: (result: unknown) => void
mocks.callStructuredAgentSession.mockImplementationOnce(
@@ -301,3 +274,87 @@ describe('the same request repeated while the first chat is still starting', ()
expect(mocks.seedDraft).toHaveBeenCalledOnce()
})
})
+
+describe('an empty chat still starting', () => {
+ let resolveFirstLaunch!: (receipt: { sessionId: string; fence: number }) => void
+ const third = launchIntent('session-third')
+ const notesRequest = { prompt: 'review notes', promptDelivery: 'submit-after-ready' } as const
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ localStorage.clear()
+ resetStructuredAgentLaunchPersistenceForTests()
+ resetStructuredAgentLaunchRegistryForTests()
+ mocks.createIntent
+ .mockReturnValueOnce(first)
+ .mockReturnValueOnce(second)
+ .mockReturnValueOnce(third)
+ mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) =>
+ intent.sessionId === first.sessionId
+ ? new Promise((resolve) => (resolveFirstLaunch = resolve))
+ : Promise.resolve({ sessionId: intent.sessionId, fence: 1 })
+ )
+ vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
+ publishedSnapshot(first.sessionId, second.sessionId, third.sessionId)
+ ])
+ mocks.callStructuredAgentSession.mockResolvedValue({
+ ok: true,
+ value: { submission: { dispatchState: 'accepted' } }
+ })
+ })
+
+ it('takes notes sent to a new agent instead of opening a second chat', async () => {
+ const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+ // The notes menu stays enabled: its pick fills the empty chat rather than repeating a start.
+ expect(
+ getStructuredAgentLaunchStatus(WORKTREE_ID, 'codex', structuredLaunchRequest(notesRequest))
+ ).toBe('idle')
+
+ const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
+ resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
+
+ expect(notes.sessionId).toBe(blank.sessionId)
+ expect(mocks.createIntent).toHaveBeenCalledOnce()
+ await expect(notes.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expect(sends()).toEqual([[first.sessionId, 'review notes']])
+ })
+
+ it('opens a new chat for any other request once its notes claimed it', async () => {
+ startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+ startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
+
+ const fix = startStructuredAgentLaunch(WORKTREE_ID, 'codex', {
+ prompt: 'Fix check B',
+ promptDelivery: 'submit-after-ready'
+ })
+ const pick = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+
+ expect(fix.sessionId).toBe(second.sessionId)
+ expect(pick.sessionId).toBe(third.sessionId)
+ await expect(fix.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expect(sends()).toEqual([[second.sessionId, 'Fix check B']])
+ })
+
+ it('sends the same notes once when they are sent again', async () => {
+ const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+ const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
+ const again = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
+ resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
+
+ expect(again.sessionId).toBe(blank.sessionId)
+ expect(mocks.createIntent).toHaveBeenCalledOnce()
+ for (const caller of [notes, again]) {
+ await expect(caller.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ }
+ expect(sends()).toEqual([[first.sessionId, 'review notes']])
+ })
+})
diff --git a/src/renderer/src/lib/structured-agent-session-launch-holders.ts b/src/renderer/src/lib/structured-agent-session-launch-holders.ts
new file mode 100644
index 00000000000..c2adc74842a
--- /dev/null
+++ b/src/renderer/src/lib/structured-agent-session-launch-holders.ts
@@ -0,0 +1,69 @@
+import {
+ launchStateLifecycle,
+ structuredLaunchStates,
+ type StructuredLaunchState
+} from './structured-agent-session-launch-registry'
+import {
+ joinsFirstLaunchAttempt,
+ type StructuredLaunchAttempt,
+ type StructuredLaunchRequest
+} from './structured-agent-session-launch-request'
+
+// Why: coalescing stops a repeat of one request (a double click) racing into two chats. A different
+// request, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new start
+// opens a new chat carrying its own text. A resume keeps holding: the host refuses a second adoption.
+function holdsLaunchIdentity(
+ state: StructuredLaunchState,
+ request?: StructuredLaunchRequest
+): boolean {
+ const lifecycle = launchStateLifecycle(state)
+ if (lifecycle === 'failed' || lifecycle === 'cancelled') {
+ return false
+ }
+ if (state.intent.params.resumeFrom) {
+ return true
+ }
+ return (
+ lifecycle !== 'visibility-unknown' && joinsFirstLaunchAttempt(state.callers.attempt, request)
+ )
+}
+
+/** Launches a start of `request` would repeat; without `request`, every new start's own create. */
+export function structuredLaunchesHoldingIdentity(
+ matches: (identity: string) => boolean,
+ request?: StructuredLaunchRequest
+): StructuredLaunchState[] {
+ return [...structuredLaunchStates()].filter(
+ (state) => matches(state.identity) && holdsLaunchIdentity(state, request)
+ )
+}
+
+/** An empty chat (a + pick, the empty-workspace default) still starting: the first request with text
+ * claims it once, and from then on it is that request's chat. A resume is never empty. */
+export function claimableStructuredLaunchAttempt(
+ state: StructuredLaunchState,
+ request: StructuredLaunchRequest
+): Extract | undefined {
+ const { attempt } = state.callers
+ return !state.intent.params.resumeFrom &&
+ attempt.kind === 'first' &&
+ attempt.request.text === '' &&
+ request.text !== ''
+ ? attempt
+ : undefined
+}
+
+/** The launch a new start of `request` joins: one it repeats, else an empty chat it claims. The
+ * newest wins if a retried resume holds the identity too. */
+export function getJoinableStructuredLaunchState(
+ identity: string,
+ request: StructuredLaunchRequest
+): StructuredLaunchState | undefined {
+ const matches = (candidate: string): boolean => candidate === identity
+ return (
+ structuredLaunchesHoldingIdentity(matches, request).at(-1) ??
+ structuredLaunchesHoldingIdentity(matches).findLast((state) =>
+ claimableStructuredLaunchAttempt(state, request)
+ )
+ )
+}
diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts
index 8c419ba4ce7..9d21852b2fb 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts
@@ -9,10 +9,6 @@ import type {
StructuredAgentLaunchOptions,
StructuredLaunchCallerGroup
} from './structured-agent-session-launch-callers'
-import {
- joinsFirstLaunchAttempt,
- type StructuredLaunchRequest
-} from './structured-agent-session-launch-request'
import {
deleteStructuredAgentLaunchRecord,
hasStructuredAgentLaunchCancellationTombstonePersisted,
@@ -82,42 +78,6 @@ export function structuredLaunchIdentity(
: `${agent}:${worktreeId}`
}
-// Why: coalescing stops a repeat of one request (a double click) racing into two chats. A different
-// request, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new start
-// opens a new chat carrying its own text. A resume keeps holding: the host refuses a second adoption.
-function holdsLaunchIdentity(
- state: StructuredLaunchState,
- request?: StructuredLaunchRequest
-): boolean {
- const lifecycle = launchStateLifecycle(state)
- if (lifecycle === 'failed' || lifecycle === 'cancelled') {
- return false
- }
- if (state.intent.params.resumeFrom) {
- return true
- }
- return (
- lifecycle !== 'visibility-unknown' && joinsFirstLaunchAttempt(state.callers.attempt, request)
- )
-}
-
-export function structuredLaunchesHoldingIdentity(
- matches: (identity: string) => boolean,
- request?: StructuredLaunchRequest
-): StructuredLaunchState[] {
- return [...structuredLaunchesBySessionId.values()].filter(
- (state) => matches(state.identity) && holdsLaunchIdentity(state, request)
- )
-}
-
-/** The launch a new start of `request` joins; the newest wins if a retried resume holds it too. */
-export function getJoinableStructuredLaunchState(
- identity: string,
- request: StructuredLaunchRequest
-): StructuredLaunchState | undefined {
- return structuredLaunchesHoldingIdentity((candidate) => candidate === identity, request).at(-1)
-}
-
export function getStructuredLaunchStateBySessionId(
sessionId: string
): StructuredLaunchState | undefined {
@@ -157,7 +117,9 @@ export function structuredLaunchStates(): IterableIterator {
const worktreeId = 'wt-coalesced-prompt-reservation'
const intent = launchIntent(worktreeId)
let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void
- let resolveDelivery!: (result: {
- ok: true
- value: { submission: { dispatchState: 'accepted' } }
- }) => void
+ const pendingSends: ((result: unknown) => void)[] = []
mocks.createIntent.mockReturnValue(intent)
mocks.launch.mockImplementationOnce(() => new Promise((resolve) => (resolveLaunch = resolve)))
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
- mocks.callStructuredAgentSession.mockImplementationOnce(
- () => new Promise((resolve) => (resolveDelivery = resolve))
+ // Every send waits, so a second send of the repeated text would show up below.
+ mocks.callStructuredAgentSession.mockImplementation(
+ () => new Promise((resolve) => pendingSends.push(resolve))
)
startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
@@ -492,15 +490,23 @@ describe('startStructuredAgentLaunch', () => {
resolveLaunch({ sessionId: intent.sessionId, fence: 1 })
await vi.waitFor(() => expect(mocks.callStructuredAgentSession).toHaveBeenCalledOnce())
- startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'second prompt' })
+ const whileSending = startStructuredAgentLaunch(worktreeId, 'codex', {
+ prompt: 'second prompt'
+ })
expect(mocks.createIntent).toHaveBeenCalledOnce()
expect(mocks.launch).toHaveBeenCalledOnce()
- resolveDelivery({ ok: true, value: { submission: { dispatchState: 'accepted' } } })
- await expect(coalesced.promptDeliveryResult).resolves.toEqual({
- delivered: true,
- failureNotified: false
- })
+ await flushLaunchSettlement()
+ for (const resolve of pendingSends) {
+ resolve({ ok: true, value: { submission: { dispatchState: 'accepted' } } })
+ }
+ for (const caller of [coalesced, whileSending]) {
+ await expect(caller.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ }
+ expect(mocks.callStructuredAgentSession).toHaveBeenCalledOnce()
})
it('opens a new chat for a new start while an earlier outcome is unknown', async () => {
diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts
index 52fdf8e70b4..04653b5e70e 100644
--- a/src/renderer/src/lib/structured-agent-session-launch.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch.ts
@@ -28,7 +28,6 @@ import {
import * as launchDraft from './structured-agent-session-launch-draft'
import {
deleteStructuredLaunchStateIfCurrent,
- getJoinableStructuredLaunchState,
getStructuredAgentSessionLaunchLifecycle,
getStructuredLaunchStateBySessionId,
markStructuredAgentSessionLaunchCancelled,
@@ -38,6 +37,10 @@ import {
type StructuredLaunchState
} from './structured-agent-session-launch-registry'
import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload'
+import {
+ claimableStructuredLaunchAttempt,
+ getJoinableStructuredLaunchState
+} from './structured-agent-session-launch-holders'
import { applyStructuredLaunchHeldOptions } from './structured-agent-session-launch-options'
import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking'
import {
@@ -163,6 +166,11 @@ function structuredAgentLaunchState(
if (existing) {
// A repeat (a double click) shares the text the first click staged, so it is sent once.
const repeat = repeatedStructuredLaunchAttempt(existing.callers.attempt, request)
+ // An empty chat takes the first text sent to it, delivered the way that request asked.
+ const claim = claimableStructuredLaunchAttempt(existing, request)
+ if (claim) {
+ existing.promptDelivery = options.promptDelivery
+ }
const retrying = existing.visibilityUnknown
if (retrying) {
restartStructuredLaunchState(existing)
@@ -176,6 +184,9 @@ function structuredAgentLaunchState(
if (!retrying && !repeat) {
launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined)
}
+ if (claim) {
+ Object.assign(claim, { request, stagedEntry: stagedPrompt })
+ }
const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined
const callerOptions = retrying ? joinedWithoutPrompt : joined
return {
From b04f371b3520179691c3dc299e801b459c9e39c8 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:41:24 -0700
Subject: [PATCH 07/16] test: an empty chat delivers the claiming text the way
its request asked
---
...d-agent-session-launch-different-request.test.ts | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
index 153908aa0bd..e466f779f77 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
@@ -341,6 +341,19 @@ describe('an empty chat still starting', () => {
expect(sends()).toEqual([[second.sessionId, 'Fix check B']])
})
+ it('delivers the claiming text the way its own request asked', async () => {
+ startStructuredAgentLaunch(WORKTREE_ID, 'codex', { promptDelivery: 'draft' })
+ const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
+ resolveFirstLaunch({ sessionId: first.sessionId, fence: 1 })
+
+ await expect(notes.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expect(sends()).toEqual([[first.sessionId, 'review notes']])
+ expect(mocks.seedDraft).not.toHaveBeenCalled()
+ })
+
it('sends the same notes once when they are sent again', async () => {
const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
From 4b72d035deb60947fd4e9fdbb3897265905ca33e Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 4 Oct 2026 00:55:35 -0700
Subject: [PATCH 08/16] fix(native-chat): only a chat its user has not used yet
takes another request's text
A blank chat that is still starting was claimable by the first request with
text even after its user had sent a message into it or typed into its
composer, so notes or a Fix with AI prompt could land in a conversation the
user had already started. Emptiness is now read from what the chat holds:
nothing in its outbox and no text in its composer draft. A chat its user has
used stays theirs, and the request opens a new chat.
If the claiming text cannot be saved, the claim is not recorded: the request
falls through to a new launch, whose save failure shows on that chat as for
any new launch, instead of reporting a failure nothing showed.
---
...t-session-launch-different-request.test.ts | 77 ++++++++++++++++
...uctured-agent-session-launch-empty-chat.ts | 16 ++++
...structured-agent-session-launch-holders.ts | 7 +-
.../lib/structured-agent-session-launch.ts | 90 +++++++++++--------
4 files changed, 151 insertions(+), 39 deletions(-)
create mode 100644 src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts
diff --git a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
index e466f779f77..7dee1159dd3 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-different-request.test.ts
@@ -58,8 +58,23 @@ vi.mock('@/lib/agent-catalog', () => ({
}))
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
+import {
+ appendStructuredAgentSessionOutboxMessage,
+ readOutbox
+} from '@/components/native-chat/structured-agent-session-outbox-storage'
+import {
+ clearNativeChatDraftCacheForTests,
+ readNativeChatDraftCache,
+ writeNativeChatDraftCache
+} from '@/components/native-chat/native-chat-draft-cache'
+import {
+ structuredAgentSessionPaneKey,
+ structuredAgentSessionTabId
+} from '../../../shared/structured-agent-session-projection'
+import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
import {
getStructuredAgentLaunchStatus,
+ getStructuredAgentSessionLaunchLifecycle,
startStructuredAgentLaunch
} from './structured-agent-session-launch'
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
@@ -289,6 +304,7 @@ describe('an empty chat still starting', () => {
.mockReturnValueOnce(first)
.mockReturnValueOnce(second)
.mockReturnValueOnce(third)
+ clearNativeChatDraftCacheForTests()
mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) =>
intent.sessionId === first.sessionId
? new Promise((resolve) => (resolveFirstLaunch = resolve))
@@ -370,4 +386,65 @@ describe('an empty chat still starting', () => {
}
expect(sends()).toEqual([[first.sessionId, 'review notes']])
})
+
+ it('leaves a chat its user already sent into to them, and opens a new chat for the notes', async () => {
+ const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+ appendStructuredAgentSessionOutboxMessage(blank.sessionId, 'my own question')
+
+ const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
+
+ expect(notes.sessionId).toBe(second.sessionId)
+ await expect(notes.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expect(sends()).toEqual([[second.sessionId, 'review notes']])
+ expect(readOutbox(blank.sessionId).map((entry) => entry.body.blocks)).toEqual([
+ [{ type: 'text', text: 'my own question' }]
+ ])
+ })
+
+ it('leaves a chat its user is typing into to them, and opens a new chat for the notes', async () => {
+ const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+ const paneKey = structuredAgentSessionPaneKey(
+ structuredAgentSessionTabId(blank.sessionId),
+ blank.sessionId
+ )
+ writeNativeChatDraftCache(paneKey, 'half a question')
+
+ const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
+
+ expect(notes.sessionId).toBe(second.sessionId)
+ await expect(notes.promptDeliveryResult).resolves.toEqual({
+ delivered: true,
+ failureNotified: false
+ })
+ expect(readOutbox(blank.sessionId)).toEqual([])
+ expect(readNativeChatDraftCache(paneKey)).toBe('half a question')
+ })
+
+ it('opens a new chat that shows the failure when the claiming text cannot be saved', async () => {
+ const blank = startStructuredAgentLaunch(WORKTREE_ID, 'codex')
+ const storageFailure = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
+ throw new Error('storage unavailable')
+ })
+ const notes = startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest)
+ storageFailure.mockRestore()
+
+ // The new chat fails with its Retry line, the failure the caller is told was shown.
+ expect(notes.sessionId).toBe(second.sessionId)
+ await expect(notes.launchResult).rejects.toBeInstanceOf(
+ StructuredAgentSessionCreateRefusalError
+ )
+ await expect(notes.promptDeliveryResult).resolves.toEqual({
+ delivered: false,
+ failureNotified: true
+ })
+ expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, second.sessionId)).toBe('failed')
+ // No claim was recorded: the blank chat is still blank and still claimable.
+ expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex').sessionId).toBe(blank.sessionId)
+ expect(startStructuredAgentLaunch(WORKTREE_ID, 'codex', notesRequest).sessionId).toBe(
+ blank.sessionId
+ )
+ })
})
diff --git a/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts b/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts
new file mode 100644
index 00000000000..e8c5becd685
--- /dev/null
+++ b/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts
@@ -0,0 +1,16 @@
+import {
+ structuredAgentSessionPaneKey,
+ structuredAgentSessionTabId
+} from '../../../shared/structured-agent-session-projection'
+import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
+import { readNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache'
+
+/** A starting chat is empty until its user sends into it or types in its composer; after that it
+ * is theirs, and another request's text never goes into it. */
+export function isStructuredLaunchChatEmpty(sessionId: string): boolean {
+ const paneKey = structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId)
+ return (
+ getStructuredAgentSessionOutbox(sessionId).length === 0 &&
+ readNativeChatDraftCache(paneKey).trim() === ''
+ )
+}
diff --git a/src/renderer/src/lib/structured-agent-session-launch-holders.ts b/src/renderer/src/lib/structured-agent-session-launch-holders.ts
index c2adc74842a..68b0c1728f6 100644
--- a/src/renderer/src/lib/structured-agent-session-launch-holders.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch-holders.ts
@@ -8,6 +8,7 @@ import {
type StructuredLaunchAttempt,
type StructuredLaunchRequest
} from './structured-agent-session-launch-request'
+import { isStructuredLaunchChatEmpty } from './structured-agent-session-launch-empty-chat'
// Why: coalescing stops a repeat of one request (a double click) racing into two chats. A different
// request, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new start
@@ -39,7 +40,8 @@ export function structuredLaunchesHoldingIdentity(
}
/** An empty chat (a + pick, the empty-workspace default) still starting: the first request with text
- * claims it once, and from then on it is that request's chat. A resume is never empty. */
+ * claims it once, and from then on it is that request's chat. A resume is never empty, nor a chat
+ * its user has already sent or typed into. */
export function claimableStructuredLaunchAttempt(
state: StructuredLaunchState,
request: StructuredLaunchRequest
@@ -48,7 +50,8 @@ export function claimableStructuredLaunchAttempt(
return !state.intent.params.resumeFrom &&
attempt.kind === 'first' &&
attempt.request.text === '' &&
- request.text !== ''
+ request.text !== '' &&
+ isStructuredLaunchChatEmpty(state.intent.sessionId)
? attempt
: undefined
}
diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts
index 04653b5e70e..d2a174110c6 100644
--- a/src/renderer/src/lib/structured-agent-session-launch.ts
+++ b/src/renderer/src/lib/structured-agent-session-launch.ts
@@ -45,7 +45,8 @@ import { applyStructuredLaunchHeldOptions } from './structured-agent-session-lau
import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking'
import {
repeatedStructuredLaunchAttempt,
- structuredLaunchRequest
+ structuredLaunchRequest,
+ type StructuredLaunchRequest
} from './structured-agent-session-launch-request'
export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt }
@@ -155,6 +156,54 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void {
notifyStructuredLaunchListeners()
}
+function joinStructuredLaunchState(
+ existing: StructuredLaunchState,
+ agent: AgentSessionHandleProvider,
+ options: StructuredAgentLaunchOptions,
+ request: StructuredLaunchRequest
+): StructuredLaunchStateResult | undefined {
+ // A repeat (a double click) shares the text the first click staged, so it is sent once.
+ const repeat = repeatedStructuredLaunchAttempt(existing.callers.attempt, request)
+ // An empty chat takes the first text sent to it, delivered the way that request asked.
+ const claim = claimableStructuredLaunchAttempt(existing, request)
+ const retrying = existing.visibilityUnknown
+ const joined = joinLaunchDelivery(
+ options,
+ claim ? options.promptDelivery : existing.promptDelivery
+ )
+ // Why: an unconfirmed launch keeps its draft/outbox, so a recheck must not stage it twice.
+ const text = retrying || repeat ? '' : outboxPromptText(joined)
+ const stagedPrompt = text
+ ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text)
+ : (repeat?.stagedEntry ?? null)
+ // An unstaged claim stays unclaimed: the new launch it falls to reports the failure.
+ if (claim && text && !stagedPrompt) {
+ return undefined
+ }
+ if (retrying) {
+ restartStructuredLaunchState(existing)
+ }
+ if (claim) {
+ existing.promptDelivery = options.promptDelivery
+ Object.assign(claim, { request, stagedEntry: stagedPrompt })
+ }
+ if (!retrying && !repeat) {
+ launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined)
+ }
+ const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined
+ const callerOptions = retrying ? joinedWithoutPrompt : joined
+ return {
+ state: existing,
+ caller: addStructuredLaunchCaller({
+ group: existing.callers,
+ launchResult: existing.promise,
+ target: existing.intent.target,
+ options: callerOptions,
+ stagedEntry: stagedPrompt
+ })
+ }
+}
+
function structuredAgentLaunchState(
worktreeId: string,
agent: AgentSessionHandleProvider,
@@ -163,42 +212,9 @@ function structuredAgentLaunchState(
const identity = structuredLaunchIdentity(worktreeId, agent, options.resumeFrom)
const request = structuredLaunchRequest(options)
const existing = getJoinableStructuredLaunchState(identity, request)
- if (existing) {
- // A repeat (a double click) shares the text the first click staged, so it is sent once.
- const repeat = repeatedStructuredLaunchAttempt(existing.callers.attempt, request)
- // An empty chat takes the first text sent to it, delivered the way that request asked.
- const claim = claimableStructuredLaunchAttempt(existing, request)
- if (claim) {
- existing.promptDelivery = options.promptDelivery
- }
- const retrying = existing.visibilityUnknown
- if (retrying) {
- restartStructuredLaunchState(existing)
- }
- const joined = joinLaunchDelivery(options, existing.promptDelivery)
- // Why: an unconfirmed launch keeps its draft/outbox, so a recheck must not stage it twice.
- const text = retrying || repeat ? '' : outboxPromptText(joined)
- const stagedPrompt = text
- ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text)
- : (repeat?.stagedEntry ?? null)
- if (!retrying && !repeat) {
- launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined)
- }
- if (claim) {
- Object.assign(claim, { request, stagedEntry: stagedPrompt })
- }
- const { prompt: _retryPrompt, ...joinedWithoutPrompt } = joined
- const callerOptions = retrying ? joinedWithoutPrompt : joined
- return {
- state: existing,
- caller: addStructuredLaunchCaller({
- group: existing.callers,
- launchResult: existing.promise,
- target: existing.intent.target,
- options: callerOptions,
- stagedEntry: stagedPrompt
- })
- }
+ const joined = existing && joinStructuredLaunchState(existing, agent, options, request)
+ if (joined) {
+ return joined
}
const intent = createStructuredAgentSessionLaunchIntent(
From 0fd16b7d938bbfe68e14ae3b1307b3cd9adabe7e Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 4 Oct 2026 01:30:19 -0700
Subject: [PATCH 09/16] fix(notes): notes handed to a send leave the next send
until it settles
Notes sent to an agent stayed in the notes shelf until their chat delivered
them, so a second "Send notes" made while the first new chat was still
starting collected the first notes again. With every different request now
opening its own chat, those notes reached two chats.
When notes or browser annotations are handed to a send (a new agent, a
running agent from the menu, or a sidebar agent row), they are held in
memory against that send's own delivery result and left out of the next
send. Delivered notes are removed as before; a failed, refused or
undelivered send releases the hold, so they come back for the next send.
The notes menu now builds each scope's prompt from the notes it will send.
---
.../BrowserAnnotationSendMenuContent.tsx | 5 +-
.../browser-guest-annotate-overlays.tsx | 2 +
.../annotate/browser-page-annotation-tray.tsx | 3 +
.../use-browser-page-annotation-send.test.tsx | 53 ++++++++
.../use-browser-page-annotation-send.ts | 38 +++++-
.../browser-page-chrome-banners.tsx | 3 +
.../browser-page-chrome-header.tsx | 1 +
.../diff-comments/diff-comment-zone-card.tsx | 3 +-
.../components/editor/DiffNotesSendMenu.tsx | 8 +-
.../MarkdownPreviewAnnotationComposer.tsx | 2 +-
.../components/editor/NotesSendMenu.test.tsx | 120 ++++++++++++++++--
.../src/components/editor/NotesSendMenu.tsx | 42 +++++-
.../ReviewNotesSendMenuContent.test.tsx | 13 +-
.../editor/ReviewNotesSendMenuContent.tsx | 11 +-
.../editor/RichMarkdownReviewNoteLayer.tsx | 6 +-
.../editor/use-markdown-preview-foundation.ts | 8 +-
.../editor/useRichMarkdownReviewData.ts | 2 +-
.../QuickLaunchButton.launch-status.test.tsx | 40 +++++-
.../components/tab-bar/QuickLaunchButton.tsx | 19 ++-
src/renderer/src/lib/notes-send-in-flight.ts | 73 +++++++++++
.../store/slices/ui-agent-send-target.test.ts | 8 +-
.../store/slices/ui/ui-slice-agent-actions.ts | 4 +-
.../store/slices/ui/ui-slice-contract-core.ts | 3 +
23 files changed, 411 insertions(+), 56 deletions(-)
create mode 100644 src/renderer/src/lib/notes-send-in-flight.ts
diff --git a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx
index e19a0a2e2ec..faceaab0f0b 100644
--- a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx
+++ b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx
@@ -6,13 +6,15 @@ export type BrowserAnnotationSendMenuContentProps = {
groupId: string
prompt: string
onPromptDelivered?: () => void
+ onPromptHandedOff?: (delivered: Promise) => void
}
export function BrowserAnnotationSendMenuContent({
worktreeId,
groupId,
prompt,
- onPromptDelivered
+ onPromptDelivered,
+ onPromptHandedOff
}: BrowserAnnotationSendMenuContentProps): React.JSX.Element {
return (
)
}
diff --git a/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx b/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx
index 200670cd536..c8141bd0685 100644
--- a/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx
+++ b/src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx
@@ -75,6 +75,7 @@ export function BrowserGuestAnnotateOverlays({
activeGroupId,
browserAnnotationsPrompt,
handleBrowserAnnotationsSentToAgent,
+ handleBrowserAnnotationsHandedOff,
handleCopyBrowserAnnotations,
browserAnnotationsCopied,
handleClearBrowserAnnotations,
@@ -119,6 +120,7 @@ export function BrowserGuestAnnotateOverlays({
activeGroupId={activeGroupId}
browserAnnotationsPrompt={browserAnnotationsPrompt}
handleBrowserAnnotationsSentToAgent={handleBrowserAnnotationsSentToAgent}
+ handleBrowserAnnotationsHandedOff={handleBrowserAnnotationsHandedOff}
handleCopyBrowserAnnotations={handleCopyBrowserAnnotations}
browserAnnotationsCopied={browserAnnotationsCopied}
handleClearBrowserAnnotations={handleClearBrowserAnnotations}
diff --git a/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx b/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx
index a22f18b06d5..06e35269429 100644
--- a/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx
+++ b/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx
@@ -29,6 +29,7 @@ export function BrowserPageAnnotationTray({
activeGroupId,
browserAnnotationsPrompt,
handleBrowserAnnotationsSentToAgent,
+ handleBrowserAnnotationsHandedOff,
handleCopyBrowserAnnotations,
browserAnnotationsCopied,
handleClearBrowserAnnotations,
@@ -43,6 +44,7 @@ export function BrowserPageAnnotationTray({
activeGroupId: string | undefined
browserAnnotationsPrompt: string
handleBrowserAnnotationsSentToAgent: () => void
+ handleBrowserAnnotationsHandedOff: (delivered: Promise) => void
handleCopyBrowserAnnotations: () => void
browserAnnotationsCopied: boolean
handleClearBrowserAnnotations: () => void
@@ -135,6 +137,7 @@ export function BrowserPageAnnotationTray({
groupId={activeGroupId ?? worktreeId}
prompt={browserAnnotationsPrompt}
onPromptDelivered={handleBrowserAnnotationsSentToAgent}
+ onPromptHandedOff={handleBrowserAnnotationsHandedOff}
/>
diff --git a/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx b/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx
index 9c7ca72e954..52bd02ba05a 100644
--- a/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx
+++ b/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx
@@ -8,6 +8,7 @@ import { createTestStore } from '@/store/slices/browser-slice-test-harness'
import type { OpenAgentSendPopoverTargetModeArgs } from '@/store/slices/ui'
import { BrowserPageAnnotationTray } from './browser-page-annotation-tray'
import { useBrowserPageAnnotationSend } from './use-browser-page-annotation-send'
+import { resetNotesInFlightForTests } from '@/lib/notes-send-in-flight'
const state = vi.hoisted((): { store?: ReturnType } => ({}))
vi.mock('@/store', () => ({
@@ -93,6 +94,7 @@ beforeEach(() => {
store = createTestStore()
state.store = store
mode = undefined
+ resetNotesInFlightForTests()
store.setState({
activeGroupIdByWorktree: {},
openAgentSendPopoverTargetMode: (next) => {
@@ -214,3 +216,54 @@ describe('website annotation delivery', () => {
expect(notes()).toEqual([])
})
})
+
+describe('website annotations handed to a send', () => {
+ const second = (): BrowserPageAnnotation => ({
+ ...makeAnnotation('page-1', 'second'),
+ comment: 'Second note'
+ })
+
+ it('sends only an annotation added while an earlier send is still on its way', async () => {
+ const view = mount()
+ const firstDelivered = view.result.current.handleBrowserAnnotationsSentToAgent
+ let deliverFirst!: (result: { delivered: boolean }) => void
+ act(() =>
+ view.result.current.handleBrowserAnnotationsHandedOff(
+ new Promise((resolve) => (deliverFirst = resolve))
+ )
+ )
+ expect(view.result.current.browserAnnotationsPrompt).toBe('')
+
+ act(() => store.getState().addBrowserPageAnnotation(second()))
+ expect(view.result.current.browserAnnotationsPrompt).toContain('Second note')
+ expect(view.result.current.browserAnnotationsPrompt).not.toContain('Fix this button')
+ const secondDelivered = view.result.current.handleBrowserAnnotationsSentToAgent
+
+ act(firstDelivered)
+ await act(async () => deliverFirst({ delivered: true }))
+ expect(notes().map((note) => note.id)).toEqual(['second'])
+ act(secondDelivered)
+ expect(notes()).toEqual([])
+ })
+
+ it('puts annotations back for the next send when their delivery fails', async () => {
+ const view = mount()
+ const delivered = Promise.resolve({ delivered: false, failureNotified: true })
+ act(() => view.result.current.handleBrowserAnnotationsHandedOff(delivered))
+ expect(view.result.current.browserAnnotationsPrompt).toBe('')
+
+ await act(async () => {
+ await delivered
+ })
+
+ expect(view.result.current.browserAnnotationsPrompt).toContain('Fix this button')
+ expect(notes()).toHaveLength(1)
+ })
+
+ it('holds what a running-agent send carries too', () => {
+ const view = mount()
+ act(() => view.result.current.handleAnnotationTraySendOpenChange(true))
+ act(() => mode?.onPromptHandedOff?.(new Promise(() => undefined)))
+ expect(view.result.current.browserAnnotationsPrompt).toBe('')
+ })
+})
diff --git a/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.ts b/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.ts
index b770302715f..5480c030bfe 100644
--- a/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.ts
+++ b/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.ts
@@ -16,6 +16,11 @@ import type {
} from '../../../../../shared/browser-grab-types'
import { formatBrowserAnnotationsAsMarkdown } from './browser-annotation-output'
import { EMPTY_BROWSER_ANNOTATIONS } from '../describe-page/browser-annotation-geometry'
+import {
+ holdNotesForSend,
+ isNoteInFlight,
+ useNotesInFlightVersion
+} from '@/lib/notes-send-in-flight'
export function useBrowserPageAnnotationSend({
browserTabId,
@@ -42,6 +47,7 @@ export function useBrowserPageAnnotationSend({
intent: BrowserAnnotationIntent
) => void
handleBrowserAnnotationsSentToAgent: () => void
+ handleBrowserAnnotationsHandedOff: (delivered: Promise) => void
activeGroupId: string | undefined
} {
const browserAnnotations = useAppStore(
@@ -52,10 +58,20 @@ export function useBrowserPageAnnotationSend({
const [browserAnnotationTrayOpen, setBrowserAnnotationTrayOpen] = useState(true)
const [browserAnnotationsCopied, setBrowserAnnotationsCopied] = useState(false)
const annotationCopyTimerRef = useRef>(undefined)
- const browserAnnotationsPrompt = useMemo(
+ const copyPrompt = useMemo(
() => formatBrowserAnnotationsAsMarkdown(browserAnnotations),
[browserAnnotations]
)
+ // Annotations another send holds are left out of the next one.
+ const inFlightVersion = useNotesInFlightVersion()
+ const sendableAnnotations = useMemo(() => {
+ void inFlightVersion
+ return browserAnnotations.filter((annotation) => !isNoteInFlight(annotation))
+ }, [browserAnnotations, inFlightVersion])
+ const browserAnnotationsPrompt = useMemo(
+ () => formatBrowserAnnotationsAsMarkdown(sendableAnnotations),
+ [sendableAnnotations]
+ )
const openAgentSendPopoverTargetMode = useAppStore((s) => s.openAgentSendPopoverTargetMode)
const closeAgentSendPopoverTargetMode = useAppStore((s) => s.closeAgentSendPopoverTargetMode)
const activeAgentSendTargetModeId = useAppStore((s) => s.agentSendPopoverTargetMode?.id ?? null)
@@ -82,26 +98,31 @@ export function useBrowserPageAnnotationSend({
}, [])
const handleCopyBrowserAnnotations = useCallback((): void => {
- if (!browserAnnotationsPrompt) {
+ if (!copyPrompt) {
return
}
- void window.api.ui.writeClipboardText(browserAnnotationsPrompt)
+ void window.api.ui.writeClipboardText(copyPrompt)
recordFeatureInteraction('browser-annotations')
clearTimeout(annotationCopyTimerRef.current)
setBrowserAnnotationsCopied(true)
annotationCopyTimerRef.current = setTimeout(() => setBrowserAnnotationsCopied(false), 1400)
- }, [browserAnnotationsPrompt, recordFeatureInteraction])
+ }, [copyPrompt, recordFeatureInteraction])
const handleBrowserAnnotationsSentToAgent = useCallback((): void => {
recordFeatureInteraction('browser-annotations-sent-to-agent')
- removeDeliveredBrowserPageAnnotations(browserTabId, browserAnnotations)
+ removeDeliveredBrowserPageAnnotations(browserTabId, sendableAnnotations)
}, [
- browserAnnotations,
+ sendableAnnotations,
browserTabId,
recordFeatureInteraction,
removeDeliveredBrowserPageAnnotations
])
+ const handleBrowserAnnotationsHandedOff = useCallback(
+ (delivered: Promise): void => holdNotesForSend(sendableAnnotations, delivered),
+ [sendableAnnotations]
+ )
+
const handleClearBrowserAnnotations = useCallback((): void => {
if (browserAnnotationsRef.current.length === 0) {
return
@@ -125,7 +146,8 @@ export function useBrowserPageAnnotationSend({
'Browser annotations'
),
launchSource: 'notes_send',
- onPromptDelivered: handleBrowserAnnotationsSentToAgent
+ onPromptDelivered: handleBrowserAnnotationsSentToAgent,
+ onPromptHandedOff: handleBrowserAnnotationsHandedOff
})
} else {
closeAgentSendPopoverTargetMode(modeId)
@@ -134,6 +156,7 @@ export function useBrowserPageAnnotationSend({
[
browserAnnotationsPrompt,
handleBrowserAnnotationsSentToAgent,
+ handleBrowserAnnotationsHandedOff,
closeAgentSendPopoverTargetMode,
openAgentSendPopoverTargetMode,
worktreeId
@@ -199,6 +222,7 @@ export function useBrowserPageAnnotationSend({
handleDeleteBrowserAnnotation,
handleUpdateBrowserAnnotation,
handleBrowserAnnotationsSentToAgent,
+ handleBrowserAnnotationsHandedOff,
activeGroupId
}
}
diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-banners.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-banners.tsx
index 48dc0a6afe0..90fbc4464b4 100644
--- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-banners.tsx
+++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-banners.tsx
@@ -28,6 +28,7 @@ export function BrowserPageChromeBanners({
activeGroupId,
browserAnnotationsPrompt,
handleBrowserAnnotationsSentToAgent,
+ handleBrowserAnnotationsHandedOff,
handleCopyBrowserAnnotations,
browserAnnotationsCopied,
handleClearBrowserAnnotations,
@@ -45,6 +46,7 @@ export function BrowserPageChromeBanners({
activeGroupId: string | undefined
browserAnnotationsPrompt: string
handleBrowserAnnotationsSentToAgent: () => void
+ handleBrowserAnnotationsHandedOff: (delivered: Promise) => void
handleCopyBrowserAnnotations: () => void
browserAnnotationsCopied: boolean
handleClearBrowserAnnotations: () => void
@@ -155,6 +157,7 @@ export function BrowserPageChromeBanners({
groupId={activeGroupId ?? worktreeId}
prompt={browserAnnotationsPrompt}
onPromptDelivered={handleBrowserAnnotationsSentToAgent}
+ onPromptHandedOff={handleBrowserAnnotationsHandedOff}
/>
diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx
index 8d5b5b68ade..b162a2ba34b 100644
--- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx
+++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx
@@ -121,6 +121,7 @@ export function BrowserPageChromeHeader({
activeGroupId={annotationSend.activeGroupId}
browserAnnotationsPrompt={annotationSend.browserAnnotationsPrompt}
handleBrowserAnnotationsSentToAgent={annotationSend.handleBrowserAnnotationsSentToAgent}
+ handleBrowserAnnotationsHandedOff={annotationSend.handleBrowserAnnotationsHandedOff}
handleCopyBrowserAnnotations={annotationSend.handleCopyBrowserAnnotations}
browserAnnotationsCopied={annotationSend.browserAnnotationsCopied}
handleClearBrowserAnnotations={annotationSend.handleClearBrowserAnnotations}
diff --git a/src/renderer/src/components/diff-comments/diff-comment-zone-card.tsx b/src/renderer/src/components/diff-comments/diff-comment-zone-card.tsx
index d34de51a2bc..5eb6d312971 100644
--- a/src/renderer/src/components/diff-comments/diff-comment-zone-card.tsx
+++ b/src/renderer/src/components/diff-comments/diff-comment-zone-card.tsx
@@ -39,7 +39,8 @@ function getSingleCommentSendScopes(
'This note'
),
notes: comment.sentAt ? [] : [comment],
- prompt: formatCommentPrompt ? formatCommentPrompt(comment) : formatDiffComments([comment])
+ formatPrompt: () =>
+ formatCommentPrompt ? formatCommentPrompt(comment) : formatDiffComments([comment])
}
]
}
diff --git a/src/renderer/src/components/editor/DiffNotesSendMenu.tsx b/src/renderer/src/components/editor/DiffNotesSendMenu.tsx
index d5ce2434449..08a0e07440a 100644
--- a/src/renderer/src/components/editor/DiffNotesSendMenu.tsx
+++ b/src/renderer/src/components/editor/DiffNotesSendMenu.tsx
@@ -54,20 +54,18 @@ export function DiffNotesSendMenu({
[consumeOpenRequest, worktreeId]
)
const unsentNotes = useMemo(() => comments.filter((comment) => !comment.sentAt), [comments])
- const unsentPrompt = useMemo(() => formatDiffComments(unsentNotes), [unsentNotes])
const fileNotes = useMemo(
() => (filePath ? comments.filter((comment) => comment.filePath === filePath) : []),
[comments, filePath]
)
const unsentFileNotes = useMemo(() => fileNotes.filter((comment) => !comment.sentAt), [fileNotes])
- const unsentFilePrompt = useMemo(() => formatDiffComments(unsentFileNotes), [unsentFileNotes])
const canSendFileScope = showFileScope && Boolean(filePath)
const scopes = useMemo[]>(() => {
const allNotesScope = {
id: 'all',
label: translate('auto.components.editor.DiffNotesSendMenu.8b87612461', 'All unsent notes'),
notes: unsentNotes,
- prompt: unsentPrompt
+ formatPrompt: formatDiffComments
}
if (!canSendFileScope) {
return [allNotesScope]
@@ -77,11 +75,11 @@ export function DiffNotesSendMenu({
id: 'file',
label: translate('auto.components.editor.DiffNotesSendMenu.f1aa04b5cf', 'This file'),
notes: unsentFileNotes,
- prompt: unsentFilePrompt
+ formatPrompt: formatDiffComments
},
allNotesScope
]
- }, [canSendFileScope, unsentFileNotes, unsentFilePrompt, unsentNotes, unsentPrompt])
+ }, [canSendFileScope, unsentFileNotes, unsentNotes])
return (
formatMarkdownReviewNotes(notes, content)
}
]}
targetModeLabel="This note"
diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx
index 64556e6b83d..7ce7fe77b85 100644
--- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx
+++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx
@@ -1,14 +1,18 @@
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildNotesSendTargetModeId, NotesSendMenu } from './NotesSendMenu'
+import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments'
+import { resetNotesInFlightForTests } from '@/lib/notes-send-in-flight'
type ReactElementLike = {
type: unknown
props: Record
}
-type TestNote = {
- id: string
+type TestNote = DiffCommentDeliverySnapshot
+
+function note(id: string): TestNote {
+ return { id, body: `body of ${id}`, filePath: 'README.md', lineNumber: 1 }
}
const hookRuntime = vi.hoisted(() => ({
@@ -41,6 +45,9 @@ vi.mock('react', async () => {
useMemo(factory: () => T): T {
return factory()
},
+ useSyncExternalStore(_subscribe: unknown, getSnapshot: () => T): T {
+ return getSnapshot()
+ },
useState(initial: T | (() => T)) {
const stateIndex = hookRuntime.index++
if (!(stateIndex in hookRuntime.states)) {
@@ -233,8 +240,8 @@ function renderMenu(
{
id: 'all',
label: 'All unsent notes',
- notes: [{ id: 'note-1' }],
- prompt: 'prompt-all'
+ notes: [note('note-1')],
+ formatPrompt: () => 'prompt-all'
}
]}
onDelivered={vi.fn()}
@@ -274,11 +281,12 @@ describe('NotesSendMenu', () => {
storeMocks.openAgentSendPopoverTargetMode.mockReset()
storeMocks.closeAgentSendPopoverTargetMode.mockReset()
storeMocks.state.agentSendPopoverTargetMode = null
+ resetNotesInFlightForTests()
})
it('disables the trigger when no scope has deliverable notes', () => {
const tree = renderMenu({
- scopes: [{ id: 'all', label: 'All unsent notes', notes: [], prompt: '' }]
+ scopes: [{ id: 'all', label: 'All unsent notes', notes: [], formatPrompt: () => '' }]
})
expect(findByType(tree, 'button').props.disabled).toBe(true)
@@ -288,7 +296,7 @@ describe('NotesSendMenu', () => {
it('uses caller-provided disabled tooltip copy for disabled note actions', () => {
const tree = renderMenu({
- scopes: [{ id: 'note', label: 'This note', notes: [], prompt: '' }],
+ scopes: [{ id: 'note', label: 'This note', notes: [], formatPrompt: () => '' }],
disabledTooltip: 'Note already sent'
})
@@ -317,7 +325,7 @@ describe('NotesSendMenu', () => {
const delivered = storeMocks.openAgentSendPopoverTargetMode.mock.calls[0][0]
.onPromptDelivered as () => void
delivered()
- expect(onDelivered).toHaveBeenCalledWith([{ id: 'note-1' }])
+ expect(onDelivered).toHaveBeenCalledWith([note('note-1')])
;(dropdown.props.onOpenChange as (open: boolean) => void)(false)
expect(storeMocks.closeAgentSendPopoverTargetMode).toHaveBeenCalledWith(
@@ -341,8 +349,18 @@ describe('NotesSendMenu', () => {
const tree = renderMenu({
defaultScopeId: 'file',
scopes: [
- { id: 'file', label: 'This file', notes: [{ id: 'file-note' }], prompt: 'prompt-file' },
- { id: 'all', label: 'All unsent notes', notes: [{ id: 'all-note' }], prompt: 'prompt-all' }
+ {
+ id: 'file',
+ label: 'This file',
+ notes: [note('file-note')],
+ formatPrompt: () => 'prompt-file'
+ },
+ {
+ id: 'all',
+ label: 'All unsent notes',
+ notes: [note('all-note')],
+ formatPrompt: () => 'prompt-all'
+ }
]
})
const [fileTrigger, allTrigger] = findAllByType(tree, 'DropdownMenuSubTrigger')
@@ -375,7 +393,7 @@ describe('NotesSendMenu', () => {
renderMenu({
openRequestNonce: 1,
onOpenRequestHandled,
- scopes: [{ id: 'all', label: 'All unsent notes', notes: [], prompt: '' }]
+ scopes: [{ id: 'all', label: 'All unsent notes', notes: [], formatPrompt: () => '' }]
})
expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled()
@@ -437,3 +455,85 @@ describe('NotesSendMenu', () => {
)
})
})
+
+describe('NotesSendMenu notes in flight', () => {
+ const noteA = note('note-a')
+ const noteB = note('note-b')
+ const scopeOf = (notes: TestNote[]) => [
+ {
+ id: 'all',
+ label: 'All unsent notes',
+ notes,
+ formatPrompt: (sent: readonly TestNote[]) => sent.map((entry) => entry.id).join('+')
+ }
+ ]
+ /** Calls a rendered callback prop, failing the test if it is missing. */
+ const invoke = (props: Record, name: string, ...args: unknown[]): unknown => {
+ const callback = props[name]
+ if (typeof callback !== 'function') {
+ throw new Error(`${name} is not a function`)
+ }
+ return callback(...args)
+ }
+ const contentProps = (tree: unknown) => {
+ const props = findByType(tree, 'ReviewNotesSendMenuContent').props
+ return {
+ prompt: props.prompt,
+ onPromptDelivered: () => invoke(props, 'onPromptDelivered'),
+ onPromptHandedOff: (delivered: Promise) =>
+ invoke(props, 'onPromptHandedOff', delivered)
+ }
+ }
+
+ beforeEach(() => {
+ resetHookRuntime()
+ storeMocks.openAgentSendPopoverTargetMode.mockReset()
+ storeMocks.state.agentSendPopoverTargetMode = null
+ resetNotesInFlightForTests()
+ })
+
+ it('sends only a note added while an earlier send to a new agent is still on its way', async () => {
+ const onDelivered = vi.fn()
+ let deliverA!: (result: { delivered: boolean }) => void
+ const first = contentProps(renderMenu({ scopes: scopeOf([noteA]), onDelivered }))
+ first.onPromptHandedOff(new Promise((resolve) => (deliverA = resolve)))
+
+ const second = contentProps(renderMenu({ scopes: scopeOf([noteA, noteB]), onDelivered }))
+ expect(second.prompt).toBe('note-b')
+ second.onPromptHandedOff(new Promise(() => undefined))
+
+ first.onPromptDelivered()
+ deliverA({ delivered: true })
+ second.onPromptDelivered()
+ await Promise.resolve()
+
+ expect(onDelivered.mock.calls).toEqual([[[noteA]], [[noteB]]])
+ })
+
+ it('leaves the notes out of the running-agent target mode too', () => {
+ contentProps(renderMenu({ scopes: scopeOf([noteA]) })).onPromptHandedOff(
+ new Promise(() => undefined)
+ )
+
+ const tree = renderMenu({ scopes: scopeOf([noteA, noteB]) })
+ invoke(findByType(tree, 'DropdownMenu').props, 'onOpenChange', true)
+
+ expect(storeMocks.openAgentSendPopoverTargetMode).toHaveBeenCalledWith(
+ expect.objectContaining({ prompt: 'note-b', onPromptHandedOff: expect.any(Function) })
+ )
+ })
+
+ it.each([
+ ['an undelivered result', () => Promise.resolve({ delivered: false, failureNotified: true })],
+ ['a failed start', () => Promise.reject(new Error('refused'))]
+ ])('puts the notes back for the next send after %s', async (_name, deliver) => {
+ const delivered = deliver()
+ contentProps(renderMenu({ scopes: scopeOf([noteA]) })).onPromptHandedOff(delivered)
+ expect(contentProps(renderMenu({ scopes: scopeOf([noteA]) })).prompt).toBe('')
+
+ await delivered.catch(() => undefined)
+ await Promise.resolve()
+
+ expect(contentProps(renderMenu({ scopes: scopeOf([noteA]) })).prompt).toBe('note-a')
+ })
+})
diff --git a/src/renderer/src/components/editor/NotesSendMenu.tsx b/src/renderer/src/components/editor/NotesSendMenu.tsx
index 8315d4512df..fceab2d1960 100644
--- a/src/renderer/src/components/editor/NotesSendMenu.tsx
+++ b/src/renderer/src/components/editor/NotesSendMenu.tsx
@@ -14,6 +14,13 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { ReviewNotesSendMenuContent } from './ReviewNotesSendMenuContent'
+import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments'
+import {
+ diffCommentSendKey,
+ holdNotesForSend,
+ isNoteInFlight,
+ useNotesInFlightVersion
+} from '@/lib/notes-send-in-flight'
import { translate } from '@/i18n/i18n'
const ENABLED_SEND_TOOLTIP = 'Send notes to an agent'
@@ -22,6 +29,11 @@ export type NotesSendMenuScope = {
id: string
label: string
notes: readonly TNote[]
+ /** The prompt for the notes this send carries: notes another send holds are left out. */
+ formatPrompt: (notes: readonly TNote[]) => string
+}
+
+type SendableNotesScope = Omit, 'formatPrompt'> & {
prompt: string
}
@@ -57,7 +69,7 @@ export function buildNotesSendTargetModeId(modeIdParts: readonly string[]): stri
return `note-send:${modeIdParts.map((part) => `${part.length}:${part}`).join('|')}`
}
-export function NotesSendMenu({
+export function NotesSendMenu({
worktreeId,
groupId,
modeIdParts,
@@ -82,7 +94,18 @@ export function NotesSendMenu({
const activeTargetModeId = useAppStore((s) => s.agentSendPopoverTargetMode?.id ?? null)
const [sendMenuOpen, setSendMenuOpen] = useState(false)
const targetModeId = useMemo(() => buildNotesSendTargetModeId(modeIdParts), [modeIdParts])
- const enabledScopes = useMemo(() => scopes.filter((scope) => scope.notes.length > 0), [scopes])
+ const inFlightVersion = useNotesInFlightVersion()
+ const sendableScopes = useMemo[]>(() => {
+ void inFlightVersion
+ return scopes.map(({ formatPrompt, ...scope }) => {
+ const notes = scope.notes.filter((note) => !isNoteInFlight(diffCommentSendKey(note)))
+ return { ...scope, notes, prompt: notes.length > 0 ? formatPrompt(notes) : '' }
+ })
+ }, [inFlightVersion, scopes])
+ const enabledScopes = useMemo(
+ () => sendableScopes.filter((scope) => scope.notes.length > 0),
+ [sendableScopes]
+ )
const defaultScope = useMemo(() => {
const requested = enabledScopes.find((scope) => scope.id === defaultScopeId)
return requested ?? enabledScopes[0] ?? null
@@ -95,9 +118,14 @@ export function NotesSendMenu({
},
[onDelivered]
)
+ const holdInFlight = useCallback(
+ (notes: readonly TNote[]) => (delivered: Promise) =>
+ holdNotesForSend(notes.map(diffCommentSendKey), delivered),
+ []
+ )
const openTargetMode = useCallback(
- (scope: NotesSendMenuScope) => {
+ (scope: SendableNotesScope) => {
if (scope.notes.length === 0) {
return
}
@@ -108,10 +136,12 @@ export function NotesSendMenu({
prompt: scope.prompt,
label: targetModeLabel ?? scope.label,
launchSource: 'notes_send',
- onPromptDelivered: () => markDelivered(scope.notes)
+ onPromptDelivered: () => markDelivered(scope.notes),
+ onPromptHandedOff: holdInFlight(scope.notes)
})
},
[
+ holdInFlight,
markDelivered,
openAgentSendPopoverTargetMode,
source,
@@ -226,7 +256,7 @@ export function NotesSendMenu({
{translate('auto.components.editor.NotesSendMenu.44dc5e60a6', 'Send notes')}
- {scopes.map((scope) => (
+ {sendableScopes.map((scope) => (
({
promptDelivery="submit-after-ready"
launchSource="notes_send"
onPromptDelivered={() => markDelivered(scope.notes)}
+ onPromptHandedOff={holdInFlight(scope.notes)}
/>
@@ -261,6 +292,7 @@ export function NotesSendMenu({
markDelivered(defaultScope.notes)
}
}}
+ onPromptHandedOff={holdInFlight(defaultScope?.notes ?? [])}
/>
)}
diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx
index 7c1961f7d7c..980b7f3918f 100644
--- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx
+++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx
@@ -650,6 +650,7 @@ describe('ReviewNotesSendMenuContent', () => {
it('sends notes to the chosen agent and tracks the send once it succeeds', async () => {
const statusPaneKey = makePaneKey(TAB_A, LEAF_A)
const onPromptDelivered = vi.fn()
+ const onPromptHandedOff = vi.fn()
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] },
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') }
@@ -665,7 +666,7 @@ describe('ReviewNotesSendMenuContent', () => {
}
]
- const tree = render({ onPromptDelivered })
+ const tree = render({ onPromptDelivered, onPromptHandedOff })
;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)()
await flushMicrotasks()
@@ -680,6 +681,10 @@ describe('ReviewNotesSendMenuContent', () => {
launch_source: 'notes_send',
request_kind: 'followup'
})
+ // The notes are held from the hand-off until this send's own outcome, after its delivery.
+ expect(onPromptHandedOff).toHaveBeenCalledOnce()
+ await onPromptHandedOff.mock.calls[0][0]
+ expect(onPromptDelivered).toHaveBeenCalledTimes(1)
})
it('keeps selected-target note failures undelivered and uses selected wording', async () => {
@@ -854,13 +859,15 @@ describe('ReviewNotesSendMenuContent', () => {
})
it('always offers the new-agent launcher', () => {
- const tree = render()
+ const onPromptHandedOff = vi.fn()
+ const tree = render({ onPromptHandedOff })
expect(findByType(tree, 'QuickLaunchAgentMenuItems').props).toMatchObject({
worktreeId: 'wt-1',
groupId: 'group-1',
prompt: 'my notes',
- launchSource: 'notes_send'
+ launchSource: 'notes_send',
+ onPromptHandedOff
})
})
})
diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx
index 688113e7a01..6c97b0f3ad6 100644
--- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx
+++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx
@@ -46,7 +46,8 @@ export function ReviewNotesSendMenuContent({
prompt,
promptDelivery = 'submit-after-ready',
launchSource = 'notes_send',
- onPromptDelivered
+ onPromptDelivered,
+ onPromptHandedOff
}: {
worktreeId: string
groupId: string
@@ -54,6 +55,8 @@ export function ReviewNotesSendMenuContent({
promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready'
launchSource?: LaunchSource
onPromptDelivered?: () => void
+ /** Given each send's own result the moment its prompt is handed to an agent. */
+ onPromptHandedOff?: (delivered: Promise) => void
}): React.JSX.Element {
const hasPrompt = prompt.trim().length > 0
@@ -114,7 +117,7 @@ export function ReviewNotesSendMenuContent({
)
)
- void send()
+ const sending = send()
.then((result) => {
if (result.status === 'sent') {
onSent()
@@ -147,8 +150,9 @@ export function ReviewNotesSendMenuContent({
{ id: pending }
)
})
+ onPromptHandedOff?.(sending)
},
- []
+ [onPromptHandedOff]
)
const sendToAgentTarget = useCallback(
@@ -208,6 +212,7 @@ export function ReviewNotesSendMenuContent({
promptDelivery={promptDelivery}
launchSource={launchSource}
onPromptDelivered={onPromptDelivered}
+ onPromptHandedOff={onPromptHandedOff}
/>
>
)
diff --git a/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx b/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx
index 0aa90569f98..2caa79b5a8d 100644
--- a/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownReviewNoteLayer.tsx
@@ -134,10 +134,8 @@ export function RichMarkdownReviewNoteLayer({
'This note'
),
notes: comment.sentAt ? [] : [comment as MarkdownReviewNote],
- prompt: formatMarkdownReviewNotes(
- [comment as MarkdownReviewNote],
- markdownReviewContent
- )
+ formatPrompt: (notes) =>
+ formatMarkdownReviewNotes(notes, markdownReviewContent)
}
]}
targetModeLabel="This note"
diff --git a/src/renderer/src/components/editor/use-markdown-preview-foundation.ts b/src/renderer/src/components/editor/use-markdown-preview-foundation.ts
index 48631ce6897..accf06ca409 100644
--- a/src/renderer/src/components/editor/use-markdown-preview-foundation.ts
+++ b/src/renderer/src/components/editor/use-markdown-preview-foundation.ts
@@ -101,20 +101,16 @@ export function useMarkdownPreviewFoundation({
() => markdownReviewNotes.filter((note) => !note.sentAt),
[markdownReviewNotes]
)
- const unsentMarkdownReviewPrompt = useMemo(
- () => formatMarkdownReviewNotes(unsentMarkdownReviewNotes, renderedContent),
- [renderedContent, unsentMarkdownReviewNotes]
- )
const unsentMarkdownReviewScope = useMemo[]>(
() => [
{
id: 'all',
label: translate('auto.components.editor.MarkdownPreview.ddf087d12e', 'All unsent notes'),
notes: unsentMarkdownReviewNotes,
- prompt: unsentMarkdownReviewPrompt
+ formatPrompt: (notes) => formatMarkdownReviewNotes(notes, renderedContent)
}
],
- [unsentMarkdownReviewNotes, unsentMarkdownReviewPrompt]
+ [renderedContent, unsentMarkdownReviewNotes]
)
const canShowReviewTools = Boolean(
markdownAnnotationsEnabled && sourceWorktree && sourceRelativePath !== null
diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewData.ts b/src/renderer/src/components/editor/useRichMarkdownReviewData.ts
index a447562f5d4..447bd938b56 100644
--- a/src/renderer/src/components/editor/useRichMarkdownReviewData.ts
+++ b/src/renderer/src/components/editor/useRichMarkdownReviewData.ts
@@ -65,7 +65,7 @@ export function useRichMarkdownReviewData({
'All unsent notes'
),
notes: unsentNotes,
- prompt: formatMarkdownReviewNotes(unsentNotes, markdownReviewContent)
+ formatPrompt: (notes) => formatMarkdownReviewNotes(notes, markdownReviewContent)
}
]
}, [markdownReviewContent, markdownReviewNotes])
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 ebeed7630cd..3e43d66f52f 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
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
-import { cleanup, render } from '@testing-library/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 {
@@ -35,8 +35,13 @@ vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: ({ agent }: { agent: string }) => {agent}
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
- DropdownMenuItem: ({ children, disabled, title }: { children: ReactNode } & DivProps) => (
-
+ DropdownMenuItem: ({
+ children,
+ disabled,
+ title,
+ onSelect
+ }: { children: ReactNode } & DivProps) => (
+
{children}
),
@@ -46,9 +51,10 @@ vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record
) =>
fallback.replace('{{value0}}', values?.value0 ?? '')
}))
-vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: vi.fn() }))
+const launchMock = vi.hoisted(() => vi.fn())
+vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: launchMock }))
-type DivProps = { disabled?: boolean; title?: string }
+type DivProps = { disabled?: boolean; title?: string; onSelect?: () => void }
import { QuickLaunchAgentMenuItems } from './QuickLaunchButton'
import {
@@ -173,4 +179,28 @@ describe('QuickLaunchAgentMenuItems launch status', () => {
render(menu('review notes'))
expect(agentRowDisabled('Codex')).toBe('true')
})
+
+ // Why: the notes menu holds what it sent until this result, so a second send leaves them out.
+ it("hands the launch's own delivery result to the notes menu", () => {
+ const delivery = Promise.resolve({ delivered: true, failureNotified: false })
+ launchMock.mockReturnValue({
+ surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'codex-session' },
+ promptDeliveryResult: delivery
+ })
+ const onPromptHandedOff = vi.fn()
+
+ render(
+
+ )
+ fireEvent.click(document.querySelector('[title="Launch Codex in a new terminal"]')!)
+
+ expect(onPromptHandedOff).toHaveBeenCalledWith(delivery)
+ })
})
diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
index b065595fb65..49a362e9826 100644
--- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
+++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
@@ -38,6 +38,8 @@ export type QuickLaunchAgentMenuItemsProps = {
launchSource?: LaunchSource
/** Called after a prompt is queued into the agent, or immediately for argv prompt launches. */
onPromptDelivered?: () => void
+ /** Given the launch's own delivery result while the prompt is still on its way. */
+ onPromptHandedOff?: (delivered: Promise) => void
}
function getCatalogEntry(agent: TuiAgent): { id: TuiAgent; label: string } | null {
@@ -104,7 +106,8 @@ function QuickLaunchAgentMenuItemsInner({
prompt,
promptDelivery,
launchSource,
- onPromptDelivered
+ onPromptDelivered,
+ onPromptHandedOff
}: QuickLaunchAgentMenuItemsProps): React.JSX.Element | null {
// Why: resolving only the SSH connectionId here made paired-runtime
// worktrees fall back to LOCAL detection, listing the client's agents
@@ -155,6 +158,9 @@ function QuickLaunchAgentMenuItemsInner({
)
return
}
+ if (result.promptDeliveryResult) {
+ onPromptHandedOff?.(result.promptDeliveryResult)
+ }
if (result.surface.kind !== 'local-terminal') {
return
}
@@ -181,7 +187,16 @@ function QuickLaunchAgentMenuItemsInner({
toast.message(getLaunchWatchdogTimeoutMessage(label))
})
},
- [worktreeId, groupId, onFocusTerminal, prompt, promptDelivery, launchSource, onPromptDelivered]
+ [
+ worktreeId,
+ groupId,
+ onFocusTerminal,
+ prompt,
+ promptDelivery,
+ launchSource,
+ onPromptDelivered,
+ onPromptHandedOff
+ ]
)
const enabledDetectedIds = detectedIds ? filterEnabledTuiAgents(detectedIds, disabledAgents) : []
diff --git a/src/renderer/src/lib/notes-send-in-flight.ts b/src/renderer/src/lib/notes-send-in-flight.ts
new file mode 100644
index 00000000000..b8f221a6e0e
--- /dev/null
+++ b/src/renderer/src/lib/notes-send-in-flight.ts
@@ -0,0 +1,73 @@
+import { useSyncExternalStore } from 'react'
+import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments'
+
+// Why: notes handed to a send leave the next send at once and come back only if that delivery
+// fails, as a submitted composer clears and restores on error. Delivered notes are still removed
+// by their owner; a hold lives only until its delivery settles.
+const holds = new Map()
+const listeners = new Set<() => void>()
+let version = 0
+
+function changed(): void {
+ version += 1
+ for (const listener of listeners) {
+ listener()
+ }
+}
+
+/** Takes `keys` out of the next send until `delivered` settles, whatever its result. */
+export function holdNotesForSend(keys: readonly unknown[], delivered: Promise): void {
+ if (keys.length === 0) {
+ return
+ }
+ for (const key of keys) {
+ holds.set(key, (holds.get(key) ?? 0) + 1)
+ }
+ changed()
+ const release = (): void => {
+ for (const key of keys) {
+ const count = (holds.get(key) ?? 1) - 1
+ if (count > 0) {
+ holds.set(key, count)
+ } else {
+ holds.delete(key)
+ }
+ }
+ changed()
+ }
+ void delivered.then(release, release)
+}
+
+export function isNoteInFlight(key: unknown): boolean {
+ return holds.has(key)
+}
+
+/** Changes whenever a hold starts or ends, for memos that filter by `isNoteInFlight`. */
+export function useNotesInFlightVersion(): number {
+ return useSyncExternalStore(
+ (listener) => {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+ () => version,
+ () => version
+ )
+}
+
+/** A note's identity for delivery: an edit makes it a new pending note, as for its removal. */
+export function diffCommentSendKey(note: DiffCommentDeliverySnapshot): string {
+ return JSON.stringify([
+ note.id,
+ note.body,
+ note.filePath,
+ note.lineNumber,
+ note.startLine ?? null,
+ note.selectedText ?? null,
+ note.source ?? null
+ ])
+}
+
+export function resetNotesInFlightForTests(): void {
+ holds.clear()
+ changed()
+}
diff --git a/src/renderer/src/store/slices/ui-agent-send-target.test.ts b/src/renderer/src/store/slices/ui-agent-send-target.test.ts
index 61f98a2afba..c94e71edd26 100644
--- a/src/renderer/src/store/slices/ui-agent-send-target.test.ts
+++ b/src/renderer/src/store/slices/ui-agent-send-target.test.ts
@@ -282,6 +282,7 @@ describe('createUISlice agent send target mode', () => {
it('sends to the live leaf PTY, runs delivery callback, tracks followup, and closes', async () => {
const store = createAgentSendStore()
const onPromptDelivered = vi.fn()
+ const onPromptHandedOff = vi.fn()
seedAgentSendState(store)
store.getState().openAgentSendPopoverTargetMode({
id: 'send-1',
@@ -290,11 +291,16 @@ describe('createUISlice agent send target mode', () => {
prompt: 'Review this',
label: 'All unsent notes',
launchSource: 'notes_send',
- onPromptDelivered
+ onPromptDelivered,
+ onPromptHandedOff
})
await expect(store.getState().sendPromptToSidebarAgentTarget(readyPaneKey)).resolves.toBe(true)
+ // The notes leave the next send for exactly this send's lifetime.
+ expect(onPromptHandedOff).toHaveBeenCalledOnce()
+ await expect(onPromptHandedOff.mock.calls[0][0]).resolves.toMatchObject({ status: 'sent' })
+
expect(mocks.sendNotesToActiveAgentSession).toHaveBeenCalledWith({
worktreeId,
prompt: 'Review this',
diff --git a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts
index 8beecf0e12d..d267b00b7b8 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-agent-actions.ts
@@ -154,7 +154,7 @@ export function createUiAgentActions(
import('@/lib/active-agent-note-send'),
import('@/lib/agent-message-send')
])
- const result = await sendMessageToAgent({
+ const sending = sendMessageToAgent({
worktreeId: mode.worktreeId,
prompt: mode.prompt,
target: runningAgentMessageTarget(target)
@@ -164,6 +164,8 @@ export function createUiAgentActions(
})
return { status: 'status-unavailable' as const, code: 'runtime-unverifiable' as const }
})
+ mode.onPromptHandedOff?.(sending)
+ const result = await sending
const stillCurrent = (): boolean => {
const current = get().agentSendPopoverTargetMode
diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts
index ffb37c40a01..5d187bcf788 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-contract-core.ts
@@ -39,6 +39,8 @@ export type AgentSendPopoverTargetMode = {
sendingPaneKey?: string
error?: string
onPromptDelivered?: () => void
+ /** Told the send's own result the moment the prompt is handed to an agent. */
+ onPromptHandedOff?: (delivered: Promise) => void
}
export type OpenAgentSendPopoverTargetModeArgs = {
@@ -49,6 +51,7 @@ export type OpenAgentSendPopoverTargetModeArgs = {
label: string
launchSource: LaunchSource
onPromptDelivered?: () => void
+ onPromptHandedOff?: (delivered: Promise) => void
}
export type TaskPageData = {
From 40e3975b421b9d79b956a5b4795d73fbb45668bf Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 4 Oct 2026 01:32:14 -0700
Subject: [PATCH 10/16] test: give the annotation tray fixture its hand-off
callback
---
.../browser-pane/annotate/browser-page-annotation-tray.test.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx b/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx
index 1fe0b6bfd46..162e1c64bf0 100644
--- a/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx
+++ b/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.test.tsx
@@ -87,6 +87,7 @@ function renderTray(currentUrl?: string): {
activeGroupId={undefined}
browserAnnotationsPrompt="prompt"
handleBrowserAnnotationsSentToAgent={vi.fn()}
+ handleBrowserAnnotationsHandedOff={vi.fn()}
handleCopyBrowserAnnotations={vi.fn()}
browserAnnotationsCopied={false}
handleClearBrowserAnnotations={vi.fn()}
From ccbfffb0e327f701db335035550e111272580736 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 4 Oct 2026 01:34:34 -0700
Subject: [PATCH 11/16] fix(notes): offer no send when every note is already on
its way
When every note or annotation in a send is held by an earlier send still in
flight, the built prompt is empty. The notes menu already disabled its
trigger then, but its New agent rows still started an agent with no text,
the annotation Send buttons still opened, and a sidebar agent row could be
sent an empty prompt. The New agent rows, the annotation Send buttons and the
sidebar send now offer nothing when there is nothing to send.
---
.../annotate/browser-page-annotation-tray.tsx | 7 ++++++-
.../use-browser-page-annotation-send.test.tsx | 11 ++++++++++
.../use-browser-page-annotation-send.ts | 4 ++++
.../browser-page-chrome-banners.tsx | 7 ++++++-
.../components/editor/NotesSendMenu.test.tsx | 11 ++++++++++
.../ReviewNotesSendMenuContent.test.tsx | 8 ++++++++
.../editor/ReviewNotesSendMenuContent.tsx | 1 +
.../QuickLaunchButton.launch-status.test.tsx | 17 ++++++++++++++++
.../components/tab-bar/QuickLaunchButton.tsx | 13 +++++++++---
.../store/slices/ui-agent-send-target.test.ts | 20 +++++++++++++++++++
.../store/slices/ui/ui-slice-agent-actions.ts | 3 ++-
11 files changed, 96 insertions(+), 6 deletions(-)
diff --git a/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx b/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx
index 06e35269429..6211684fb54 100644
--- a/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx
+++ b/src/renderer/src/components/browser-pane/annotate/browser-page-annotation-tray.tsx
@@ -113,7 +113,12 @@ export function BrowserPageAnnotationTray({
-