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({ - 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 52bd02ba05a..8eb9934c5a8 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 @@ -266,4 +266,15 @@ describe('website annotations handed to a send', () => { act(() => mode?.onPromptHandedOff?.(new Promise(() => undefined))) expect(view.result.current.browserAnnotationsPrompt).toBe('') }) + + it('offers no send while every annotation is already on its way', () => { + const view = mount() + act(() => view.result.current.handleBrowserAnnotationsHandedOff(new Promise(() => undefined))) + + act(() => view.result.current.handleAnnotationTraySendOpenChange(true)) + expect(mode).toBeUndefined() + + render() + expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled() + }) }) 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 5480c030bfe..37bfa660ba9 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 @@ -136,6 +136,10 @@ export function useBrowserPageAnnotationSend({ const handleAnnotationSendOpenChange = useCallback( (modeId: string, open: boolean): void => { if (open) { + // Every annotation may already be on its way: there is nothing to send. + if (!browserAnnotationsPrompt) { + return + } openAgentSendPopoverTargetMode({ id: modeId, worktreeId, 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 90fbc4464b4..5bced58139a 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 @@ -133,7 +133,12 @@ export function BrowserPageChromeBanners({ - diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index 7ce7fe77b85..1ecc99df544 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -536,4 +536,15 @@ describe('NotesSendMenu notes in flight', () => { expect(contentProps(renderMenu({ scopes: scopeOf([noteA]) })).prompt).toBe('note-a') }) + + it('offers no send once every note is on its way', () => { + contentProps(renderMenu({ scopes: scopeOf([noteA]) })).onPromptHandedOff( + new Promise(() => undefined) + ) + + const tree = renderMenu({ scopes: scopeOf([noteA]) }) + expect(findByType(tree, 'button').props.disabled).toBe(true) + invoke(findByType(tree, 'DropdownMenu').props, 'onOpenChange', true) + expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 980b7f3918f..af4c1e98445 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -870,4 +870,12 @@ describe('ReviewNotesSendMenuContent', () => { onPromptHandedOff }) }) + + // Every note already on its way leaves an empty prompt: no agent may start with no text. + it('disables the new-agent launcher when there is nothing left to send', () => { + expect(findByType(render({ prompt: '' }), 'QuickLaunchAgentMenuItems').props.disabled).toBe( + true + ) + expect(findByType(render(), 'QuickLaunchAgentMenuItems').props.disabled).toBe(false) + }) }) diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 6c97b0f3ad6..935d98dd986 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -213,6 +213,7 @@ export function ReviewNotesSendMenuContent({ launchSource={launchSource} onPromptDelivered={onPromptDelivered} onPromptHandedOff={onPromptHandedOff} + disabled={!hasPrompt} /> ) 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 3e43d66f52f..80b3a59ada4 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 @@ -203,4 +203,21 @@ describe('QuickLaunchAgentMenuItems launch status', () => { expect(onPromptHandedOff).toHaveBeenCalledWith(delivery) }) + + it('starts no agent when the menu has nothing left to send', () => { + launchMock.mockClear() + render( + + ) + + expect(agentRowDisabled('Codex')).toBe('true') + fireEvent.click(document.querySelector('[title="Launch Codex in a new terminal"]')!) + expect(launchMock).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 49a362e9826..c380b40fd9a 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -40,6 +40,8 @@ export type QuickLaunchAgentMenuItemsProps = { onPromptDelivered?: () => void /** Given the launch's own delivery result while the prompt is still on its way. */ onPromptHandedOff?: (delivered: Promise) => void + /** Nothing to send: e.g. every note is already on its way, so no agent is started. */ + disabled?: boolean } function getCatalogEntry(agent: TuiAgent): { id: TuiAgent; label: string } | null { @@ -107,7 +109,8 @@ function QuickLaunchAgentMenuItemsInner({ promptDelivery, launchSource, onPromptDelivered, - onPromptHandedOff + onPromptHandedOff, + disabled = false }: 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 @@ -137,6 +140,9 @@ function QuickLaunchAgentMenuItemsInner({ const runLaunch = useCallback( (agent: TuiAgent) => { + if (disabled) { + return + } const entry = getCatalogEntry(agent) const label = entry?.label ?? agent const result = launchAgentInNewTab({ @@ -195,7 +201,8 @@ function QuickLaunchAgentMenuItemsInner({ promptDelivery, launchSource, onPromptDelivered, - onPromptHandedOff + onPromptHandedOff, + disabled ] ) @@ -227,7 +234,7 @@ function QuickLaunchAgentMenuItemsInner({ return ( runLaunch(agent)} className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" title={translate( 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 c94e71edd26..6d49bb1baa5 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 @@ -406,6 +406,26 @@ describe('createUISlice agent send target mode', () => { expect(mocks.toastSuccess).toHaveBeenCalledWith('Sent to Claude') }) + it('sends nothing to a sidebar agent when every note is already on its way', async () => { + const store = createAgentSendStore() + const onPromptHandedOff = vi.fn() + seedAgentSendState(store) + store.getState().openAgentSendPopoverTargetMode({ + id: 'send-1', + worktreeId, + source: 'diff-notes', + prompt: '', + label: 'All unsent notes', + launchSource: 'notes_send', + onPromptHandedOff + }) + + await expect(store.getState().sendPromptToSidebarAgentTarget(readyPaneKey)).resolves.toBe(false) + + expect(mocks.sendNotesToActiveAgentSession).not.toHaveBeenCalled() + expect(onPromptHandedOff).not.toHaveBeenCalled() + }) + it('keeps target mode open and does not run delivery callback when send fails', async () => { const store = createAgentSendStore() const onPromptDelivered = vi.fn() 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 d267b00b7b8..7af82053993 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 @@ -120,7 +120,8 @@ export function createUiAgentActions( }), sendPromptToSidebarAgentTarget: async (paneKey) => { const mode = get().agentSendPopoverTargetMode - if (!mode || mode.status === 'sending') { + // An empty prompt has nothing to send: every note may already be on its way. + if (!mode || mode.status === 'sending' || !mode.prompt.trim()) { return false } From 60d7e010e5943a73f8dc5f2e02cf7ed3f1c5904d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:52:02 -0700 Subject: [PATCH 12/16] fix(notes): a new chat's staged prompt decides when its notes leave the shelf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notes sent to a new agent were released back to the shelf as soon as the chat's start failed, while that failed chat kept the same text staged for its own Retry. Re-sending and pressing Retry put the notes in two chats, and Retry alone left delivered notes listed as unsent. For a new chat, the notes now follow the prompt it staged: held while that message is in the chat's outbox, cleared from the shelf when any dispatch (Retry or re-check included) sends it on, and back on the shelf when the chat is closed and its outbox thrown away. A paired server's chat is found once its start settles. Running-agent sends keep their own result. While notes are only on their way, the send button reads "Sending…" rather than "All notes sent". --- .../use-browser-page-annotation-send.ts | 5 +- .../components/editor/NotesSendMenu.test.tsx | 27 +- .../src/components/editor/NotesSendMenu.tsx | 12 +- ...ctured-agent-session-outbox-entry-watch.ts | 63 +++++ ...structured-agent-session-outbox-storage.ts | 16 +- .../QuickLaunchButton.launch-status.test.tsx | 5 +- .../components/tab-bar/QuickLaunchButton.tsx | 13 +- .../src/lib/new-agent-prompt-outcome.test.ts | 234 ++++++++++++++++++ .../src/lib/new-agent-prompt-outcome.ts | 62 +++++ src/renderer/src/lib/notes-send-in-flight.ts | 43 +++- 10 files changed, 458 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-entry-watch.ts create mode 100644 src/renderer/src/lib/new-agent-prompt-outcome.test.ts create mode 100644 src/renderer/src/lib/new-agent-prompt-outcome.ts 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 37bfa660ba9..700094fe874 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 @@ -119,8 +119,9 @@ export function useBrowserPageAnnotationSend({ ]) const handleBrowserAnnotationsHandedOff = useCallback( - (delivered: Promise): void => holdNotesForSend(sendableAnnotations, delivered), - [sendableAnnotations] + (delivered: Promise): void => + holdNotesForSend(sendableAnnotations, delivered, handleBrowserAnnotationsSentToAgent), + [handleBrowserAnnotationsSentToAgent, sendableAnnotations] ) const handleClearBrowserAnnotations = useCallback((): void => { diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index 1ecc99df544..b8758850895 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -507,7 +507,10 @@ describe('NotesSendMenu notes in flight', () => { second.onPromptDelivered() await Promise.resolve() - expect(onDelivered.mock.calls).toEqual([[[noteA]], [[noteB]]]) + // Each send clears only its own note; a repeated clear of A is a no-op for its owner. + expect(onDelivered).not.toHaveBeenCalledWith([noteA, noteB]) + expect(onDelivered).toHaveBeenCalledWith([noteA]) + expect(onDelivered).toHaveBeenCalledWith([noteB]) }) it('leaves the notes out of the running-agent target mode too', () => { @@ -547,4 +550,26 @@ describe('NotesSendMenu notes in flight', () => { invoke(findByType(tree, 'DropdownMenu').props, 'onOpenChange', true) expect(storeMocks.openAgentSendPopoverTargetMode).not.toHaveBeenCalled() }) + + it('says the notes are on their way, not sent, while every note is held', () => { + contentProps( + renderMenu({ scopes: scopeOf([noteA]), disabledTooltip: 'Note already sent' }) + ).onPromptHandedOff(new Promise(() => undefined)) + + const tree = renderMenu({ scopes: scopeOf([noteA]), disabledTooltip: 'Note already sent' }) + + expect(findByType(tree, 'button').props.title).toBe('Sending…') + }) + + // A failed new chat's Retry delivers them after the send's own callback is gone. + it('clears notes whose send reports delivery later', async () => { + const onDelivered = vi.fn() + const delivered = Promise.resolve({ delivered: true }) + contentProps(renderMenu({ scopes: scopeOf([noteA]), onDelivered })).onPromptHandedOff(delivered) + + await delivered + await Promise.resolve() + + expect(onDelivered).toHaveBeenCalledWith([noteA]) + }) }) diff --git a/src/renderer/src/components/editor/NotesSendMenu.tsx b/src/renderer/src/components/editor/NotesSendMenu.tsx index fceab2d1960..bb8cac0511a 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.tsx @@ -111,6 +111,10 @@ export function NotesSendMenu({ return requested ?? enabledScopes[0] ?? null }, [defaultScopeId, enabledScopes]) const hasDeliverableNotes = enabledScopes.length > 0 + // Notes only on their way to an agent are not sent yet. + const disabledTitle = scopes.some((scope) => scope.notes.length > 0) + ? translate('components.native-chat.question.sending', 'Sending…') + : disabledTooltip const markDelivered = useCallback( (notes: readonly TNote[]) => { @@ -120,8 +124,8 @@ export function NotesSendMenu({ ) const holdInFlight = useCallback( (notes: readonly TNote[]) => (delivered: Promise) => - holdNotesForSend(notes.map(diffCommentSendKey), delivered), - [] + holdNotesForSend(notes.map(diffCommentSendKey), delivered, () => markDelivered(notes)), + [markDelivered] ) const openTargetMode = useCallback( @@ -211,7 +215,7 @@ export function NotesSendMenu({ triggerClassName )} disabled={!hasDeliverableNotes} - title={hasDeliverableNotes ? ENABLED_SEND_TOOLTIP : disabledTooltip} + title={hasDeliverableNotes ? ENABLED_SEND_TOOLTIP : disabledTitle} aria-label={ triggerLabel ? translate( @@ -242,7 +246,7 @@ export function NotesSendMenu({ - {hasDeliverableNotes ? ENABLED_SEND_TOOLTIP : disabledTooltip} + {hasDeliverableNotes ? ENABLED_SEND_TOOLTIP : disabledTitle} void +} + +const watchesBySession = new Map>() + +// A user's Retry of a refused message gives it a new id; its text and queue time stay. +function stillQueued( + watched: StructuredAgentSessionOutboxEntry, + entries: readonly StructuredAgentSessionOutboxEntry[] +): boolean { + const body = JSON.stringify(watched.body) + return entries.some( + (entry) => + entry.clientMessageId === watched.clientMessageId || + (entry.queuedAt === watched.queuedAt && JSON.stringify(entry.body) === body) + ) +} + +/** Calls `onGone` once, when `entry` leaves its session's outbox. */ +export function watchStructuredAgentSessionOutboxEntry( + entry: StructuredAgentSessionOutboxEntry, + onGone: (removal: StructuredAgentSessionOutboxEntryRemoval) => void +): () => void { + const watches = watchesBySession.get(entry.sessionId) ?? new Set() + watchesBySession.set(entry.sessionId, watches) + const watch: EntryWatch = { entry, onGone } + watches.add(watch) + return () => { + watches.delete(watch) + if (watches.size === 0 && watchesBySession.get(entry.sessionId) === watches) { + watchesBySession.delete(entry.sessionId) + } + } +} + +/** Run by the outbox's one write funnel after a change takes effect. */ +export function settleStructuredAgentSessionOutboxEntryWatches( + sessionId: string, + entries: readonly StructuredAgentSessionOutboxEntry[], + removal: StructuredAgentSessionOutboxEntryRemoval +): void { + const watches = watchesBySession.get(sessionId) + if (!watches) { + return + } + for (const watch of watches) { + if (!stillQueued(watch.entry, entries)) { + watches.delete(watch) + watch.onGone(removal) + } + } + if (watches.size === 0) { + watchesBySession.delete(sessionId) + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index ce270d60ddb..93c38669226 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -6,6 +6,10 @@ import { } from '../../../../shared/structured-agent-session-outbox' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { createBrowserUuid } from '@/lib/browser-uuid' +import { + settleStructuredAgentSessionOutboxEntryWatches, + type StructuredAgentSessionOutboxEntryRemoval +} from './structured-agent-session-outbox-entry-watch' const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:' @@ -159,6 +163,15 @@ export function commitStructuredAgentSessionOutbox( sessionId: string, entries: StructuredAgentSessionOutboxEntry[], options: { onlyIfSaved?: boolean } = {} +): boolean { + return commitOutbox(sessionId, entries, options, 'spent') +} + +function commitOutbox( + sessionId: string, + entries: StructuredAgentSessionOutboxEntry[], + options: { onlyIfSaved?: boolean }, + removal: StructuredAgentSessionOutboxEntryRemoval ): boolean { const saved = writeOutbox(sessionId, entries) if (!saved && options.onlyIfSaved) { @@ -171,6 +184,7 @@ export function commitStructuredAgentSessionOutbox( listener() } } + settleStructuredAgentSessionOutboxEntryWatches(sessionId, entries, removal) return saved } @@ -209,7 +223,7 @@ export function enqueueStructuredAgentSessionLaunchPrompt( } export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void { - commitStructuredAgentSessionOutbox(sessionId, []) + commitOutbox(sessionId, [], {}, 'discarded') } export function mutateStructuredAgentSessionLaunchPrompt( 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 80b3a59ada4..1f06c3307c3 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 @@ -181,7 +181,7 @@ describe('QuickLaunchAgentMenuItems launch status', () => { }) // 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", () => { + it("hands the launch's own delivery outcome to the notes menu", async () => { const delivery = Promise.resolve({ delivered: true, failureNotified: false }) launchMock.mockReturnValue({ surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'codex-session' }, @@ -201,7 +201,8 @@ describe('QuickLaunchAgentMenuItems launch status', () => { ) fireEvent.click(document.querySelector('[title="Launch Codex in a new terminal"]')!) - expect(onPromptHandedOff).toHaveBeenCalledWith(delivery) + expect(onPromptHandedOff).toHaveBeenCalledOnce() + await expect(onPromptHandedOff.mock.calls[0][0]).resolves.toEqual({ delivered: true }) }) it('starts no agent when the menu has nothing left to send', () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index c380b40fd9a..a5eda32b0e6 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -18,6 +18,7 @@ import { import { translate } from '@/i18n/i18n' import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' import { structuredLaunchRequest } from '@/lib/structured-agent-session-launch-request' +import { newAgentPromptOutcome } from '@/lib/new-agent-prompt-outcome' export type QuickLaunchAgentMenuItemsProps = { worktreeId: string @@ -164,8 +165,16 @@ function QuickLaunchAgentMenuItemsInner({ ) return } - if (result.promptDeliveryResult) { - onPromptHandedOff?.(result.promptDeliveryResult) + if (onPromptHandedOff && result.promptDeliveryResult) { + onPromptHandedOff( + newAgentPromptOutcome({ + prompt: prompt ?? '', + ...(result.surface.kind === 'local-agent-session' + ? { sessionId: result.surface.sessionId } + : {}), + delivery: result.promptDeliveryResult + }) + ) } if (result.surface.kind !== 'local-terminal') { return diff --git a/src/renderer/src/lib/new-agent-prompt-outcome.test.ts b/src/renderer/src/lib/new-agent-prompt-outcome.test.ts new file mode 100644 index 00000000000..dd26ded38b2 --- /dev/null +++ b/src/renderer/src/lib/new-agent-prompt-outcome.test.ts @@ -0,0 +1,234 @@ +// @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(() => ({ + callStructuredAgentSession: vi.fn(), + createIntent: vi.fn(), + launch: 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: (intent: unknown) => intent, + restoreStructuredAgentSessionLaunchIntent: vi.fn(), + abandonStructuredAgentSessionLaunchIntent: vi.fn(), + 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: vi.fn(), + clearNativeChatLaunchDraft: vi.fn() + }), + 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 { + mutateStructuredAgentSessionLaunchPrompt, + readOutbox +} from '@/components/native-chat/structured-agent-session-outbox-storage' +import { + cancelStructuredAgentLaunch, + getStructuredAgentSessionLaunchLifecycle, + retryStructuredAgentSessionLaunch, + startStructuredAgentLaunch +} from './structured-agent-session-launch' +import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' +import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' +import { + holdNotesForSend, + isNoteInFlight, + resetNotesInFlightForTests +} from './notes-send-in-flight' +import { newAgentPromptOutcome } from './new-agent-prompt-outcome' + +const WORKTREE_ID = 'wt-notes-new-agent' +const NOTES = 'review notes' + +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 published(sessionId: string): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'tab-1', + title: 'Codex', + sessionId, + agent: 'codex', + isActive: false + } + ] + } +} + +async function settle(): Promise { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } +} + +const chat = launchIntent('session-notes') + +/** What the notes menu does with a "New agent" pick: the launch, then the hold on its outcome. */ +function sendNotesToNewAgent(options: { paired?: boolean } = {}) { + const onDelivered = vi.fn() + const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { + prompt: NOTES, + promptDelivery: 'submit-after-ready' + }) + holdNotesForSend( + ['note-a'], + newAgentPromptOutcome({ + prompt: NOTES, + ...(options.paired ? {} : { sessionId: launch.sessionId }), + delivery: launch.promptDeliveryResult! + }), + onDelivered + ) + return { launch, onDelivered } +} + +/** A start the host refused outright: the chat shows it failed, with Retry. */ +async function failTheStart(): Promise { + await settle() + expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, chat.sessionId)).toBe('failed') +} + +describe('notes sent to a new agent', () => { + beforeEach(() => { + vi.resetAllMocks() + localStorage.clear() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentLaunchRegistryForTests() + resetNotesInFlightForTests() + mocks.createIntent.mockReturnValue(chat) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([published(chat.sessionId)]) + mocks.callStructuredAgentSession.mockResolvedValue({ + ok: true, + value: { submission: { dispatchState: 'accepted' } } + }) + }) + + it('leaves the shelf once the new chat delivers them', async () => { + mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 }) + const { onDelivered } = sendNotesToNewAgent() + expect(isNoteInFlight('note-a')).toBe(true) + + await settle() + + expect(onDelivered).toHaveBeenCalledOnce() + expect(isNoteInFlight('note-a')).toBe(false) + }) + + it('stay held, not resendable, while a failed chat keeps them for its Retry', async () => { + mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) + const { onDelivered } = sendNotesToNewAgent() + await failTheStart() + + expect(readOutbox(chat.sessionId)).toHaveLength(1) + expect(isNoteInFlight('note-a')).toBe(true) + expect(onDelivered).not.toHaveBeenCalled() + }) + + it("leave the shelf when that chat's Retry delivers them", async () => { + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) + const { onDelivered } = sendNotesToNewAgent() + await failTheStart() + mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 }) + + expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, chat.sessionId)).toBe(true) + await settle() + // The open chat's own send accepts the staged prompt, as every dispatch does. + const [entry] = readOutbox(chat.sessionId) + mutateStructuredAgentSessionLaunchPrompt(chat.sessionId, entry.clientMessageId, () => null) + await settle() + + expect(onDelivered).toHaveBeenCalledOnce() + expect(isNoteInFlight('note-a')).toBe(false) + }) + + it('come back to the shelf when the failed chat is closed', async () => { + mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) + const { onDelivered } = sendNotesToNewAgent() + await failTheStart() + + cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId) + await settle() + + expect(isNoteInFlight('note-a')).toBe(false) + expect(onDelivered).not.toHaveBeenCalled() + }) + + it('come back when a chat still starting is closed, without waiting on its create', async () => { + mocks.launch.mockImplementation(() => new Promise(() => undefined)) + const { onDelivered } = sendNotesToNewAgent() + + cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId) + await settle() + + expect(isNoteInFlight('note-a')).toBe(false) + expect(onDelivered).not.toHaveBeenCalled() + }) + + it("stay held by a paired server's failed chat, found once its start settles", async () => { + mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) + sendNotesToNewAgent({ paired: true }) + await failTheStart() + + expect(isNoteInFlight('note-a')).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/new-agent-prompt-outcome.ts b/src/renderer/src/lib/new-agent-prompt-outcome.ts new file mode 100644 index 00000000000..8fff9022213 --- /dev/null +++ b/src/renderer/src/lib/new-agent-prompt-outcome.ts @@ -0,0 +1,62 @@ +import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { watchStructuredAgentSessionOutboxEntry } from '@/components/native-chat/structured-agent-session-outbox-entry-watch' +import { structuredLaunchStates } from './structured-agent-session-launch-registry' + +export type NewAgentPromptOutcome = { delivered: boolean } + +function stagedLaunchPrompt( + sessionId: string, + text: string +): StructuredAgentSessionOutboxEntry | undefined { + return getStructuredAgentSessionOutbox(sessionId).findLast( + (entry) => + entry.source === 'launch' && + entry.body.blocks.some((block) => block.type === 'text' && block.text === text) + ) +} + +/** A failed or unconfirmed chat keeps its prompt staged for its own Retry or re-check. */ +function stagedInAnyLaunch(text: string): StructuredAgentSessionOutboxEntry | undefined { + let found: StructuredAgentSessionOutboxEntry | undefined + for (const state of structuredLaunchStates()) { + found = stagedLaunchPrompt(state.intent.sessionId, text) ?? found + } + return found +} + +function outcomeOf(entry: StructuredAgentSessionOutboxEntry): Promise { + return new Promise((resolve) => { + watchStructuredAgentSessionOutboxEntry(entry, (removal) => + resolve({ delivered: removal === 'spent' }) + ) + }) +} + +/** + * Settles once a new agent's prompt is sent on (delivered) or thrown away with its chat. A chat's + * staged prompt is the authority, so a Retry or re-check that sends it later still counts, and a + * failed start does not hand the text back while that chat still holds it. + */ +export function newAgentPromptOutcome(args: { + prompt: string + sessionId?: string + delivery: Promise<{ delivered: boolean }> +}): Promise { + const text = args.prompt.trim() + const staged = args.sessionId ? stagedLaunchPrompt(args.sessionId, text) : undefined + if (staged) { + return outcomeOf(staged) + } + // A paired server's chat exists only once it is admitted, so look again after the start. + const afterStart = ( + delivered: boolean + ): Promise | NewAgentPromptOutcome => { + const kept = delivered ? undefined : stagedInAnyLaunch(text) + return kept ? outcomeOf(kept) : { delivered } + } + return args.delivery.then( + (result) => afterStart(result.delivered), + () => afterStart(false) + ) +} diff --git a/src/renderer/src/lib/notes-send-in-flight.ts b/src/renderer/src/lib/notes-send-in-flight.ts index b8f221a6e0e..cd50f23dc7d 100644 --- a/src/renderer/src/lib/notes-send-in-flight.ts +++ b/src/renderer/src/lib/notes-send-in-flight.ts @@ -15,11 +15,32 @@ function changed(): void { } } -/** Takes `keys` out of the next send until `delivered` settles, whatever its result. */ -export function holdNotesForSend(keys: readonly unknown[], delivered: Promise): void { +function reportsDelivered(result: unknown): boolean { + return typeof result === 'object' && result !== null && 'delivered' in result + ? result.delivered === true + : false +} + +/** Takes `keys` out of the next send until `delivered` settles, whatever its result. A result that + * reports delivery also runs `onDelivered`, for a send whose own callback can no longer fire + * (a Retry of a failed new chat). */ +export function holdNotesForSend( + keys: readonly unknown[], + delivered: Promise, + onDelivered?: () => void +): void { if (keys.length === 0) { return } + // Registered before the release, so delivered notes are gone before they could show again. + void delivered.then( + (result) => { + if (reportsDelivered(result)) { + onDelivered?.() + } + }, + () => undefined + ) for (const key of keys) { holds.set(key, (holds.get(key) ?? 0) + 1) } @@ -42,16 +63,18 @@ export function isNoteInFlight(key: unknown): boolean { return holds.has(key) } +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +function getVersion(): number { + return version +} + /** 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 - ) + return useSyncExternalStore(subscribe, getVersion, getVersion) } /** A note's identity for delivery: an edit makes it a new pending note, as for its removal. */ From b88d0dc2cc78be26b0fe0b33fd76285b2172447d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:45:41 -0700 Subject: [PATCH 13/16] fix(notes): a new chat's saved message keeps its notes out of another send across a reload The hold that keeps notes sent to a new agent out of the next send lived only in memory. A reload while that chat had not yet sent them put the notes back on the shelf while the chat's saved message still carried their text, so a second "Send notes" sent them twice. The staged message now saves the send keys of the notes it was built from. The shelf treats a note as on its way while any saved message carries its key, read from the saved outboxes on first use and kept current by the outbox's one write funnel. When a message carrying notes is sent on (its own start, a Retry or the re-check), those notes are cleared from their shelf; when it is thrown away with its chat, they come back. Browser annotations sent to a new chat use the same keys while the app runs. Running-agent sends keep their in-memory hold. This replaces the per-message watch and outcome promise from the previous change. --- .../BrowserAnnotationSendMenuContent.tsx | 7 +- .../browser-guest-annotate-overlays.tsx | 4 +- .../browser-page-annotation-tray.test.tsx | 2 +- .../annotate/browser-page-annotation-tray.tsx | 7 +- .../use-browser-page-annotation-send.test.tsx | 6 +- .../use-browser-page-annotation-send.ts | 23 +- .../browser-page-chrome-banners.tsx | 7 +- .../browser-page-chrome-header.tsx | 2 +- .../components/editor/NotesSendMenu.test.tsx | 25 +- .../src/components/editor/NotesSendMenu.tsx | 19 +- .../ReviewNotesSendMenuContent.test.tsx | 14 +- .../editor/ReviewNotesSendMenuContent.tsx | 13 +- ...ured-agent-session-outbox-carried-notes.ts | 116 ++++++++ ...ctured-agent-session-outbox-entry-watch.ts | 63 ---- ...structured-agent-session-outbox-storage.ts | 44 ++- .../QuickLaunchButton.launch-status.test.tsx | 14 +- .../components/tab-bar/QuickLaunchButton.tsx | 26 +- .../src/lib/agent-session-launch-plan.ts | 7 +- .../src/lib/launch-agent-in-new-tab.ts | 6 +- .../src/lib/new-agent-prompt-outcome.test.ts | 234 --------------- .../src/lib/new-agent-prompt-outcome.ts | 62 ---- .../src/lib/notes-carried-by-new-chat.test.ts | 271 ++++++++++++++++++ .../src/lib/notes-delivered-by-chat.ts | 42 +++ src/renderer/src/lib/notes-send-in-flight.ts | 99 ++++--- ...structured-agent-session-launch-callers.ts | 2 + .../lib/structured-agent-session-launch.ts | 8 +- src/renderer/src/main.tsx | 2 + src/shared/structured-agent-session-outbox.ts | 7 + 28 files changed, 642 insertions(+), 490 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-carried-notes.ts delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-entry-watch.ts delete mode 100644 src/renderer/src/lib/new-agent-prompt-outcome.test.ts delete mode 100644 src/renderer/src/lib/new-agent-prompt-outcome.ts create mode 100644 src/renderer/src/lib/notes-carried-by-new-chat.test.ts create mode 100644 src/renderer/src/lib/notes-delivered-by-chat.ts diff --git a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx index faceaab0f0b..1342aac2da6 100644 --- a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx +++ b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx @@ -1,12 +1,13 @@ import React from 'react' import { ReviewNotesSendMenuContent } from '@/components/editor/ReviewNotesSendMenuContent' +import type { NotesSendHandOff } from '@/lib/notes-send-in-flight' export type BrowserAnnotationSendMenuContentProps = { worktreeId: string groupId: string prompt: string onPromptDelivered?: () => void - onPromptHandedOff?: (delivered: Promise) => void + notesHandOff?: NotesSendHandOff } export function BrowserAnnotationSendMenuContent({ @@ -14,7 +15,7 @@ export function BrowserAnnotationSendMenuContent({ groupId, prompt, onPromptDelivered, - onPromptHandedOff + notesHandOff }: 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 c8141bd0685..46c368b5ce4 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,7 +75,7 @@ export function BrowserGuestAnnotateOverlays({ activeGroupId, browserAnnotationsPrompt, handleBrowserAnnotationsSentToAgent, - handleBrowserAnnotationsHandedOff, + browserAnnotationsHandOff, handleCopyBrowserAnnotations, browserAnnotationsCopied, handleClearBrowserAnnotations, @@ -120,7 +120,7 @@ export function BrowserGuestAnnotateOverlays({ activeGroupId={activeGroupId} browserAnnotationsPrompt={browserAnnotationsPrompt} handleBrowserAnnotationsSentToAgent={handleBrowserAnnotationsSentToAgent} - handleBrowserAnnotationsHandedOff={handleBrowserAnnotationsHandedOff} + browserAnnotationsHandOff={browserAnnotationsHandOff} handleCopyBrowserAnnotations={handleCopyBrowserAnnotations} browserAnnotationsCopied={browserAnnotationsCopied} handleClearBrowserAnnotations={handleClearBrowserAnnotations} 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 162e1c64bf0..27b28a13fdc 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,7 +87,7 @@ function renderTray(currentUrl?: string): { activeGroupId={undefined} browserAnnotationsPrompt="prompt" handleBrowserAnnotationsSentToAgent={vi.fn()} - handleBrowserAnnotationsHandedOff={vi.fn()} + browserAnnotationsHandOff={{ carriedNoteKeys: [], handOff: vi.fn() }} handleCopyBrowserAnnotations={vi.fn()} browserAnnotationsCopied={false} handleClearBrowserAnnotations={vi.fn()} 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 6211684fb54..f2bbeaab055 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 @@ -19,6 +19,7 @@ import { BROWSER_ANNOTATION_INTENT_OPTIONS } from '../describe-page/browser-anno import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent' import { preventAgentSendTargetOutsideDismiss } from './prevent-agent-send-target-outside-dismiss' import { browserAnnotationMatchesPageUrl } from './browser-annotation-page-url' +import type { NotesSendHandOff } from '@/lib/notes-send-in-flight' export function BrowserPageAnnotationTray({ browserAnnotations, @@ -29,7 +30,7 @@ export function BrowserPageAnnotationTray({ activeGroupId, browserAnnotationsPrompt, handleBrowserAnnotationsSentToAgent, - handleBrowserAnnotationsHandedOff, + browserAnnotationsHandOff, handleCopyBrowserAnnotations, browserAnnotationsCopied, handleClearBrowserAnnotations, @@ -44,7 +45,7 @@ export function BrowserPageAnnotationTray({ activeGroupId: string | undefined browserAnnotationsPrompt: string handleBrowserAnnotationsSentToAgent: () => void - handleBrowserAnnotationsHandedOff: (delivered: Promise) => void + browserAnnotationsHandOff: NotesSendHandOff handleCopyBrowserAnnotations: () => void browserAnnotationsCopied: boolean handleClearBrowserAnnotations: () => void @@ -142,7 +143,7 @@ export function BrowserPageAnnotationTray({ groupId={activeGroupId ?? worktreeId} prompt={browserAnnotationsPrompt} onPromptDelivered={handleBrowserAnnotationsSentToAgent} - onPromptHandedOff={handleBrowserAnnotationsHandedOff} + notesHandOff={browserAnnotationsHandOff} /> 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 8eb9934c5a8..e68078dfd83 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 @@ -228,7 +228,7 @@ describe('website annotations handed to a send', () => { const firstDelivered = view.result.current.handleBrowserAnnotationsSentToAgent let deliverFirst!: (result: { delivered: boolean }) => void act(() => - view.result.current.handleBrowserAnnotationsHandedOff( + view.result.current.browserAnnotationsHandOff.handOff( new Promise((resolve) => (deliverFirst = resolve)) ) ) @@ -249,7 +249,7 @@ describe('website annotations handed to a send', () => { 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)) + act(() => view.result.current.browserAnnotationsHandOff.handOff(delivered)) expect(view.result.current.browserAnnotationsPrompt).toBe('') await act(async () => { @@ -269,7 +269,7 @@ describe('website annotations handed to a send', () => { it('offers no send while every annotation is already on its way', () => { const view = mount() - act(() => view.result.current.handleBrowserAnnotationsHandedOff(new Promise(() => undefined))) + act(() => view.result.current.browserAnnotationsHandOff.handOff(new Promise(() => undefined))) act(() => view.result.current.handleAnnotationTraySendOpenChange(true)) expect(mode).toBeUndefined() 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 700094fe874..02e42a0eed2 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 @@ -17,8 +17,10 @@ import type { import { formatBrowserAnnotationsAsMarkdown } from './browser-annotation-output' import { EMPTY_BROWSER_ANNOTATIONS } from '../describe-page/browser-annotation-geometry' import { - holdNotesForSend, + browserAnnotationSendKey, isNoteInFlight, + notesSendHandOff, + type NotesSendHandOff, useNotesInFlightVersion } from '@/lib/notes-send-in-flight' @@ -47,7 +49,7 @@ export function useBrowserPageAnnotationSend({ intent: BrowserAnnotationIntent ) => void handleBrowserAnnotationsSentToAgent: () => void - handleBrowserAnnotationsHandedOff: (delivered: Promise) => void + browserAnnotationsHandOff: NotesSendHandOff activeGroupId: string | undefined } { const browserAnnotations = useAppStore( @@ -66,7 +68,9 @@ export function useBrowserPageAnnotationSend({ const inFlightVersion = useNotesInFlightVersion() const sendableAnnotations = useMemo(() => { void inFlightVersion - return browserAnnotations.filter((annotation) => !isNoteInFlight(annotation)) + return browserAnnotations.filter( + (annotation) => !isNoteInFlight(browserAnnotationSendKey(annotation)) + ) }, [browserAnnotations, inFlightVersion]) const browserAnnotationsPrompt = useMemo( () => formatBrowserAnnotationsAsMarkdown(sendableAnnotations), @@ -118,10 +122,9 @@ export function useBrowserPageAnnotationSend({ removeDeliveredBrowserPageAnnotations ]) - const handleBrowserAnnotationsHandedOff = useCallback( - (delivered: Promise): void => - holdNotesForSend(sendableAnnotations, delivered, handleBrowserAnnotationsSentToAgent), - [handleBrowserAnnotationsSentToAgent, sendableAnnotations] + const browserAnnotationsHandOff = useMemo( + () => notesSendHandOff(sendableAnnotations.map(browserAnnotationSendKey)), + [sendableAnnotations] ) const handleClearBrowserAnnotations = useCallback((): void => { @@ -152,7 +155,7 @@ export function useBrowserPageAnnotationSend({ ), launchSource: 'notes_send', onPromptDelivered: handleBrowserAnnotationsSentToAgent, - onPromptHandedOff: handleBrowserAnnotationsHandedOff + onPromptHandedOff: browserAnnotationsHandOff.handOff }) } else { closeAgentSendPopoverTargetMode(modeId) @@ -161,7 +164,7 @@ export function useBrowserPageAnnotationSend({ [ browserAnnotationsPrompt, handleBrowserAnnotationsSentToAgent, - handleBrowserAnnotationsHandedOff, + browserAnnotationsHandOff, closeAgentSendPopoverTargetMode, openAgentSendPopoverTargetMode, worktreeId @@ -227,7 +230,7 @@ export function useBrowserPageAnnotationSend({ handleDeleteBrowserAnnotation, handleUpdateBrowserAnnotation, handleBrowserAnnotationsSentToAgent, - handleBrowserAnnotationsHandedOff, + browserAnnotationsHandOff, 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 5bced58139a..fc0b6651eff 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 @@ -14,6 +14,7 @@ import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-type import type { GrabModeHook } from '../annotate/useGrabMode' import { preventAgentSendTargetOutsideDismiss } from '../annotate/prevent-agent-send-target-outside-dismiss' import type { GrabIntent } from '../describe-page/browser-page-types' +import type { NotesSendHandOff } from '@/lib/notes-send-in-flight' export function BrowserPageChromeBanners({ resourceNotice, @@ -28,7 +29,7 @@ export function BrowserPageChromeBanners({ activeGroupId, browserAnnotationsPrompt, handleBrowserAnnotationsSentToAgent, - handleBrowserAnnotationsHandedOff, + browserAnnotationsHandOff, handleCopyBrowserAnnotations, browserAnnotationsCopied, handleClearBrowserAnnotations, @@ -46,7 +47,7 @@ export function BrowserPageChromeBanners({ activeGroupId: string | undefined browserAnnotationsPrompt: string handleBrowserAnnotationsSentToAgent: () => void - handleBrowserAnnotationsHandedOff: (delivered: Promise) => void + browserAnnotationsHandOff: NotesSendHandOff handleCopyBrowserAnnotations: () => void browserAnnotationsCopied: boolean handleClearBrowserAnnotations: () => void @@ -162,7 +163,7 @@ export function BrowserPageChromeBanners({ groupId={activeGroupId ?? worktreeId} prompt={browserAnnotationsPrompt} onPromptDelivered={handleBrowserAnnotationsSentToAgent} - onPromptHandedOff={handleBrowserAnnotationsHandedOff} + notesHandOff={browserAnnotationsHandOff} /> 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 b162a2ba34b..5b52ff63fe0 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,7 +121,7 @@ export function BrowserPageChromeHeader({ activeGroupId={annotationSend.activeGroupId} browserAnnotationsPrompt={annotationSend.browserAnnotationsPrompt} handleBrowserAnnotationsSentToAgent={annotationSend.handleBrowserAnnotationsSentToAgent} - handleBrowserAnnotationsHandedOff={annotationSend.handleBrowserAnnotationsHandedOff} + browserAnnotationsHandOff={annotationSend.browserAnnotationsHandOff} handleCopyBrowserAnnotations={annotationSend.handleCopyBrowserAnnotations} browserAnnotationsCopied={annotationSend.browserAnnotationsCopied} handleClearBrowserAnnotations={annotationSend.handleClearBrowserAnnotations} diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index b8758850895..9c1ddca6812 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -9,10 +9,10 @@ type ReactElementLike = { props: Record } -type TestNote = DiffCommentDeliverySnapshot +type TestNote = DiffCommentDeliverySnapshot & { worktreeId: string } function note(id: string): TestNote { - return { id, body: `body of ${id}`, filePath: 'README.md', lineNumber: 1 } + return { id, worktreeId: 'wt-1', body: `body of ${id}`, filePath: 'README.md', lineNumber: 1 } } const hookRuntime = vi.hoisted(() => ({ @@ -475,13 +475,20 @@ describe('NotesSendMenu notes in flight', () => { } return callback(...args) } + const handOffOf = (props: Record): Record => { + const handOff = props.notesHandOff + if (typeof handOff !== 'object' || handOff === null) { + throw new Error('notesHandOff is missing') + } + return { ...handOff } + } 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) + invoke(handOffOf(props), 'handOff', delivered) } } @@ -560,16 +567,4 @@ describe('NotesSendMenu notes in flight', () => { expect(findByType(tree, 'button').props.title).toBe('Sending…') }) - - // A failed new chat's Retry delivers them after the send's own callback is gone. - it('clears notes whose send reports delivery later', async () => { - const onDelivered = vi.fn() - const delivered = Promise.resolve({ delivered: true }) - contentProps(renderMenu({ scopes: scopeOf([noteA]), onDelivered })).onPromptHandedOff(delivered) - - await delivered - await Promise.resolve() - - expect(onDelivered).toHaveBeenCalledWith([noteA]) - }) }) diff --git a/src/renderer/src/components/editor/NotesSendMenu.tsx b/src/renderer/src/components/editor/NotesSendMenu.tsx index bb8cac0511a..288812a23cd 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.tsx @@ -17,7 +17,7 @@ import { ReviewNotesSendMenuContent } from './ReviewNotesSendMenuContent' import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments' import { diffCommentSendKey, - holdNotesForSend, + notesSendHandOff, isNoteInFlight, useNotesInFlightVersion } from '@/lib/notes-send-in-flight' @@ -69,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, @@ -122,10 +122,9 @@ export function NotesSendMenu({ }, [onDelivered] ) - const holdInFlight = useCallback( - (notes: readonly TNote[]) => (delivered: Promise) => - holdNotesForSend(notes.map(diffCommentSendKey), delivered, () => markDelivered(notes)), - [markDelivered] + const handOffNotes = useCallback( + (notes: readonly TNote[]) => notesSendHandOff(notes.map(diffCommentSendKey)), + [] ) const openTargetMode = useCallback( @@ -141,11 +140,11 @@ export function NotesSendMenu({ label: targetModeLabel ?? scope.label, launchSource: 'notes_send', onPromptDelivered: () => markDelivered(scope.notes), - onPromptHandedOff: holdInFlight(scope.notes) + onPromptHandedOff: handOffNotes(scope.notes).handOff }) }, [ - holdInFlight, + handOffNotes, markDelivered, openAgentSendPopoverTargetMode, source, @@ -278,7 +277,7 @@ export function NotesSendMenu({ promptDelivery="submit-after-ready" launchSource="notes_send" onPromptDelivered={() => markDelivered(scope.notes)} - onPromptHandedOff={holdInFlight(scope.notes)} + notesHandOff={handOffNotes(scope.notes)} /> @@ -296,7 +295,7 @@ export function NotesSendMenu({ markDelivered(defaultScope.notes) } }} - onPromptHandedOff={holdInFlight(defaultScope?.notes ?? [])} + notesHandOff={handOffNotes(defaultScope?.notes ?? [])} /> )} diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index af4c1e98445..051b58bc76b 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -650,7 +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() + const notesHandOff = { carriedNoteKeys: ['note-a'], handOff: vi.fn() } setStore({ tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] }, terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') } @@ -666,7 +666,7 @@ describe('ReviewNotesSendMenuContent', () => { } ] - const tree = render({ onPromptDelivered, onPromptHandedOff }) + const tree = render({ onPromptDelivered, notesHandOff }) ;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)() await flushMicrotasks() @@ -682,8 +682,8 @@ describe('ReviewNotesSendMenuContent', () => { 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(notesHandOff.handOff).toHaveBeenCalledOnce() + await notesHandOff.handOff.mock.calls[0][0] expect(onPromptDelivered).toHaveBeenCalledTimes(1) }) @@ -859,15 +859,15 @@ describe('ReviewNotesSendMenuContent', () => { }) it('always offers the new-agent launcher', () => { - const onPromptHandedOff = vi.fn() - const tree = render({ onPromptHandedOff }) + const notesHandOff = { carriedNoteKeys: ['note-a'], handOff: vi.fn() } + const tree = render({ notesHandOff }) expect(findByType(tree, 'QuickLaunchAgentMenuItems').props).toMatchObject({ worktreeId: 'wt-1', groupId: 'group-1', prompt: 'my notes', launchSource: 'notes_send', - onPromptHandedOff + notesHandOff }) }) diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 935d98dd986..c1ebe3d6229 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -34,6 +34,7 @@ import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows' import type { LaunchSource } from '../../../../shared/telemetry-events' import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state' import { translate } from '@/i18n/i18n' +import type { NotesSendHandOff } from '@/lib/notes-send-in-flight' type OrderedSendTarget = { target: NotesSendAgentTarget @@ -47,7 +48,7 @@ export function ReviewNotesSendMenuContent({ promptDelivery = 'submit-after-ready', launchSource = 'notes_send', onPromptDelivered, - onPromptHandedOff + notesHandOff }: { worktreeId: string groupId: string @@ -55,8 +56,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 + /** The notes this prompt was built from: held from each send's hand-off until its result. */ + notesHandOff?: NotesSendHandOff }): React.JSX.Element { const hasPrompt = prompt.trim().length > 0 @@ -150,9 +151,9 @@ export function ReviewNotesSendMenuContent({ { id: pending } ) }) - onPromptHandedOff?.(sending) + notesHandOff?.handOff(sending) }, - [onPromptHandedOff] + [notesHandOff] ) const sendToAgentTarget = useCallback( @@ -212,7 +213,7 @@ export function ReviewNotesSendMenuContent({ promptDelivery={promptDelivery} launchSource={launchSource} onPromptDelivered={onPromptDelivered} - onPromptHandedOff={onPromptHandedOff} + notesHandOff={notesHandOff} disabled={!hasPrompt} /> diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-carried-notes.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-carried-notes.ts new file mode 100644 index 00000000000..a865e12d744 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-carried-notes.ts @@ -0,0 +1,116 @@ +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' + +/** How a message left its chat's outbox: sent on (accepted, or handed to the host or the + * composer), or thrown away with the chat. */ +export type StructuredAgentSessionOutboxRemoval = 'spent' | 'discarded' + +// Why: the notes a chat's queued messages carry, read from the saved outboxes once and kept current +// by the outbox's one write funnel. A message's notes go when it does, so nothing here outlives it. +const carriedBySession = new Map() +let loaded = false +let version = 0 +const changeListeners = new Set<() => void>() +const spentListeners = new Set<(keys: readonly string[]) => void>() + +function carriedKeys(entries: readonly StructuredAgentSessionOutboxEntry[]): string[] { + return entries.flatMap((entry) => entry.carriedNoteKeys ?? []) +} + +function setCarried( + sessionId: string, + entries: readonly StructuredAgentSessionOutboxEntry[] +): boolean { + const next = carriedKeys(entries) + const previous = carriedBySession.get(sessionId) ?? [] + if (next.length === 0) { + carriedBySession.delete(sessionId) + } else { + carriedBySession.set(sessionId, next) + } + return next.length !== previous.length || next.some((key, index) => key !== previous[index]) +} + +function changed(): void { + version += 1 + for (const listener of changeListeners) { + listener() + } +} + +/** Reads every saved outbox once, the first time the notes they carry are asked about. */ +export function loadStructuredAgentSessionCarriedNotes( + saved: () => Iterable +): void { + if (loaded) { + return + } + loaded = true + for (const [sessionId, entries] of saved()) { + setCarried(sessionId, entries) + } +} + +// A user's Retry of a refused message gives it a new id; its text and queue time stay. +function stillQueued( + message: StructuredAgentSessionOutboxEntry, + entries: readonly StructuredAgentSessionOutboxEntry[] +): boolean { + const body = JSON.stringify(message.body) + return entries.some( + (entry) => + entry.clientMessageId === message.clientMessageId || + (entry.queuedAt === message.queuedAt && JSON.stringify(entry.body) === body) + ) +} + +/** Run by the outbox's write funnel once `after` replaces `before` as the session's outbox. */ +export function recordStructuredAgentSessionCarriedNotes( + sessionId: string, + before: readonly StructuredAgentSessionOutboxEntry[], + after: readonly StructuredAgentSessionOutboxEntry[], + removal: StructuredAgentSessionOutboxRemoval +): void { + const spent = + removal === 'spent' ? carriedKeys(before.filter((message) => !stillQueued(message, after))) : [] + // A spent message's notes are cleared before they could show as sendable again. + if (spent.length > 0) { + for (const listener of spentListeners) { + listener(spent) + } + } + if (setCarried(sessionId, after)) { + changed() + } +} + +export function carriedNotesInclude(key: string): boolean { + for (const keys of carriedBySession.values()) { + if (keys.includes(key)) { + return true + } + } + return false +} + +export function subscribeToStructuredAgentSessionCarriedNotes(listener: () => void): () => void { + changeListeners.add(listener) + return () => changeListeners.delete(listener) +} + +export function structuredAgentSessionCarriedNotesVersion(): number { + return version +} + +/** Told the notes of every message sent on, whichever send or Retry sent it. */ +export function subscribeToStructuredAgentSessionCarriedNotesSpent( + listener: (keys: readonly string[]) => void +): () => void { + spentListeners.add(listener) + return () => spentListeners.delete(listener) +} + +export function resetStructuredAgentSessionCarriedNotesForTests(): void { + carriedBySession.clear() + loaded = false + changed() +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-entry-watch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-entry-watch.ts deleted file mode 100644 index 71644f31340..00000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-entry-watch.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' - -/** How a watched message left its chat's outbox: sent on (accepted, or handed to the host or the - * composer), or thrown away with the chat. */ -export type StructuredAgentSessionOutboxEntryRemoval = 'spent' | 'discarded' - -type EntryWatch = { - entry: StructuredAgentSessionOutboxEntry - onGone: (removal: StructuredAgentSessionOutboxEntryRemoval) => void -} - -const watchesBySession = new Map>() - -// A user's Retry of a refused message gives it a new id; its text and queue time stay. -function stillQueued( - watched: StructuredAgentSessionOutboxEntry, - entries: readonly StructuredAgentSessionOutboxEntry[] -): boolean { - const body = JSON.stringify(watched.body) - return entries.some( - (entry) => - entry.clientMessageId === watched.clientMessageId || - (entry.queuedAt === watched.queuedAt && JSON.stringify(entry.body) === body) - ) -} - -/** Calls `onGone` once, when `entry` leaves its session's outbox. */ -export function watchStructuredAgentSessionOutboxEntry( - entry: StructuredAgentSessionOutboxEntry, - onGone: (removal: StructuredAgentSessionOutboxEntryRemoval) => void -): () => void { - const watches = watchesBySession.get(entry.sessionId) ?? new Set() - watchesBySession.set(entry.sessionId, watches) - const watch: EntryWatch = { entry, onGone } - watches.add(watch) - return () => { - watches.delete(watch) - if (watches.size === 0 && watchesBySession.get(entry.sessionId) === watches) { - watchesBySession.delete(entry.sessionId) - } - } -} - -/** Run by the outbox's one write funnel after a change takes effect. */ -export function settleStructuredAgentSessionOutboxEntryWatches( - sessionId: string, - entries: readonly StructuredAgentSessionOutboxEntry[], - removal: StructuredAgentSessionOutboxEntryRemoval -): void { - const watches = watchesBySession.get(sessionId) - if (!watches) { - return - } - for (const watch of watches) { - if (!stillQueued(watch.entry, entries)) { - watches.delete(watch) - watch.onGone(removal) - } - } - if (watches.size === 0) { - watchesBySession.delete(sessionId) - } -} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index 93c38669226..2637954aebb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -7,9 +7,11 @@ import { import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { createBrowserUuid } from '@/lib/browser-uuid' import { - settleStructuredAgentSessionOutboxEntryWatches, - type StructuredAgentSessionOutboxEntryRemoval -} from './structured-agent-session-outbox-entry-watch' + carriedNotesInclude, + loadStructuredAgentSessionCarriedNotes, + recordStructuredAgentSessionCarriedNotes, + type StructuredAgentSessionOutboxRemoval +} from './structured-agent-session-outbox-carried-notes' const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:' @@ -171,8 +173,9 @@ function commitOutbox( sessionId: string, entries: StructuredAgentSessionOutboxEntry[], options: { onlyIfSaved?: boolean }, - removal: StructuredAgentSessionOutboxEntryRemoval + removal: StructuredAgentSessionOutboxRemoval ): boolean { + const before = getStructuredAgentSessionOutbox(sessionId) const saved = writeOutbox(sessionId, entries) if (!saved && options.onlyIfSaved) { return false @@ -184,17 +187,38 @@ function commitOutbox( listener() } } - settleStructuredAgentSessionOutboxEntryWatches(sessionId, entries, removal) + recordStructuredAgentSessionCarriedNotes(sessionId, before, entries, removal) return saved } +function* savedOutboxes(): Generator { + // Storage-less hosts (tests, a blocked profile) have no saved outboxes. + if (typeof localStorage === 'undefined') { + return + } + for (let index = 0; index < localStorage.length; index += 1) { + const key = localStorage.key(index) + if (key?.startsWith(OUTBOX_PREFIX)) { + const sessionId = decodeURIComponent(key.slice(OUTBOX_PREFIX.length)) + yield [sessionId, readOutbox(sessionId, { recoverDispatching: false })] + } + } +} + +/** Whether a saved message still carries this note: it stays out of another send until then. */ +export function structuredAgentSessionOutboxCarriesNote(key: string): boolean { + loadStructuredAgentSessionCarriedNotes(savedOutboxes) + return carriedNotesInclude(key) +} + /** Queues a user message on the session's outbox: the one enqueue the composer, a launch prompt, * and a message sent from outside the chat all share. */ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - source?: 'launch' + source?: 'launch', + carriedNoteKeys?: readonly string[] ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ @@ -204,7 +228,8 @@ export function appendStructuredAgentSessionOutboxMessage( attachments, queuedAt: Date.now() }), - ...(source ? { source } : {}) + ...(source ? { source } : {}), + ...(carriedNoteKeys?.length ? { carriedNoteKeys: [...carriedNoteKeys] } : {}) } return commitStructuredAgentSessionOutbox( sessionId, @@ -217,9 +242,10 @@ export function appendStructuredAgentSessionOutboxMessage( export function enqueueStructuredAgentSessionLaunchPrompt( sessionId: string, - text: string + text: string, + carriedNoteKeys?: readonly string[] ): StructuredAgentSessionOutboxEntry | null { - return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch') + return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch', carriedNoteKeys) } export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.launch-status.test.tsx index 1f06c3307c3..9de078f885a 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 @@ -180,14 +180,14 @@ describe('QuickLaunchAgentMenuItems launch status', () => { 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 outcome to the notes menu", async () => { + // Why: a new chat saves the notes' keys with its message; the result holds them until then. + it("saves the notes' keys with the launch and holds them on its own result", () => { 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() + const notesHandOff = { carriedNoteKeys: ['note-a'], handOff: vi.fn() } render( { onFocusTerminal={vi.fn()} prompt="review notes" promptDelivery="submit-after-ready" - onPromptHandedOff={onPromptHandedOff} + notesHandOff={notesHandOff} /> ) fireEvent.click(document.querySelector('[title="Launch Codex in a new terminal"]')!) - expect(onPromptHandedOff).toHaveBeenCalledOnce() - await expect(onPromptHandedOff.mock.calls[0][0]).resolves.toEqual({ delivered: true }) + expect(launchMock).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'review notes', carriedNoteKeys: ['note-a'] }) + ) + expect(notesHandOff.handOff).toHaveBeenCalledWith(delivery) }) it('starts no agent when the menu has nothing left to send', () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index a5eda32b0e6..411baccf287 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -18,7 +18,7 @@ import { import { translate } from '@/i18n/i18n' import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' import { structuredLaunchRequest } from '@/lib/structured-agent-session-launch-request' -import { newAgentPromptOutcome } from '@/lib/new-agent-prompt-outcome' +import type { NotesSendHandOff } from '@/lib/notes-send-in-flight' export type QuickLaunchAgentMenuItemsProps = { worktreeId: string @@ -39,8 +39,9 @@ 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 + /** The notes this prompt was built from: a new chat saves their keys with its message, and the + * launch's own delivery result holds them until then. */ + notesHandOff?: NotesSendHandOff /** Nothing to send: e.g. every note is already on its way, so no agent is started. */ disabled?: boolean } @@ -110,7 +111,7 @@ function QuickLaunchAgentMenuItemsInner({ promptDelivery, launchSource, onPromptDelivered, - onPromptHandedOff, + notesHandOff, disabled = false }: QuickLaunchAgentMenuItemsProps): React.JSX.Element | null { // Why: resolving only the SSH connectionId here made paired-runtime @@ -153,7 +154,8 @@ function QuickLaunchAgentMenuItemsInner({ ...(prompt !== undefined ? { prompt } : {}), ...(promptDelivery !== undefined ? { promptDelivery } : {}), ...(launchSource !== undefined ? { launchSource } : {}), - ...(onPromptDelivered !== undefined ? { onPromptDelivered } : {}) + ...(onPromptDelivered !== undefined ? { onPromptDelivered } : {}), + ...(notesHandOff ? { carriedNoteKeys: notesHandOff.carriedNoteKeys } : {}) }) if (!result) { toast.error( @@ -165,16 +167,8 @@ function QuickLaunchAgentMenuItemsInner({ ) return } - if (onPromptHandedOff && result.promptDeliveryResult) { - onPromptHandedOff( - newAgentPromptOutcome({ - prompt: prompt ?? '', - ...(result.surface.kind === 'local-agent-session' - ? { sessionId: result.surface.sessionId } - : {}), - delivery: result.promptDeliveryResult - }) - ) + if (result.promptDeliveryResult) { + notesHandOff?.handOff(result.promptDeliveryResult) } if (result.surface.kind !== 'local-terminal') { return @@ -210,7 +204,7 @@ function QuickLaunchAgentMenuItemsInner({ promptDelivery, launchSource, onPromptDelivered, - onPromptHandedOff, + notesHandOff, disabled ] ) diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index d4fa18551d4..1bc4cc7a14f 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -28,6 +28,7 @@ import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-sessio export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void + carriedNoteKeys?: readonly string[] } /** @@ -45,6 +46,8 @@ export type AgentSessionLaunchVerdict = { promptDelivery?: NativeChatLaunchPromptDelivery resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void + /** Send keys of the notes the prompt was built from; a structured launch saves them with it. */ + carriedNoteKeys?: readonly string[] } export type AgentSessionStructuredFeasibilityRequest = AgentLaunchRouteArgs & { @@ -81,6 +84,7 @@ function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): Structured ...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}), ...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}), ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}), + ...(verdict.carriedNoteKeys ? { carriedNoteKeys: verdict.carriedNoteKeys } : {}), ...(verdict.executionHostId ? { executionHostId: verdict.executionHostId } : {}) } } @@ -170,6 +174,7 @@ export function planAgentSessionLaunch( ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), ...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}), ...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}), - ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}) + ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}), + ...(request.carriedNoteKeys ? { carriedNoteKeys: request.carriedNoteKeys } : {}) }) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index eff738dde7f..6f565d4cb00 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -52,6 +52,8 @@ export type LaunchAgentInNewTabArgs = { launchPlatform?: NodeJS.Platform /** Called after the prompt is actually delivered to the agent input path. */ onPromptDelivered?: () => void + /** Send keys of the notes the prompt was built from; a structured chat saves them with it. */ + carriedNoteKeys?: readonly string[] /** * Called before `onPromptDelivered` when the paste was written without ever observing the * agent's composer, so the launch cannot claim the prompt arrived. Fires only on the @@ -115,6 +117,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent launchPlatform, onPromptDelivered, onPromptDeliveryUnconfirmed, + carriedNoteKeys, agentSessionLaunchPlan, pendingActivationSpawn, beforeSurfaceOpen @@ -179,7 +182,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent promptDelivery: viewModePromptDelivery, tuiCustomization: { cwd: initialCwd }, initialSessionOptions: startupPlan.sessionOptions, - onPromptDelivered + onPromptDelivered, + ...(carriedNoteKeys ? { carriedNoteKeys } : {}) }) if (plan?.route === 'structured-native-chat') { const structured = launchStructuredAgentFromNewTab({ diff --git a/src/renderer/src/lib/new-agent-prompt-outcome.test.ts b/src/renderer/src/lib/new-agent-prompt-outcome.test.ts deleted file mode 100644 index dd26ded38b2..00000000000 --- a/src/renderer/src/lib/new-agent-prompt-outcome.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -// @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(() => ({ - callStructuredAgentSession: vi.fn(), - createIntent: vi.fn(), - launch: 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: (intent: unknown) => intent, - restoreStructuredAgentSessionLaunchIntent: vi.fn(), - abandonStructuredAgentSessionLaunchIntent: vi.fn(), - 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: vi.fn(), - clearNativeChatLaunchDraft: vi.fn() - }), - 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 { - mutateStructuredAgentSessionLaunchPrompt, - readOutbox -} from '@/components/native-chat/structured-agent-session-outbox-storage' -import { - cancelStructuredAgentLaunch, - getStructuredAgentSessionLaunchLifecycle, - retryStructuredAgentSessionLaunch, - startStructuredAgentLaunch -} from './structured-agent-session-launch' -import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' -import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' -import { - holdNotesForSend, - isNoteInFlight, - resetNotesInFlightForTests -} from './notes-send-in-flight' -import { newAgentPromptOutcome } from './new-agent-prompt-outcome' - -const WORKTREE_ID = 'wt-notes-new-agent' -const NOTES = 'review notes' - -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 published(sessionId: string): RuntimeMobileSessionTabsResult { - return { - worktree: WORKTREE_ID, - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [ - { - type: 'agent-session', - id: 'tab-1', - title: 'Codex', - sessionId, - agent: 'codex', - isActive: false - } - ] - } -} - -async function settle(): Promise { - for (let i = 0; i < 20; i += 1) { - await Promise.resolve() - } -} - -const chat = launchIntent('session-notes') - -/** What the notes menu does with a "New agent" pick: the launch, then the hold on its outcome. */ -function sendNotesToNewAgent(options: { paired?: boolean } = {}) { - const onDelivered = vi.fn() - const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { - prompt: NOTES, - promptDelivery: 'submit-after-ready' - }) - holdNotesForSend( - ['note-a'], - newAgentPromptOutcome({ - prompt: NOTES, - ...(options.paired ? {} : { sessionId: launch.sessionId }), - delivery: launch.promptDeliveryResult! - }), - onDelivered - ) - return { launch, onDelivered } -} - -/** A start the host refused outright: the chat shows it failed, with Retry. */ -async function failTheStart(): Promise { - await settle() - expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, chat.sessionId)).toBe('failed') -} - -describe('notes sent to a new agent', () => { - beforeEach(() => { - vi.resetAllMocks() - localStorage.clear() - resetStructuredAgentLaunchPersistenceForTests() - resetStructuredAgentLaunchRegistryForTests() - resetNotesInFlightForTests() - mocks.createIntent.mockReturnValue(chat) - vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([published(chat.sessionId)]) - mocks.callStructuredAgentSession.mockResolvedValue({ - ok: true, - value: { submission: { dispatchState: 'accepted' } } - }) - }) - - it('leaves the shelf once the new chat delivers them', async () => { - mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 }) - const { onDelivered } = sendNotesToNewAgent() - expect(isNoteInFlight('note-a')).toBe(true) - - await settle() - - expect(onDelivered).toHaveBeenCalledOnce() - expect(isNoteInFlight('note-a')).toBe(false) - }) - - it('stay held, not resendable, while a failed chat keeps them for its Retry', async () => { - mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) - const { onDelivered } = sendNotesToNewAgent() - await failTheStart() - - expect(readOutbox(chat.sessionId)).toHaveLength(1) - expect(isNoteInFlight('note-a')).toBe(true) - expect(onDelivered).not.toHaveBeenCalled() - }) - - it("leave the shelf when that chat's Retry delivers them", async () => { - mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) - const { onDelivered } = sendNotesToNewAgent() - await failTheStart() - mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 }) - - expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, chat.sessionId)).toBe(true) - await settle() - // The open chat's own send accepts the staged prompt, as every dispatch does. - const [entry] = readOutbox(chat.sessionId) - mutateStructuredAgentSessionLaunchPrompt(chat.sessionId, entry.clientMessageId, () => null) - await settle() - - expect(onDelivered).toHaveBeenCalledOnce() - expect(isNoteInFlight('note-a')).toBe(false) - }) - - it('come back to the shelf when the failed chat is closed', async () => { - mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) - const { onDelivered } = sendNotesToNewAgent() - await failTheStart() - - cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId) - await settle() - - expect(isNoteInFlight('note-a')).toBe(false) - expect(onDelivered).not.toHaveBeenCalled() - }) - - it('come back when a chat still starting is closed, without waiting on its create', async () => { - mocks.launch.mockImplementation(() => new Promise(() => undefined)) - const { onDelivered } = sendNotesToNewAgent() - - cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId) - await settle() - - expect(isNoteInFlight('note-a')).toBe(false) - expect(onDelivered).not.toHaveBeenCalled() - }) - - it("stay held by a paired server's failed chat, found once its start settles", async () => { - mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) - sendNotesToNewAgent({ paired: true }) - await failTheStart() - - expect(isNoteInFlight('note-a')).toBe(true) - }) -}) diff --git a/src/renderer/src/lib/new-agent-prompt-outcome.ts b/src/renderer/src/lib/new-agent-prompt-outcome.ts deleted file mode 100644 index 8fff9022213..00000000000 --- a/src/renderer/src/lib/new-agent-prompt-outcome.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' -import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' -import { watchStructuredAgentSessionOutboxEntry } from '@/components/native-chat/structured-agent-session-outbox-entry-watch' -import { structuredLaunchStates } from './structured-agent-session-launch-registry' - -export type NewAgentPromptOutcome = { delivered: boolean } - -function stagedLaunchPrompt( - sessionId: string, - text: string -): StructuredAgentSessionOutboxEntry | undefined { - return getStructuredAgentSessionOutbox(sessionId).findLast( - (entry) => - entry.source === 'launch' && - entry.body.blocks.some((block) => block.type === 'text' && block.text === text) - ) -} - -/** A failed or unconfirmed chat keeps its prompt staged for its own Retry or re-check. */ -function stagedInAnyLaunch(text: string): StructuredAgentSessionOutboxEntry | undefined { - let found: StructuredAgentSessionOutboxEntry | undefined - for (const state of structuredLaunchStates()) { - found = stagedLaunchPrompt(state.intent.sessionId, text) ?? found - } - return found -} - -function outcomeOf(entry: StructuredAgentSessionOutboxEntry): Promise { - return new Promise((resolve) => { - watchStructuredAgentSessionOutboxEntry(entry, (removal) => - resolve({ delivered: removal === 'spent' }) - ) - }) -} - -/** - * Settles once a new agent's prompt is sent on (delivered) or thrown away with its chat. A chat's - * staged prompt is the authority, so a Retry or re-check that sends it later still counts, and a - * failed start does not hand the text back while that chat still holds it. - */ -export function newAgentPromptOutcome(args: { - prompt: string - sessionId?: string - delivery: Promise<{ delivered: boolean }> -}): Promise { - const text = args.prompt.trim() - const staged = args.sessionId ? stagedLaunchPrompt(args.sessionId, text) : undefined - if (staged) { - return outcomeOf(staged) - } - // A paired server's chat exists only once it is admitted, so look again after the start. - const afterStart = ( - delivered: boolean - ): Promise | NewAgentPromptOutcome => { - const kept = delivered ? undefined : stagedInAnyLaunch(text) - return kept ? outcomeOf(kept) : { delivered } - } - return args.delivery.then( - (result) => afterStart(result.delivered), - () => afterStart(false) - ) -} diff --git a/src/renderer/src/lib/notes-carried-by-new-chat.test.ts b/src/renderer/src/lib/notes-carried-by-new-chat.test.ts new file mode 100644 index 00000000000..699d577a7fc --- /dev/null +++ b/src/renderer/src/lib/notes-carried-by-new-chat.test.ts @@ -0,0 +1,271 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' +import type { DiffComment } from '../../../shared/diff-comment-types' +import type { BrowserPageAnnotation } from '../../../shared/browser-grab-types' +import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' + +type ShelfAnnotation = Pick + +const mocks = vi.hoisted(() => { + const shelf: { notes: DiffComment[]; annotations: ShelfAnnotation[] } = { + notes: [], + annotations: [] + } + return { + callStructuredAgentSession: vi.fn(), + createIntent: vi.fn(), + launch: vi.fn(), + refresh: vi.fn(), + clearDeliveredDiffComments: vi.fn(), + removeDeliveredBrowserPageAnnotations: vi.fn(), + shelf + } +}) + +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: (intent: unknown) => intent, + restoreStructuredAgentSessionLaunchIntent: vi.fn(), + abandonStructuredAgentSessionLaunchIntent: vi.fn(), + launchStructuredAgentSession: mocks.launch, + StructuredAgentSessionCreateRefusalError + } +}) + +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + refreshLocalStructuredSessionTabs: mocks.refresh +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.callStructuredAgentSession +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + unifiedTabsByWorktree: {}, + seedNativeChatLaunchDraft: vi.fn(), + clearNativeChatLaunchDraft: vi.fn(), + getDiffComments: () => mocks.shelf.notes, + clearDeliveredDiffComments: mocks.clearDeliveredDiffComments, + browserAnnotationsByPageId: { 'page-1': mocks.shelf.annotations }, + removeDeliveredBrowserPageAnnotations: mocks.removeDeliveredBrowserPageAnnotations + }), + subscribe: () => () => undefined + } +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +vi.mock('@/lib/agent-catalog', () => ({ + getAgentLabel: () => 'Codex', + getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }] +})) + +const WORKTREE_ID = 'wt-notes-new-agent' +const NOTES = 'review notes' + +const note: DiffComment = { + id: 'note-a', + worktreeId: WORKTREE_ID, + filePath: 'README.md', + lineNumber: 3, + body: 'tighten this', + createdAt: 1, + side: 'modified' +} + +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 published(sessionId: string): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'tab-1', + title: 'Codex', + sessionId, + agent: 'codex', + isActive: false + } + ] + } +} + +async function settle(): Promise { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } +} + +const chat = launchIntent('session-notes') + +/** A renderer as it starts: fresh modules over the same saved storage, the clearer installed. */ +async function renderer() { + const launch = await import('./structured-agent-session-launch') + const outbox = await import('@/components/native-chat/structured-agent-session-outbox-storage') + const inFlight = await import('./notes-send-in-flight') + const { installNotesDeliveredByChat } = await import('./notes-delivered-by-chat') + installNotesDeliveredByChat() + return { launch, outbox, inFlight, key: inFlight.diffCommentSendKey(note) } +} + +/** What "Send notes > New agent" does: the launch, carrying the notes' keys, held on its result. */ +async function sendNotesToNewAgent() { + const app = await renderer() + const handOff = app.inFlight.notesSendHandOff([app.key]) + const started = app.launch.startStructuredAgentLaunch(WORKTREE_ID, 'codex', { + prompt: NOTES, + promptDelivery: 'submit-after-ready', + carriedNoteKeys: handOff.carriedNoteKeys + }) + handOff.handOff(started.promptDeliveryResult!) + return app +} + +/** The chat's own send accepting its staged message, as the composer's drain or a Retry does. */ +function acceptStagedMessage(app: Awaited>): void { + const [message] = app.outbox.readOutbox(chat.sessionId) + app.outbox.mutateStructuredAgentSessionLaunchPrompt( + chat.sessionId, + message.clientMessageId, + () => null + ) +} + +describe('notes sent to a new agent', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + localStorage.clear() + mocks.shelf.notes = [note] + mocks.shelf.annotations = [] + mocks.createIntent.mockReturnValue(chat) + mocks.refresh.mockResolvedValue([published(chat.sessionId)]) + mocks.callStructuredAgentSession.mockResolvedValue({ + ok: true, + value: { submission: { dispatchState: 'accepted' } } + }) + }) + + it('save their keys with the staged message and leave the shelf once it is sent', async () => { + mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 }) + const app = await sendNotesToNewAgent() + expect(app.outbox.readOutbox(chat.sessionId)[0].carriedNoteKeys).toEqual([app.key]) + expect(app.inFlight.isNoteInFlight(app.key)).toBe(true) + + await settle() + + expect(mocks.clearDeliveredDiffComments).toHaveBeenCalledWith(WORKTREE_ID, [note]) + expect(app.inFlight.isNoteInFlight(app.key)).toBe(false) + }) + + it('stay held across a reload while the chat can still send them, and clear when it does', async () => { + mocks.launch.mockImplementation(() => new Promise(() => undefined)) + await sendNotesToNewAgent() + + vi.resetModules() + const reloaded = await renderer() + + expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(true) + acceptStagedMessage(reloaded) + expect(mocks.clearDeliveredDiffComments).toHaveBeenCalledWith(WORKTREE_ID, [note]) + expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(false) + }) + + it('come back to the shelf when the chat is closed after a reload', async () => { + mocks.launch.mockImplementation(() => new Promise(() => undefined)) + await sendNotesToNewAgent() + + vi.resetModules() + const reloaded = await renderer() + expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(true) + // Closing a chat throws its outbox away (tab retirement, cancel, workspace teardown). + reloaded.outbox.discardStructuredAgentSessionLaunchOutbox(chat.sessionId) + + expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(false) + expect(mocks.clearDeliveredDiffComments).not.toHaveBeenCalled() + }) + + it('stay held by a failed chat, clear when its Retry sends them, and return when it closes', async () => { + const { StructuredAgentSessionCreateRefusalError } = + await import('@/lib/launch-structured-agent-session') + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) + const app = await sendNotesToNewAgent() + await settle() + expect(app.launch.getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, chat.sessionId)).toBe( + 'failed' + ) + expect(app.inFlight.isNoteInFlight(app.key)).toBe(true) + + mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 }) + expect(app.launch.retryStructuredAgentSessionLaunch(WORKTREE_ID, chat.sessionId)).toBe(true) + await settle() + acceptStagedMessage(app) + + expect(mocks.clearDeliveredDiffComments).toHaveBeenCalledWith(WORKTREE_ID, [note]) + expect(app.inFlight.isNoteInFlight(app.key)).toBe(false) + }) + + it('return when a failed chat is closed', async () => { + const { StructuredAgentSessionCreateRefusalError } = + await import('@/lib/launch-structured-agent-session') + mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) + const app = await sendNotesToNewAgent() + await settle() + + app.launch.cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId) + + expect(app.inFlight.isNoteInFlight(app.key)).toBe(false) + expect(mocks.clearDeliveredDiffComments).not.toHaveBeenCalled() + }) + + it('clears browser annotations a new chat sends on', async () => { + mocks.launch.mockImplementation(() => new Promise(() => undefined)) + mocks.shelf.annotations = [ + { id: 'annotation-1', browserPageId: 'page-1', comment: 'Fix', intent: 'fix' } + ] + const app = await renderer() + const key = app.inFlight.browserAnnotationSendKey(mocks.shelf.annotations[0]) + app.launch.startStructuredAgentLaunch(WORKTREE_ID, 'codex', { + prompt: NOTES, + carriedNoteKeys: [key] + }) + + acceptStagedMessage(app) + + expect(mocks.removeDeliveredBrowserPageAnnotations).toHaveBeenCalledWith('page-1', [ + mocks.shelf.annotations[0] + ]) + }) +}) diff --git a/src/renderer/src/lib/notes-delivered-by-chat.ts b/src/renderer/src/lib/notes-delivered-by-chat.ts new file mode 100644 index 00000000000..15872b31e19 --- /dev/null +++ b/src/renderer/src/lib/notes-delivered-by-chat.ts @@ -0,0 +1,42 @@ +import { useAppStore } from '@/store' +import { subscribeToStructuredAgentSessionCarriedNotesSpent } from '@/components/native-chat/structured-agent-session-outbox-carried-notes' +import { + browserAnnotationSendKey, + diffCommentSendKey, + noteSendKeyOwner +} from './notes-send-in-flight' + +/** A new chat's message sent on, by its own start, a Retry or a re-check, clears the notes it + * carries from their shelf as a delivered send does, including after a reload. */ +export function installNotesDeliveredByChat(): () => void { + return subscribeToStructuredAgentSessionCarriedNotesSpent((keys) => { + const sent = new Set(keys) + const state = useAppStore.getState() + const worktreeIds = new Set() + const pageIds = new Set() + for (const key of keys) { + const owner = noteSendKeyOwner(key) + if (owner?.kind === 'diff-comment') { + worktreeIds.add(owner.owner) + } else if (owner?.kind === 'browser-annotation') { + pageIds.add(owner.owner) + } + } + for (const worktreeId of worktreeIds) { + const notes = state + .getDiffComments(worktreeId) + .filter((note) => sent.has(diffCommentSendKey(note))) + if (notes.length > 0) { + void state.clearDeliveredDiffComments(worktreeId, notes) + } + } + for (const pageId of pageIds) { + const annotations = (state.browserAnnotationsByPageId[pageId] ?? []).filter((annotation) => + sent.has(browserAnnotationSendKey(annotation)) + ) + if (annotations.length > 0) { + state.removeDeliveredBrowserPageAnnotations(pageId, annotations) + } + } + }) +} diff --git a/src/renderer/src/lib/notes-send-in-flight.ts b/src/renderer/src/lib/notes-send-in-flight.ts index cd50f23dc7d..88554ad00ed 100644 --- a/src/renderer/src/lib/notes-send-in-flight.ts +++ b/src/renderer/src/lib/notes-send-in-flight.ts @@ -1,13 +1,26 @@ import { useSyncExternalStore } from 'react' import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments' +import type { BrowserPageAnnotation } from '../../../shared/browser-grab-types' +import { structuredAgentSessionOutboxCarriesNote } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { + structuredAgentSessionCarriedNotesVersion, + subscribeToStructuredAgentSessionCarriedNotes +} from '@/components/native-chat/structured-agent-session-outbox-carried-notes' -// 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() +// Why: notes handed to a send leave the next send at once and come back only if that send fails, +// as a submitted composer clears and restores on error. A new chat's saved message carries its +// notes' keys, so those stay out for as long as the chat can still send them, reload included; +// other sends hold them in memory until their own result. Delivered notes are removed by their owner. +const holds = new Map() const listeners = new Set<() => void>() let version = 0 +/** What a notes send gives its route: the keys a new chat saves with its message, and the hold. */ +export type NotesSendHandOff = { + carriedNoteKeys: readonly string[] + handOff: (delivered: Promise) => void +} + function changed(): void { version += 1 for (const listener of listeners) { @@ -15,32 +28,11 @@ function changed(): void { } } -function reportsDelivered(result: unknown): boolean { - return typeof result === 'object' && result !== null && 'delivered' in result - ? result.delivered === true - : false -} - -/** Takes `keys` out of the next send until `delivered` settles, whatever its result. A result that - * reports delivery also runs `onDelivered`, for a send whose own callback can no longer fire - * (a Retry of a failed new chat). */ -export function holdNotesForSend( - keys: readonly unknown[], - delivered: Promise, - onDelivered?: () => void -): void { +/** Takes `keys` out of the next send until `delivered` settles, whatever its result. */ +export function holdNotesForSend(keys: readonly string[], delivered: Promise): void { if (keys.length === 0) { return } - // Registered before the release, so delivered notes are gone before they could show again. - void delivered.then( - (result) => { - if (reportsDelivered(result)) { - onDelivered?.() - } - }, - () => undefined - ) for (const key of keys) { holds.set(key, (holds.get(key) ?? 0) + 1) } @@ -59,17 +51,25 @@ export function holdNotesForSend( void delivered.then(release, release) } -export function isNoteInFlight(key: unknown): boolean { - return holds.has(key) +export function notesSendHandOff(keys: readonly string[]): NotesSendHandOff { + return { carriedNoteKeys: keys, handOff: (delivered) => holdNotesForSend(keys, delivered) } +} + +export function isNoteInFlight(key: string): boolean { + return holds.has(key) || structuredAgentSessionOutboxCarriesNote(key) } function subscribe(listener: () => void): () => void { listeners.add(listener) - return () => listeners.delete(listener) + const unsubscribeCarried = subscribeToStructuredAgentSessionCarriedNotes(listener) + return () => { + listeners.delete(listener) + unsubscribeCarried() + } } function getVersion(): number { - return version + return version + structuredAgentSessionCarriedNotesVersion() } /** Changes whenever a hold starts or ends, for memos that filter by `isNoteInFlight`. */ @@ -77,9 +77,13 @@ export function useNotesInFlightVersion(): number { return useSyncExternalStore(subscribe, getVersion, getVersion) } +type DiffCommentSendKeyNote = DiffCommentDeliverySnapshot & { worktreeId: string } + /** A note's identity for delivery: an edit makes it a new pending note, as for its removal. */ -export function diffCommentSendKey(note: DiffCommentDeliverySnapshot): string { +export function diffCommentSendKey(note: DiffCommentSendKeyNote): string { return JSON.stringify([ + 'diff-comment', + note.worktreeId, note.id, note.body, note.filePath, @@ -90,6 +94,37 @@ export function diffCommentSendKey(note: DiffCommentDeliverySnapshot): string { ]) } +export function browserAnnotationSendKey( + annotation: Pick +): string { + return JSON.stringify([ + 'browser-annotation', + annotation.browserPageId, + annotation.id, + annotation.comment, + annotation.intent + ]) +} + +/** Where a saved key's note lives (its workspace or browser page), or null for a key this build + * does not read. The note itself is found by its key. */ +export function noteSendKeyOwner( + key: string +): { kind: 'diff-comment' | 'browser-annotation'; owner: string } | null { + try { + const parts: unknown = JSON.parse(key) + if (!Array.isArray(parts)) { + return null + } + const [kind, owner] = parts + return (kind === 'diff-comment' || kind === 'browser-annotation') && typeof owner === 'string' + ? { kind, owner } + : null + } catch { + return null + } +} + export function resetNotesInFlightForTests(): void { holds.clear() changed() 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 0015fbd8f75..fa0f02f73d7 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -10,6 +10,8 @@ export type StructuredAgentLaunchOptions = { prompt?: string promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void + /** Send keys of the notes the prompt was built from, saved with its staged message. */ + carriedNoteKeys?: readonly string[] /** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's * identity, not a preference — see `launchIdentity`. */ resumeFrom?: StructuredAgentSessionResumeSource diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index d2a174110c6..25dba85a1a3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -174,7 +174,11 @@ function joinStructuredLaunchState( // 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) + ? enqueueStructuredAgentSessionLaunchPrompt( + existing.intent.sessionId, + text, + options.carriedNoteKeys + ) : (repeat?.stagedEntry ?? null) // An unstaged claim stays unclaimed: the new launch it falls to reports the failure. if (claim && text && !stagedPrompt) { @@ -226,7 +230,7 @@ function structuredAgentLaunchState( ) const text = outboxPromptText(options) const stagedPrompt = text - ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text) + ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text, options.carriedNoteKeys) : null launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options) const callers = createStructuredLaunchCallerGroup({ diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 36239d52516..b70b662dc8a 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -24,11 +24,13 @@ import { getOrCreateRendererRoot } from './lib/react-renderer-root' import { primeTerminalWebglAddon } from './lib/pane-manager/pane-webgl-renderer' import { SkillWarningPreviewLauncher } from './components/skills/SkillWarningPreviewLauncher' import { installBrowserClientPageRenderer } from './components/browser-pane/browser-client-page-renderer-installation' +import { installNotesDeliveredByChat } from './lib/notes-delivered-by-chat' recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() installTypingLatencyDiagnostic() installAutomationHostDiagnostic() +installNotesDeliveredByChat() if ( import.meta.env.DEV && diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 7b3606f3dc8..01a75db22cd 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -35,6 +35,9 @@ export type StructuredAgentSessionOutboxEntry = { lastAttemptAt: number | null retryAfterUnknownSubmittedAt: number | null source?: 'launch' + /** The notes a launch prompt was built from, as their send keys. Saved with the message so the + * notes stay out of another send for as long as this message can still be sent, reload included. */ + carriedNoteKeys?: string[] /** A Stop landed after this queue send went out: only the user's Retry sends it again, never the * drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */ outlivedStop?: true @@ -298,6 +301,10 @@ export function parseStructuredAgentSessionOutboxEntry( ? entry.retryAfterUnknownSubmittedAt : null, ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), + ...(Array.isArray(entry.carriedNoteKeys) && + entry.carriedNoteKeys.every((key) => typeof key === 'string') + ? { carriedNoteKeys: entry.carriedNoteKeys } + : {}), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } From ae0b9f3fea67d3073be8c8336335ad7527ed65b1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:01:07 -0700 Subject: [PATCH 14/16] fix(notes): release a gone chat's notes, and clear sent ones in the web client too Two gaps in keeping notes out of another send while a saved chat message carries them: - The web client never installed the clearing that removes notes once a chat sends them on, so after a Retry there the notes stayed listed. It is now installed, once per renderer, by the background services both the desktop and the web client load with App. - A saved message carrying notes was thrown away only by this window's own close. A chat closed from the phone, another window or while Orca was off, or closed here without a known host, kept its notes held for good. A chat the host stops listing (affirmed, and not a launch still starting or failed) now has its queued messages thrown away once that sync lands, as does a close that can name no host, so the notes come back. --- .../src/app-shell/AppBackgroundServices.tsx | 5 + .../notes-delivered-by-chat-install.test.ts | 103 ++++++++++ .../src/lib/notes-delivered-by-chat.ts | 16 +- src/renderer/src/main.tsx | 2 - .../apply-preparation-browser.ts | 18 +- .../host-removed-structured-chats.test.ts | 189 ++++++++++++++++++ .../host-removed-structured-chats.ts | 13 ++ .../web-session-tabs-sync/store-patch.ts | 9 + .../tabs/structured-chat-close-owner.test.ts | 20 +- .../store/slices/tabs/tabs-close-actions.ts | 5 +- 10 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/app-shell/notes-delivered-by-chat-install.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.ts diff --git a/src/renderer/src/app-shell/AppBackgroundServices.tsx b/src/renderer/src/app-shell/AppBackgroundServices.tsx index ca22678decf..caea319fd9d 100644 --- a/src/renderer/src/app-shell/AppBackgroundServices.tsx +++ b/src/renderer/src/app-shell/AppBackgroundServices.tsx @@ -8,6 +8,11 @@ import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost' import { useAppStore } from '../store' import { StructuredAgentSessionAttentionBridge } from '../components/native-chat/StructuredAgentSessionAttentionBridge' import { StructuredAgentSessionStatusBridge } from '../components/native-chat/StructuredAgentSessionStatusBridge' +import { installNotesDeliveredByChat } from '../lib/notes-delivered-by-chat' + +// Why at load and not in an effect: both the desktop and the web client load this with App, before +// any chat can send its saved message. +installNotesDeliveredByChat() const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge')) diff --git a/src/renderer/src/app-shell/notes-delivered-by-chat-install.test.ts b/src/renderer/src/app-shell/notes-delivered-by-chat-install.test.ts new file mode 100644 index 00000000000..36455b4f4ae --- /dev/null +++ b/src/renderer/src/app-shell/notes-delivered-by-chat-install.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DiffComment } from '../../../shared/diff-comment-types' +import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' + +const mocks = vi.hoisted(() => ({ clearDelivered: vi.fn() })) + +const note: DiffComment = { + id: 'note-a', + worktreeId: 'wt-1', + filePath: 'README.md', + lineNumber: 3, + body: 'tighten this', + createdAt: 1, + side: 'modified' +} + +vi.mock('../store', () => ({ + useAppStore: Object.assign(() => false, { + getState: () => ({ + getDiffComments: () => [note], + clearDeliveredDiffComments: mocks.clearDelivered, + browserAnnotationsByPageId: {}, + removeDeliveredBrowserPageAnnotations: vi.fn() + }) + }) +})) +vi.mock('../components/AgentHibernationGate', () => ({ AgentHibernationGate: () => null })) +vi.mock('../components/AiVaultTabTitleSyncGate', () => ({ AiVaultTabTitleSyncGate: () => null })) +vi.mock('../components/dashboard/RetainedAgentsSyncGate', () => ({ default: () => null })) +vi.mock('../components/ports/WorkspacePortScanner', () => ({ WorkspacePortScanner: () => null })) +vi.mock('../hooks/MacosTccPromptNoticeHost', () => ({ MacosTccPromptNoticeHost: () => null })) +vi.mock('../components/native-chat/StructuredAgentSessionAttentionBridge', () => ({ + StructuredAgentSessionAttentionBridge: () => null +})) +vi.mock('../components/native-chat/StructuredAgentSessionStatusBridge', () => ({ + StructuredAgentSessionStatusBridge: () => null +})) + +const RENDERER = join(__dirname, '..') + +function carrying(keys: string[]): StructuredAgentSessionOutboxEntry { + return { + clientMessageId: 'message-1', + sessionId: 'chat-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'review notes' }] }, + previewUris: [], + state: 'rejected', + queuedAt: 1, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null, + source: 'launch', + carriedNoteKeys: keys + } +} + +describe('clearing notes a chat sends on', () => { + beforeEach(() => { + vi.resetModules() + mocks.clearDelivered.mockReset() + }) + + // The desktop and the web client both render App, which loads its background services. + it('is installed by the background services both app entries render', async () => { + expect(readFileSync(join(RENDERER, 'main.tsx'), 'utf8')).toContain("import App from './App'") + expect(readFileSync(join(RENDERER, 'web/main.tsx'), 'utf8')).toContain("import('../App')") + expect(readFileSync(join(RENDERER, 'App.tsx'), 'utf8')).toContain( + "from './app-shell/AppBackgroundServices'" + ) + + await import('./AppBackgroundServices') + const carried = + await import('../components/native-chat/structured-agent-session-outbox-carried-notes') + const { diffCommentSendKey } = await import('../lib/notes-send-in-flight') + carried.recordStructuredAgentSessionCarriedNotes( + 'chat-1', + [carrying([diffCommentSendKey(note)])], + [], + 'spent' + ) + + expect(mocks.clearDelivered).toHaveBeenCalledWith('wt-1', [note]) + }) + + it('runs once per renderer however often it is installed', async () => { + const { installNotesDeliveredByChat } = await import('../lib/notes-delivered-by-chat') + const carried = + await import('../components/native-chat/structured-agent-session-outbox-carried-notes') + const { diffCommentSendKey } = await import('../lib/notes-send-in-flight') + installNotesDeliveredByChat() + installNotesDeliveredByChat() + + carried.recordStructuredAgentSessionCarriedNotes( + 'chat-1', + [carrying([diffCommentSendKey(note)])], + [], + 'spent' + ) + + expect(mocks.clearDelivered).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/lib/notes-delivered-by-chat.ts b/src/renderer/src/lib/notes-delivered-by-chat.ts index 15872b31e19..43839295532 100644 --- a/src/renderer/src/lib/notes-delivered-by-chat.ts +++ b/src/renderer/src/lib/notes-delivered-by-chat.ts @@ -6,10 +6,15 @@ import { noteSendKeyOwner } from './notes-send-in-flight' +let uninstall: (() => void) | null = null + /** A new chat's message sent on, by its own start, a Retry or a re-check, clears the notes it - * carries from their shelf as a delivered send does, including after a reload. */ -export function installNotesDeliveredByChat(): () => void { - return subscribeToStructuredAgentSessionCarriedNotesSpent((keys) => { + * carries from their shelf as a delivered send does, including after a reload. Once per renderer. */ +export function installNotesDeliveredByChat(): void { + if (uninstall) { + return + } + uninstall = subscribeToStructuredAgentSessionCarriedNotesSpent((keys) => { const sent = new Set(keys) const state = useAppStore.getState() const worktreeIds = new Set() @@ -40,3 +45,8 @@ export function installNotesDeliveredByChat(): () => void { } }) } + +import.meta.hot?.dispose(() => { + uninstall?.() + uninstall = null +}) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index b70b662dc8a..36239d52516 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -24,13 +24,11 @@ import { getOrCreateRendererRoot } from './lib/react-renderer-root' import { primeTerminalWebglAddon } from './lib/pane-manager/pane-webgl-renderer' import { SkillWarningPreviewLauncher } from './components/skills/SkillWarningPreviewLauncher' import { installBrowserClientPageRenderer } from './components/browser-pane/browser-client-page-renderer-installation' -import { installNotesDeliveredByChat } from './lib/notes-delivered-by-chat' recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() installTypingLatencyDiagnostic() installAutomationHostDiagnostic() -installNotesDeliveredByChat() if ( import.meta.env.DEV && diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index 678b915d49c..c15105b80f0 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -21,6 +21,7 @@ import { } from './state-equality-files' import { shouldRetainStructuredAgentSessionLaunchTab } from '@/lib/structured-agent-session-launch-registry' import { executionHostIdForSessionTabsOwner } from '../local-structured-session-owner' +import { noteHostRemovedStructuredChat } from './host-removed-structured-chats' export function prepareWebSessionTabsSnapshotBrowser( base: ReturnType @@ -184,11 +185,18 @@ export function prepareWebSessionTabsSnapshotBrowser( // A matching host row is authoritative; retaining the provisional tab beside its mirror // would briefly render two panes before lifecycle publication is recorded. // A host that cannot list its chats is no evidence this one closed. - return ( - !publishedAgentSessionIds.has(tab.entityId) && - (!agentSessionsAffirmed || - shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId)) - ) + if (publishedAgentSessionIds.has(tab.entityId)) { + return false + } + if ( + !agentSessionsAffirmed || + shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId) + ) { + return true + } + // Listed before, affirmed absent now: the chat itself is gone, not a launch still starting. + noteHostRemovedStructuredChat(tab.entityId) + return false } if (tab.contentType === 'browser') { return ( diff --git a/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.test.ts new file mode 100644 index 00000000000..f3b6ef3a1df --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.test.ts @@ -0,0 +1,189 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' +import type { WebSessionTabsSyncState } from './state' + +const store = vi.hoisted(() => { + const state: Record = {} + return { state } +}) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ ...store.state, scheduleAgentStatusFreshness: () => undefined }), + setState: (update: (state: Record) => Record) => { + store.state = { ...store.state, ...update(store.state) } + } + } +})) + +vi.mock('@/lib/launch-structured-agent-session', () => { + class StructuredAgentSessionCreateRefusalError extends Error {} + return { + createStructuredAgentSessionLaunchIntent: (worktreeId: string) => ({ + worktreeId, + sessionId: 'launch-1', + executionHostId: 'local', + target: { kind: 'local' }, + agent: 'codex', + params: { + envelope: { + sessionId: 'launch-1', + clientOperationId: 'operation-launch-1', + expectedRuntimeFence: null, + payloadFingerprint: 'fingerprint-launch-1' + }, + worktree: `id:${worktreeId}`, + agent: 'codex' + } + }), + retryStructuredAgentSessionLaunchIntent: (intent: unknown) => intent, + restoreStructuredAgentSessionLaunchIntent: vi.fn(), + abandonStructuredAgentSessionLaunchIntent: vi.fn(), + launchStructuredAgentSession: () => + Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')), + StructuredAgentSessionCreateRefusalError + } +}) + +import { applyWebSessionTabsSnapshot } from '../web-session-tabs-sync' +import { applyWebSessionTabsStorePatch } from './store-patch' +import { + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState, + ENV, + NOW, + WT +} from '../web-session-tabs-sync-test-harness' +import { + appendStructuredAgentSessionOutboxMessage, + readOutbox +} from '@/components/native-chat/structured-agent-session-outbox-storage' +import { resetStructuredAgentSessionCarriedNotesForTests } from '@/components/native-chat/structured-agent-session-outbox-carried-notes' +import { isNoteInFlight } from '@/lib/notes-send-in-flight' +import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' +import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence' +import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { takeHostRemovedStructuredChats } from './host-removed-structured-chats' + +const NOTE_KEY = 'note-a' + +function chatTab(sessionId: string, id = `tab-${sessionId}`): Tab { + return { + id, + entityId: sessionId, + contentType: 'agent-session', + agentSessionAgent: 'codex', + worktreeId: WT, + groupId: 'group-1', + label: 'Codex', + customLabel: null, + color: null, + createdAt: 1, + sortOrder: 0 + } +} + +function windowWith(tabs: Tab[]): WebSessionTabsSyncState { + return makeState({ + unifiedTabsByWorktree: { [WT]: tabs }, + groupsByWorktree: { + [WT]: [ + { + id: 'group-1', + worktreeId: WT, + tabOrder: tabs.map((tab) => tab.id), + activeTabId: tabs[0]?.id ?? null + } + ] + }, + activeGroupIdByWorktree: { [WT]: 'group-1' } + }) +} + +/** A chat whose notes message the host refused: it waits on its Retry, carrying the notes. */ +function refusedNotesMessage(sessionId: string): void { + appendStructuredAgentSessionOutboxMessage(sessionId, 'review notes', [], 'launch', [NOTE_KEY]) +} + +/** The host's next list of chats, as this window's sync commits it. */ +function hostLists(sessions: string[]): void { + applyWebSessionTabsStorePatch( + (state) => + applyWebSessionTabsSnapshot( + state, + makeSnapshot( + sessions.map((sessionId) => ({ + type: 'agent-session' as const, + id: `agent-session:${sessionId}`, + sessionId, + agent: 'codex' as const, + title: 'Codex', + isActive: false + })), + { snapshotVersion: 2 } + ), + ENV, + NOW, + { contentScope: 'agent-session', preserveLocalLayout: true, terminalPtyMode: 'local' } + ), + { frames: [] } + ) +} + +describe('a chat the host stops listing', () => { + beforeEach(() => { + resetWebSessionTabsSyncTestState() + localStorage.clear() + resetStructuredAgentSessionCarriedNotesForTests() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + takeHostRemovedStructuredChats() + }) + + it('takes its queued messages with it, so the notes they carried come back', () => { + store.state = { ...windowWith([chatTab('chat-1')]) } + refusedNotesMessage('chat-1') + expect(isNoteInFlight(NOTE_KEY)).toBe(true) + + hostLists([]) + + expect(readOutbox('chat-1')).toEqual([]) + expect(isNoteInFlight(NOTE_KEY)).toBe(false) + }) + + it('keeps a chat the host still lists, under another local tab id', () => { + store.state = { ...windowWith([chatTab('chat-1', 'chat-1:history-1')]) } + refusedNotesMessage('chat-1') + + hostLists(['chat-1']) + + expect(readOutbox('chat-1')).toHaveLength(1) + expect(isNoteInFlight(NOTE_KEY)).toBe(true) + }) + + it("keeps a failed launch's messages for its Retry: the host never listed it", async () => { + const failed = startFailedLaunch() + await settle() + store.state = { ...windowWith([chatTab(failed)]) } + + hostLists([]) + + expect(readOutbox(failed)).toHaveLength(1) + expect(isNoteInFlight(NOTE_KEY)).toBe(true) + }) +}) + +function startFailedLaunch(): string { + return startStructuredAgentLaunch(WT, 'codex', { + prompt: 'review notes', + carriedNoteKeys: [NOTE_KEY] + }).sessionId +} + +async function settle(): Promise { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.ts b/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.ts new file mode 100644 index 00000000000..e3ae45fc06c --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.ts @@ -0,0 +1,13 @@ +// Why: a chat the host stopped listing is gone, wherever it was closed (the phone, another window, +// while Orca was off). Noted while a sync patch is prepared, acted on only once that patch lands. +const removedSessionIds = new Set() + +export function noteHostRemovedStructuredChat(sessionId: string): void { + removedSessionIds.add(sessionId) +} + +export function takeHostRemovedStructuredChats(): string[] { + const sessionIds = [...removedSessionIds] + removedSessionIds.clear() + return sessionIds +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts index db2758d4bbe..1f45178027c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts @@ -17,6 +17,8 @@ import { type HostSessionMirrorPatchVerdict, type HostSessionMirrorSettle } from './mirror-settle' +import { takeHostRemovedStructuredChats } from './host-removed-structured-chats' +import { discardStructuredAgentSessionLaunchOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' /** Commit a reconciliation patch and return a receipt for its host evidence. */ export function applyWebSessionTabsStorePatch( @@ -149,10 +151,13 @@ export function applyWebSessionTabsStorePatch( return patch } + // Only what this patch's own preparation notes. + takeHostRemovedStructuredChats() try { useAppStore.setState(runStorePatch) } catch (error) { if (!patchCommitted) { + takeHostRemovedStructuredChats() throw error } console.warn('[web-session-tabs-sync] a store subscriber failed after the patch landed:', error) @@ -160,6 +165,10 @@ export function applyWebSessionTabsStorePatch( const settleHostMirror = createHostSessionMirrorSettle(hostMirrorVerdict) try { + // A chat gone from its host takes its queued messages, and the notes they held, with it. + for (const sessionId of takeHostRemovedStructuredChats()) { + discardStructuredAgentSessionLaunchOutbox(sessionId) + } if (mirroredAgentStatusChanged) { useAppStore.getState().scheduleAgentStatusFreshness() } diff --git a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts index 0c9e658cac1..54df71490c5 100644 --- a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts +++ b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../../shared/tab-types' import { createTestStore, makeWorktree, seedStore } from '../store-test-helpers' +import type * as OutboxStorageModule from '@/components/native-chat/structured-agent-session-outbox-storage' -const mocks = vi.hoisted(() => ({ beginClose: vi.fn() })) +const mocks = vi.hoisted(() => ({ beginClose: vi.fn(), discardOutbox: vi.fn() })) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -10,6 +11,13 @@ vi.mock('sonner', () => ({ vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ beginStructuredAgentSessionTabClose: mocks.beginClose })) +vi.mock( + '@/components/native-chat/structured-agent-session-outbox-storage', + async (importOriginal) => ({ + ...(await importOriginal()), + discardStructuredAgentSessionLaunchOutbox: mocks.discardOutbox + }) +) // `repoId::path` names both checkouts: this machine's and the paired server's. const WORKTREE = 'repo-1::/work/app' @@ -58,6 +66,7 @@ function storeWith(tab: Tab): ReturnType { beforeEach(() => { mocks.beginClose.mockReset() + mocks.discardOutbox.mockReset() }) describe('closing a structured chat from outside its workspace', () => { @@ -82,4 +91,13 @@ describe('closing a structured chat from outside its workspace', () => { expect(mocks.beginClose).not.toHaveBeenCalled() expect(store.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) }) + + // Its queued messages go with it, as for any close, so notes they carried come back. + it('throws away the queued messages of a chat it can name no host for', () => { + const store = storeWith(chatTab()) + + store.getState().closeUnifiedTab('agent-session:chat-1') + + expect(mocks.discardOutbox).toHaveBeenCalledWith('chat-1') + }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index f4cd23a01d7..a2edefb0f95 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -9,6 +9,7 @@ import { } from '../tab-group-state' import { buildActiveSurfacePatch } from './tabs-surface' import { beginStructuredAgentSessionTabClose } from '@/runtime/structured-agent-session-tab-retirement' +import { discardStructuredAgentSessionLaunchOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' import { hasStructuredAgentSessionLaunchCancellationTombstone, shouldRetainStructuredAgentSessionLaunchTab @@ -69,8 +70,10 @@ export function createTabsCloseActions( provisional }) } else { - // Closing still removes the tab; no host can be named to stop its chat on. + // Closing still removes the tab; no host can be named to stop its chat on. Its queued + // messages go with it, as for any close, so notes they carried return to their shelf. console.warn('[structured-agent-session] close found no owning host', tab.entityId) + discardStructuredAgentSessionLaunchOutbox(tab.entityId) } get().clearNativeChatLaunchDraft(structuredAgentSessionTabId(tab.entityId)) } From 19cabc72b0dbeff95f304746e2da629456631c39 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:12:04 -0700 Subject: [PATCH 15/16] Revert "fix(notes): release a gone chat's notes, and clear sent ones in the web client too" This reverts commit ae0b9f3fea67d3073be8c8336335ad7527ed65b1. --- .../src/app-shell/AppBackgroundServices.tsx | 5 - .../notes-delivered-by-chat-install.test.ts | 103 ---------- .../src/lib/notes-delivered-by-chat.ts | 16 +- src/renderer/src/main.tsx | 2 + .../apply-preparation-browser.ts | 18 +- .../host-removed-structured-chats.test.ts | 189 ------------------ .../host-removed-structured-chats.ts | 13 -- .../web-session-tabs-sync/store-patch.ts | 9 - .../tabs/structured-chat-close-owner.test.ts | 20 +- .../store/slices/tabs/tabs-close-actions.ts | 5 +- 10 files changed, 12 insertions(+), 368 deletions(-) delete mode 100644 src/renderer/src/app-shell/notes-delivered-by-chat-install.test.ts delete mode 100644 src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.test.ts delete mode 100644 src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.ts diff --git a/src/renderer/src/app-shell/AppBackgroundServices.tsx b/src/renderer/src/app-shell/AppBackgroundServices.tsx index caea319fd9d..ca22678decf 100644 --- a/src/renderer/src/app-shell/AppBackgroundServices.tsx +++ b/src/renderer/src/app-shell/AppBackgroundServices.tsx @@ -8,11 +8,6 @@ import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost' import { useAppStore } from '../store' import { StructuredAgentSessionAttentionBridge } from '../components/native-chat/StructuredAgentSessionAttentionBridge' import { StructuredAgentSessionStatusBridge } from '../components/native-chat/StructuredAgentSessionStatusBridge' -import { installNotesDeliveredByChat } from '../lib/notes-delivered-by-chat' - -// Why at load and not in an effect: both the desktop and the web client load this with App, before -// any chat can send its saved message. -installNotesDeliveredByChat() const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge')) diff --git a/src/renderer/src/app-shell/notes-delivered-by-chat-install.test.ts b/src/renderer/src/app-shell/notes-delivered-by-chat-install.test.ts deleted file mode 100644 index 36455b4f4ae..00000000000 --- a/src/renderer/src/app-shell/notes-delivered-by-chat-install.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { DiffComment } from '../../../shared/diff-comment-types' -import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' - -const mocks = vi.hoisted(() => ({ clearDelivered: vi.fn() })) - -const note: DiffComment = { - id: 'note-a', - worktreeId: 'wt-1', - filePath: 'README.md', - lineNumber: 3, - body: 'tighten this', - createdAt: 1, - side: 'modified' -} - -vi.mock('../store', () => ({ - useAppStore: Object.assign(() => false, { - getState: () => ({ - getDiffComments: () => [note], - clearDeliveredDiffComments: mocks.clearDelivered, - browserAnnotationsByPageId: {}, - removeDeliveredBrowserPageAnnotations: vi.fn() - }) - }) -})) -vi.mock('../components/AgentHibernationGate', () => ({ AgentHibernationGate: () => null })) -vi.mock('../components/AiVaultTabTitleSyncGate', () => ({ AiVaultTabTitleSyncGate: () => null })) -vi.mock('../components/dashboard/RetainedAgentsSyncGate', () => ({ default: () => null })) -vi.mock('../components/ports/WorkspacePortScanner', () => ({ WorkspacePortScanner: () => null })) -vi.mock('../hooks/MacosTccPromptNoticeHost', () => ({ MacosTccPromptNoticeHost: () => null })) -vi.mock('../components/native-chat/StructuredAgentSessionAttentionBridge', () => ({ - StructuredAgentSessionAttentionBridge: () => null -})) -vi.mock('../components/native-chat/StructuredAgentSessionStatusBridge', () => ({ - StructuredAgentSessionStatusBridge: () => null -})) - -const RENDERER = join(__dirname, '..') - -function carrying(keys: string[]): StructuredAgentSessionOutboxEntry { - return { - clientMessageId: 'message-1', - sessionId: 'chat-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'review notes' }] }, - previewUris: [], - state: 'rejected', - queuedAt: 1, - lastAttemptAt: null, - retryAfterUnknownSubmittedAt: null, - source: 'launch', - carriedNoteKeys: keys - } -} - -describe('clearing notes a chat sends on', () => { - beforeEach(() => { - vi.resetModules() - mocks.clearDelivered.mockReset() - }) - - // The desktop and the web client both render App, which loads its background services. - it('is installed by the background services both app entries render', async () => { - expect(readFileSync(join(RENDERER, 'main.tsx'), 'utf8')).toContain("import App from './App'") - expect(readFileSync(join(RENDERER, 'web/main.tsx'), 'utf8')).toContain("import('../App')") - expect(readFileSync(join(RENDERER, 'App.tsx'), 'utf8')).toContain( - "from './app-shell/AppBackgroundServices'" - ) - - await import('./AppBackgroundServices') - const carried = - await import('../components/native-chat/structured-agent-session-outbox-carried-notes') - const { diffCommentSendKey } = await import('../lib/notes-send-in-flight') - carried.recordStructuredAgentSessionCarriedNotes( - 'chat-1', - [carrying([diffCommentSendKey(note)])], - [], - 'spent' - ) - - expect(mocks.clearDelivered).toHaveBeenCalledWith('wt-1', [note]) - }) - - it('runs once per renderer however often it is installed', async () => { - const { installNotesDeliveredByChat } = await import('../lib/notes-delivered-by-chat') - const carried = - await import('../components/native-chat/structured-agent-session-outbox-carried-notes') - const { diffCommentSendKey } = await import('../lib/notes-send-in-flight') - installNotesDeliveredByChat() - installNotesDeliveredByChat() - - carried.recordStructuredAgentSessionCarriedNotes( - 'chat-1', - [carrying([diffCommentSendKey(note)])], - [], - 'spent' - ) - - expect(mocks.clearDelivered).toHaveBeenCalledOnce() - }) -}) diff --git a/src/renderer/src/lib/notes-delivered-by-chat.ts b/src/renderer/src/lib/notes-delivered-by-chat.ts index 43839295532..15872b31e19 100644 --- a/src/renderer/src/lib/notes-delivered-by-chat.ts +++ b/src/renderer/src/lib/notes-delivered-by-chat.ts @@ -6,15 +6,10 @@ import { noteSendKeyOwner } from './notes-send-in-flight' -let uninstall: (() => void) | null = null - /** A new chat's message sent on, by its own start, a Retry or a re-check, clears the notes it - * carries from their shelf as a delivered send does, including after a reload. Once per renderer. */ -export function installNotesDeliveredByChat(): void { - if (uninstall) { - return - } - uninstall = subscribeToStructuredAgentSessionCarriedNotesSpent((keys) => { + * carries from their shelf as a delivered send does, including after a reload. */ +export function installNotesDeliveredByChat(): () => void { + return subscribeToStructuredAgentSessionCarriedNotesSpent((keys) => { const sent = new Set(keys) const state = useAppStore.getState() const worktreeIds = new Set() @@ -45,8 +40,3 @@ export function installNotesDeliveredByChat(): void { } }) } - -import.meta.hot?.dispose(() => { - uninstall?.() - uninstall = null -}) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 36239d52516..b70b662dc8a 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -24,11 +24,13 @@ import { getOrCreateRendererRoot } from './lib/react-renderer-root' import { primeTerminalWebglAddon } from './lib/pane-manager/pane-webgl-renderer' import { SkillWarningPreviewLauncher } from './components/skills/SkillWarningPreviewLauncher' import { installBrowserClientPageRenderer } from './components/browser-pane/browser-client-page-renderer-installation' +import { installNotesDeliveredByChat } from './lib/notes-delivered-by-chat' recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() installTypingLatencyDiagnostic() installAutomationHostDiagnostic() +installNotesDeliveredByChat() if ( import.meta.env.DEV && diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index c15105b80f0..678b915d49c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -21,7 +21,6 @@ import { } from './state-equality-files' import { shouldRetainStructuredAgentSessionLaunchTab } from '@/lib/structured-agent-session-launch-registry' import { executionHostIdForSessionTabsOwner } from '../local-structured-session-owner' -import { noteHostRemovedStructuredChat } from './host-removed-structured-chats' export function prepareWebSessionTabsSnapshotBrowser( base: ReturnType @@ -185,18 +184,11 @@ export function prepareWebSessionTabsSnapshotBrowser( // A matching host row is authoritative; retaining the provisional tab beside its mirror // would briefly render two panes before lifecycle publication is recorded. // A host that cannot list its chats is no evidence this one closed. - if (publishedAgentSessionIds.has(tab.entityId)) { - return false - } - if ( - !agentSessionsAffirmed || - shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId) - ) { - return true - } - // Listed before, affirmed absent now: the chat itself is gone, not a launch still starting. - noteHostRemovedStructuredChat(tab.entityId) - return false + return ( + !publishedAgentSessionIds.has(tab.entityId) && + (!agentSessionsAffirmed || + shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId)) + ) } if (tab.contentType === 'browser') { return ( diff --git a/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.test.ts deleted file mode 100644 index f3b6ef3a1df..00000000000 --- a/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -// @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Tab } from '../../../../shared/tab-types' -import type { WebSessionTabsSyncState } from './state' - -const store = vi.hoisted(() => { - const state: Record = {} - return { state } -}) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => ({ ...store.state, scheduleAgentStatusFreshness: () => undefined }), - setState: (update: (state: Record) => Record) => { - store.state = { ...store.state, ...update(store.state) } - } - } -})) - -vi.mock('@/lib/launch-structured-agent-session', () => { - class StructuredAgentSessionCreateRefusalError extends Error {} - return { - createStructuredAgentSessionLaunchIntent: (worktreeId: string) => ({ - worktreeId, - sessionId: 'launch-1', - executionHostId: 'local', - target: { kind: 'local' }, - agent: 'codex', - params: { - envelope: { - sessionId: 'launch-1', - clientOperationId: 'operation-launch-1', - expectedRuntimeFence: null, - payloadFingerprint: 'fingerprint-launch-1' - }, - worktree: `id:${worktreeId}`, - agent: 'codex' - } - }), - retryStructuredAgentSessionLaunchIntent: (intent: unknown) => intent, - restoreStructuredAgentSessionLaunchIntent: vi.fn(), - abandonStructuredAgentSessionLaunchIntent: vi.fn(), - launchStructuredAgentSession: () => - Promise.reject(new StructuredAgentSessionCreateRefusalError('unsupported')), - StructuredAgentSessionCreateRefusalError - } -}) - -import { applyWebSessionTabsSnapshot } from '../web-session-tabs-sync' -import { applyWebSessionTabsStorePatch } from './store-patch' -import { - makeSnapshot, - makeState, - resetWebSessionTabsSyncTestState, - ENV, - NOW, - WT -} from '../web-session-tabs-sync-test-harness' -import { - appendStructuredAgentSessionOutboxMessage, - readOutbox -} from '@/components/native-chat/structured-agent-session-outbox-storage' -import { resetStructuredAgentSessionCarriedNotesForTests } from '@/components/native-chat/structured-agent-session-outbox-carried-notes' -import { isNoteInFlight } from '@/lib/notes-send-in-flight' -import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' -import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence' -import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' -import { takeHostRemovedStructuredChats } from './host-removed-structured-chats' - -const NOTE_KEY = 'note-a' - -function chatTab(sessionId: string, id = `tab-${sessionId}`): Tab { - return { - id, - entityId: sessionId, - contentType: 'agent-session', - agentSessionAgent: 'codex', - worktreeId: WT, - groupId: 'group-1', - label: 'Codex', - customLabel: null, - color: null, - createdAt: 1, - sortOrder: 0 - } -} - -function windowWith(tabs: Tab[]): WebSessionTabsSyncState { - return makeState({ - unifiedTabsByWorktree: { [WT]: tabs }, - groupsByWorktree: { - [WT]: [ - { - id: 'group-1', - worktreeId: WT, - tabOrder: tabs.map((tab) => tab.id), - activeTabId: tabs[0]?.id ?? null - } - ] - }, - activeGroupIdByWorktree: { [WT]: 'group-1' } - }) -} - -/** A chat whose notes message the host refused: it waits on its Retry, carrying the notes. */ -function refusedNotesMessage(sessionId: string): void { - appendStructuredAgentSessionOutboxMessage(sessionId, 'review notes', [], 'launch', [NOTE_KEY]) -} - -/** The host's next list of chats, as this window's sync commits it. */ -function hostLists(sessions: string[]): void { - applyWebSessionTabsStorePatch( - (state) => - applyWebSessionTabsSnapshot( - state, - makeSnapshot( - sessions.map((sessionId) => ({ - type: 'agent-session' as const, - id: `agent-session:${sessionId}`, - sessionId, - agent: 'codex' as const, - title: 'Codex', - isActive: false - })), - { snapshotVersion: 2 } - ), - ENV, - NOW, - { contentScope: 'agent-session', preserveLocalLayout: true, terminalPtyMode: 'local' } - ), - { frames: [] } - ) -} - -describe('a chat the host stops listing', () => { - beforeEach(() => { - resetWebSessionTabsSyncTestState() - localStorage.clear() - resetStructuredAgentSessionCarriedNotesForTests() - resetStructuredAgentLaunchRegistryForTests() - resetStructuredAgentLaunchPersistenceForTests() - takeHostRemovedStructuredChats() - }) - - it('takes its queued messages with it, so the notes they carried come back', () => { - store.state = { ...windowWith([chatTab('chat-1')]) } - refusedNotesMessage('chat-1') - expect(isNoteInFlight(NOTE_KEY)).toBe(true) - - hostLists([]) - - expect(readOutbox('chat-1')).toEqual([]) - expect(isNoteInFlight(NOTE_KEY)).toBe(false) - }) - - it('keeps a chat the host still lists, under another local tab id', () => { - store.state = { ...windowWith([chatTab('chat-1', 'chat-1:history-1')]) } - refusedNotesMessage('chat-1') - - hostLists(['chat-1']) - - expect(readOutbox('chat-1')).toHaveLength(1) - expect(isNoteInFlight(NOTE_KEY)).toBe(true) - }) - - it("keeps a failed launch's messages for its Retry: the host never listed it", async () => { - const failed = startFailedLaunch() - await settle() - store.state = { ...windowWith([chatTab(failed)]) } - - hostLists([]) - - expect(readOutbox(failed)).toHaveLength(1) - expect(isNoteInFlight(NOTE_KEY)).toBe(true) - }) -}) - -function startFailedLaunch(): string { - return startStructuredAgentLaunch(WT, 'codex', { - prompt: 'review notes', - carriedNoteKeys: [NOTE_KEY] - }).sessionId -} - -async function settle(): Promise { - for (let i = 0; i < 20; i += 1) { - await Promise.resolve() - } -} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.ts b/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.ts deleted file mode 100644 index e3ae45fc06c..00000000000 --- a/src/renderer/src/runtime/web-session-tabs-sync/host-removed-structured-chats.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Why: a chat the host stopped listing is gone, wherever it was closed (the phone, another window, -// while Orca was off). Noted while a sync patch is prepared, acted on only once that patch lands. -const removedSessionIds = new Set() - -export function noteHostRemovedStructuredChat(sessionId: string): void { - removedSessionIds.add(sessionId) -} - -export function takeHostRemovedStructuredChats(): string[] { - const sessionIds = [...removedSessionIds] - removedSessionIds.clear() - return sessionIds -} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts index 1f45178027c..db2758d4bbe 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts @@ -17,8 +17,6 @@ import { type HostSessionMirrorPatchVerdict, type HostSessionMirrorSettle } from './mirror-settle' -import { takeHostRemovedStructuredChats } from './host-removed-structured-chats' -import { discardStructuredAgentSessionLaunchOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' /** Commit a reconciliation patch and return a receipt for its host evidence. */ export function applyWebSessionTabsStorePatch( @@ -151,13 +149,10 @@ export function applyWebSessionTabsStorePatch( return patch } - // Only what this patch's own preparation notes. - takeHostRemovedStructuredChats() try { useAppStore.setState(runStorePatch) } catch (error) { if (!patchCommitted) { - takeHostRemovedStructuredChats() throw error } console.warn('[web-session-tabs-sync] a store subscriber failed after the patch landed:', error) @@ -165,10 +160,6 @@ export function applyWebSessionTabsStorePatch( const settleHostMirror = createHostSessionMirrorSettle(hostMirrorVerdict) try { - // A chat gone from its host takes its queued messages, and the notes they held, with it. - for (const sessionId of takeHostRemovedStructuredChats()) { - discardStructuredAgentSessionLaunchOutbox(sessionId) - } if (mirroredAgentStatusChanged) { useAppStore.getState().scheduleAgentStatusFreshness() } diff --git a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts index 54df71490c5..0c9e658cac1 100644 --- a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts +++ b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../../shared/tab-types' import { createTestStore, makeWorktree, seedStore } from '../store-test-helpers' -import type * as OutboxStorageModule from '@/components/native-chat/structured-agent-session-outbox-storage' -const mocks = vi.hoisted(() => ({ beginClose: vi.fn(), discardOutbox: vi.fn() })) +const mocks = vi.hoisted(() => ({ beginClose: vi.fn() })) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -11,13 +10,6 @@ vi.mock('sonner', () => ({ vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ beginStructuredAgentSessionTabClose: mocks.beginClose })) -vi.mock( - '@/components/native-chat/structured-agent-session-outbox-storage', - async (importOriginal) => ({ - ...(await importOriginal()), - discardStructuredAgentSessionLaunchOutbox: mocks.discardOutbox - }) -) // `repoId::path` names both checkouts: this machine's and the paired server's. const WORKTREE = 'repo-1::/work/app' @@ -66,7 +58,6 @@ function storeWith(tab: Tab): ReturnType { beforeEach(() => { mocks.beginClose.mockReset() - mocks.discardOutbox.mockReset() }) describe('closing a structured chat from outside its workspace', () => { @@ -91,13 +82,4 @@ describe('closing a structured chat from outside its workspace', () => { expect(mocks.beginClose).not.toHaveBeenCalled() expect(store.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) }) - - // Its queued messages go with it, as for any close, so notes they carried come back. - it('throws away the queued messages of a chat it can name no host for', () => { - const store = storeWith(chatTab()) - - store.getState().closeUnifiedTab('agent-session:chat-1') - - expect(mocks.discardOutbox).toHaveBeenCalledWith('chat-1') - }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index a2edefb0f95..f4cd23a01d7 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -9,7 +9,6 @@ import { } from '../tab-group-state' import { buildActiveSurfacePatch } from './tabs-surface' import { beginStructuredAgentSessionTabClose } from '@/runtime/structured-agent-session-tab-retirement' -import { discardStructuredAgentSessionLaunchOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' import { hasStructuredAgentSessionLaunchCancellationTombstone, shouldRetainStructuredAgentSessionLaunchTab @@ -70,10 +69,8 @@ export function createTabsCloseActions( provisional }) } else { - // Closing still removes the tab; no host can be named to stop its chat on. Its queued - // messages go with it, as for any close, so notes they carried return to their shelf. + // Closing still removes the tab; no host can be named to stop its chat on. console.warn('[structured-agent-session] close found no owning host', tab.entityId) - discardStructuredAgentSessionLaunchOutbox(tab.entityId) } get().clearNativeChatLaunchDraft(structuredAgentSessionTabId(tab.entityId)) } From a3c1d7c8a0e57825a7e0d0c66645179914220057 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:12:05 -0700 Subject: [PATCH 16/16] Revert "fix(notes): a new chat's saved message keeps its notes out of another send across a reload" This reverts commit b88d0dc2cc78be26b0fe0b33fd76285b2172447d. --- .../BrowserAnnotationSendMenuContent.tsx | 7 +- .../browser-guest-annotate-overlays.tsx | 4 +- .../browser-page-annotation-tray.test.tsx | 2 +- .../annotate/browser-page-annotation-tray.tsx | 7 +- .../use-browser-page-annotation-send.test.tsx | 6 +- .../use-browser-page-annotation-send.ts | 23 +- .../browser-page-chrome-banners.tsx | 7 +- .../browser-page-chrome-header.tsx | 2 +- .../components/editor/NotesSendMenu.test.tsx | 25 +- .../src/components/editor/NotesSendMenu.tsx | 19 +- .../ReviewNotesSendMenuContent.test.tsx | 14 +- .../editor/ReviewNotesSendMenuContent.tsx | 13 +- ...ured-agent-session-outbox-carried-notes.ts | 116 -------- ...ctured-agent-session-outbox-entry-watch.ts | 63 ++++ ...structured-agent-session-outbox-storage.ts | 44 +-- .../QuickLaunchButton.launch-status.test.tsx | 14 +- .../components/tab-bar/QuickLaunchButton.tsx | 26 +- .../src/lib/agent-session-launch-plan.ts | 7 +- .../src/lib/launch-agent-in-new-tab.ts | 6 +- .../src/lib/new-agent-prompt-outcome.test.ts | 234 +++++++++++++++ .../src/lib/new-agent-prompt-outcome.ts | 62 ++++ .../src/lib/notes-carried-by-new-chat.test.ts | 271 ------------------ .../src/lib/notes-delivered-by-chat.ts | 42 --- src/renderer/src/lib/notes-send-in-flight.ts | 99 +++---- ...structured-agent-session-launch-callers.ts | 2 - .../lib/structured-agent-session-launch.ts | 8 +- src/renderer/src/main.tsx | 2 - src/shared/structured-agent-session-outbox.ts | 7 - 28 files changed, 490 insertions(+), 642 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-carried-notes.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-entry-watch.ts create mode 100644 src/renderer/src/lib/new-agent-prompt-outcome.test.ts create mode 100644 src/renderer/src/lib/new-agent-prompt-outcome.ts delete mode 100644 src/renderer/src/lib/notes-carried-by-new-chat.test.ts delete mode 100644 src/renderer/src/lib/notes-delivered-by-chat.ts diff --git a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx index 1342aac2da6..faceaab0f0b 100644 --- a/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx +++ b/src/renderer/src/components/browser-pane/annotate/BrowserAnnotationSendMenuContent.tsx @@ -1,13 +1,12 @@ import React from 'react' import { ReviewNotesSendMenuContent } from '@/components/editor/ReviewNotesSendMenuContent' -import type { NotesSendHandOff } from '@/lib/notes-send-in-flight' export type BrowserAnnotationSendMenuContentProps = { worktreeId: string groupId: string prompt: string onPromptDelivered?: () => void - notesHandOff?: NotesSendHandOff + onPromptHandedOff?: (delivered: Promise) => void } export function BrowserAnnotationSendMenuContent({ @@ -15,7 +14,7 @@ export function BrowserAnnotationSendMenuContent({ groupId, prompt, onPromptDelivered, - notesHandOff + 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 46c368b5ce4..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,7 +75,7 @@ export function BrowserGuestAnnotateOverlays({ activeGroupId, browserAnnotationsPrompt, handleBrowserAnnotationsSentToAgent, - browserAnnotationsHandOff, + handleBrowserAnnotationsHandedOff, handleCopyBrowserAnnotations, browserAnnotationsCopied, handleClearBrowserAnnotations, @@ -120,7 +120,7 @@ export function BrowserGuestAnnotateOverlays({ activeGroupId={activeGroupId} browserAnnotationsPrompt={browserAnnotationsPrompt} handleBrowserAnnotationsSentToAgent={handleBrowserAnnotationsSentToAgent} - browserAnnotationsHandOff={browserAnnotationsHandOff} + handleBrowserAnnotationsHandedOff={handleBrowserAnnotationsHandedOff} handleCopyBrowserAnnotations={handleCopyBrowserAnnotations} browserAnnotationsCopied={browserAnnotationsCopied} handleClearBrowserAnnotations={handleClearBrowserAnnotations} 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 27b28a13fdc..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,7 +87,7 @@ function renderTray(currentUrl?: string): { activeGroupId={undefined} browserAnnotationsPrompt="prompt" handleBrowserAnnotationsSentToAgent={vi.fn()} - browserAnnotationsHandOff={{ carriedNoteKeys: [], handOff: vi.fn() }} + handleBrowserAnnotationsHandedOff={vi.fn()} handleCopyBrowserAnnotations={vi.fn()} browserAnnotationsCopied={false} handleClearBrowserAnnotations={vi.fn()} 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 f2bbeaab055..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 @@ -19,7 +19,6 @@ import { BROWSER_ANNOTATION_INTENT_OPTIONS } from '../describe-page/browser-anno import { BrowserAnnotationSendMenuContent } from './BrowserAnnotationSendMenuContent' import { preventAgentSendTargetOutsideDismiss } from './prevent-agent-send-target-outside-dismiss' import { browserAnnotationMatchesPageUrl } from './browser-annotation-page-url' -import type { NotesSendHandOff } from '@/lib/notes-send-in-flight' export function BrowserPageAnnotationTray({ browserAnnotations, @@ -30,7 +29,7 @@ export function BrowserPageAnnotationTray({ activeGroupId, browserAnnotationsPrompt, handleBrowserAnnotationsSentToAgent, - browserAnnotationsHandOff, + handleBrowserAnnotationsHandedOff, handleCopyBrowserAnnotations, browserAnnotationsCopied, handleClearBrowserAnnotations, @@ -45,7 +44,7 @@ export function BrowserPageAnnotationTray({ activeGroupId: string | undefined browserAnnotationsPrompt: string handleBrowserAnnotationsSentToAgent: () => void - browserAnnotationsHandOff: NotesSendHandOff + handleBrowserAnnotationsHandedOff: (delivered: Promise) => void handleCopyBrowserAnnotations: () => void browserAnnotationsCopied: boolean handleClearBrowserAnnotations: () => void @@ -143,7 +142,7 @@ export function BrowserPageAnnotationTray({ groupId={activeGroupId ?? worktreeId} prompt={browserAnnotationsPrompt} onPromptDelivered={handleBrowserAnnotationsSentToAgent} - notesHandOff={browserAnnotationsHandOff} + 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 e68078dfd83..8eb9934c5a8 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 @@ -228,7 +228,7 @@ describe('website annotations handed to a send', () => { const firstDelivered = view.result.current.handleBrowserAnnotationsSentToAgent let deliverFirst!: (result: { delivered: boolean }) => void act(() => - view.result.current.browserAnnotationsHandOff.handOff( + view.result.current.handleBrowserAnnotationsHandedOff( new Promise((resolve) => (deliverFirst = resolve)) ) ) @@ -249,7 +249,7 @@ describe('website annotations handed to a send', () => { 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.browserAnnotationsHandOff.handOff(delivered)) + act(() => view.result.current.handleBrowserAnnotationsHandedOff(delivered)) expect(view.result.current.browserAnnotationsPrompt).toBe('') await act(async () => { @@ -269,7 +269,7 @@ describe('website annotations handed to a send', () => { it('offers no send while every annotation is already on its way', () => { const view = mount() - act(() => view.result.current.browserAnnotationsHandOff.handOff(new Promise(() => undefined))) + act(() => view.result.current.handleBrowserAnnotationsHandedOff(new Promise(() => undefined))) act(() => view.result.current.handleAnnotationTraySendOpenChange(true)) expect(mode).toBeUndefined() 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 02e42a0eed2..700094fe874 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 @@ -17,10 +17,8 @@ import type { import { formatBrowserAnnotationsAsMarkdown } from './browser-annotation-output' import { EMPTY_BROWSER_ANNOTATIONS } from '../describe-page/browser-annotation-geometry' import { - browserAnnotationSendKey, + holdNotesForSend, isNoteInFlight, - notesSendHandOff, - type NotesSendHandOff, useNotesInFlightVersion } from '@/lib/notes-send-in-flight' @@ -49,7 +47,7 @@ export function useBrowserPageAnnotationSend({ intent: BrowserAnnotationIntent ) => void handleBrowserAnnotationsSentToAgent: () => void - browserAnnotationsHandOff: NotesSendHandOff + handleBrowserAnnotationsHandedOff: (delivered: Promise) => void activeGroupId: string | undefined } { const browserAnnotations = useAppStore( @@ -68,9 +66,7 @@ export function useBrowserPageAnnotationSend({ const inFlightVersion = useNotesInFlightVersion() const sendableAnnotations = useMemo(() => { void inFlightVersion - return browserAnnotations.filter( - (annotation) => !isNoteInFlight(browserAnnotationSendKey(annotation)) - ) + return browserAnnotations.filter((annotation) => !isNoteInFlight(annotation)) }, [browserAnnotations, inFlightVersion]) const browserAnnotationsPrompt = useMemo( () => formatBrowserAnnotationsAsMarkdown(sendableAnnotations), @@ -122,9 +118,10 @@ export function useBrowserPageAnnotationSend({ removeDeliveredBrowserPageAnnotations ]) - const browserAnnotationsHandOff = useMemo( - () => notesSendHandOff(sendableAnnotations.map(browserAnnotationSendKey)), - [sendableAnnotations] + const handleBrowserAnnotationsHandedOff = useCallback( + (delivered: Promise): void => + holdNotesForSend(sendableAnnotations, delivered, handleBrowserAnnotationsSentToAgent), + [handleBrowserAnnotationsSentToAgent, sendableAnnotations] ) const handleClearBrowserAnnotations = useCallback((): void => { @@ -155,7 +152,7 @@ export function useBrowserPageAnnotationSend({ ), launchSource: 'notes_send', onPromptDelivered: handleBrowserAnnotationsSentToAgent, - onPromptHandedOff: browserAnnotationsHandOff.handOff + onPromptHandedOff: handleBrowserAnnotationsHandedOff }) } else { closeAgentSendPopoverTargetMode(modeId) @@ -164,7 +161,7 @@ export function useBrowserPageAnnotationSend({ [ browserAnnotationsPrompt, handleBrowserAnnotationsSentToAgent, - browserAnnotationsHandOff, + handleBrowserAnnotationsHandedOff, closeAgentSendPopoverTargetMode, openAgentSendPopoverTargetMode, worktreeId @@ -230,7 +227,7 @@ export function useBrowserPageAnnotationSend({ handleDeleteBrowserAnnotation, handleUpdateBrowserAnnotation, handleBrowserAnnotationsSentToAgent, - browserAnnotationsHandOff, + 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 fc0b6651eff..5bced58139a 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 @@ -14,7 +14,6 @@ import type { BrowserGrabPayload } from '../../../../../shared/browser-grab-type import type { GrabModeHook } from '../annotate/useGrabMode' import { preventAgentSendTargetOutsideDismiss } from '../annotate/prevent-agent-send-target-outside-dismiss' import type { GrabIntent } from '../describe-page/browser-page-types' -import type { NotesSendHandOff } from '@/lib/notes-send-in-flight' export function BrowserPageChromeBanners({ resourceNotice, @@ -29,7 +28,7 @@ export function BrowserPageChromeBanners({ activeGroupId, browserAnnotationsPrompt, handleBrowserAnnotationsSentToAgent, - browserAnnotationsHandOff, + handleBrowserAnnotationsHandedOff, handleCopyBrowserAnnotations, browserAnnotationsCopied, handleClearBrowserAnnotations, @@ -47,7 +46,7 @@ export function BrowserPageChromeBanners({ activeGroupId: string | undefined browserAnnotationsPrompt: string handleBrowserAnnotationsSentToAgent: () => void - browserAnnotationsHandOff: NotesSendHandOff + handleBrowserAnnotationsHandedOff: (delivered: Promise) => void handleCopyBrowserAnnotations: () => void browserAnnotationsCopied: boolean handleClearBrowserAnnotations: () => void @@ -163,7 +162,7 @@ export function BrowserPageChromeBanners({ groupId={activeGroupId ?? worktreeId} prompt={browserAnnotationsPrompt} onPromptDelivered={handleBrowserAnnotationsSentToAgent} - notesHandOff={browserAnnotationsHandOff} + 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 5b52ff63fe0..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,7 +121,7 @@ export function BrowserPageChromeHeader({ activeGroupId={annotationSend.activeGroupId} browserAnnotationsPrompt={annotationSend.browserAnnotationsPrompt} handleBrowserAnnotationsSentToAgent={annotationSend.handleBrowserAnnotationsSentToAgent} - browserAnnotationsHandOff={annotationSend.browserAnnotationsHandOff} + handleBrowserAnnotationsHandedOff={annotationSend.handleBrowserAnnotationsHandedOff} handleCopyBrowserAnnotations={annotationSend.handleCopyBrowserAnnotations} browserAnnotationsCopied={annotationSend.browserAnnotationsCopied} handleClearBrowserAnnotations={annotationSend.handleClearBrowserAnnotations} diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index 9c1ddca6812..b8758850895 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -9,10 +9,10 @@ type ReactElementLike = { props: Record } -type TestNote = DiffCommentDeliverySnapshot & { worktreeId: string } +type TestNote = DiffCommentDeliverySnapshot function note(id: string): TestNote { - return { id, worktreeId: 'wt-1', body: `body of ${id}`, filePath: 'README.md', lineNumber: 1 } + return { id, body: `body of ${id}`, filePath: 'README.md', lineNumber: 1 } } const hookRuntime = vi.hoisted(() => ({ @@ -475,20 +475,13 @@ describe('NotesSendMenu notes in flight', () => { } return callback(...args) } - const handOffOf = (props: Record): Record => { - const handOff = props.notesHandOff - if (typeof handOff !== 'object' || handOff === null) { - throw new Error('notesHandOff is missing') - } - return { ...handOff } - } const contentProps = (tree: unknown) => { const props = findByType(tree, 'ReviewNotesSendMenuContent').props return { prompt: props.prompt, onPromptDelivered: () => invoke(props, 'onPromptDelivered'), onPromptHandedOff: (delivered: Promise) => - invoke(handOffOf(props), 'handOff', delivered) + invoke(props, 'onPromptHandedOff', delivered) } } @@ -567,4 +560,16 @@ describe('NotesSendMenu notes in flight', () => { expect(findByType(tree, 'button').props.title).toBe('Sending…') }) + + // A failed new chat's Retry delivers them after the send's own callback is gone. + it('clears notes whose send reports delivery later', async () => { + const onDelivered = vi.fn() + const delivered = Promise.resolve({ delivered: true }) + contentProps(renderMenu({ scopes: scopeOf([noteA]), onDelivered })).onPromptHandedOff(delivered) + + await delivered + await Promise.resolve() + + expect(onDelivered).toHaveBeenCalledWith([noteA]) + }) }) diff --git a/src/renderer/src/components/editor/NotesSendMenu.tsx b/src/renderer/src/components/editor/NotesSendMenu.tsx index 288812a23cd..bb8cac0511a 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.tsx @@ -17,7 +17,7 @@ import { ReviewNotesSendMenuContent } from './ReviewNotesSendMenuContent' import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments' import { diffCommentSendKey, - notesSendHandOff, + holdNotesForSend, isNoteInFlight, useNotesInFlightVersion } from '@/lib/notes-send-in-flight' @@ -69,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, @@ -122,9 +122,10 @@ export function NotesSendMenu notesSendHandOff(notes.map(diffCommentSendKey)), - [] + const holdInFlight = useCallback( + (notes: readonly TNote[]) => (delivered: Promise) => + holdNotesForSend(notes.map(diffCommentSendKey), delivered, () => markDelivered(notes)), + [markDelivered] ) const openTargetMode = useCallback( @@ -140,11 +141,11 @@ export function NotesSendMenu markDelivered(scope.notes), - onPromptHandedOff: handOffNotes(scope.notes).handOff + onPromptHandedOff: holdInFlight(scope.notes) }) }, [ - handOffNotes, + holdInFlight, markDelivered, openAgentSendPopoverTargetMode, source, @@ -277,7 +278,7 @@ export function NotesSendMenu markDelivered(scope.notes)} - notesHandOff={handOffNotes(scope.notes)} + onPromptHandedOff={holdInFlight(scope.notes)} /> @@ -295,7 +296,7 @@ export function NotesSendMenu )} diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 051b58bc76b..af4c1e98445 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -650,7 +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 notesHandOff = { carriedNoteKeys: ['note-a'], handOff: vi.fn() } + const onPromptHandedOff = vi.fn() setStore({ tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] }, terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') } @@ -666,7 +666,7 @@ describe('ReviewNotesSendMenuContent', () => { } ] - const tree = render({ onPromptDelivered, notesHandOff }) + const tree = render({ onPromptDelivered, onPromptHandedOff }) ;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)() await flushMicrotasks() @@ -682,8 +682,8 @@ describe('ReviewNotesSendMenuContent', () => { request_kind: 'followup' }) // The notes are held from the hand-off until this send's own outcome, after its delivery. - expect(notesHandOff.handOff).toHaveBeenCalledOnce() - await notesHandOff.handOff.mock.calls[0][0] + expect(onPromptHandedOff).toHaveBeenCalledOnce() + await onPromptHandedOff.mock.calls[0][0] expect(onPromptDelivered).toHaveBeenCalledTimes(1) }) @@ -859,15 +859,15 @@ describe('ReviewNotesSendMenuContent', () => { }) it('always offers the new-agent launcher', () => { - const notesHandOff = { carriedNoteKeys: ['note-a'], handOff: vi.fn() } - const tree = render({ notesHandOff }) + 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', - notesHandOff + onPromptHandedOff }) }) diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index c1ebe3d6229..935d98dd986 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -34,7 +34,6 @@ import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows' import type { LaunchSource } from '../../../../shared/telemetry-events' import { agentRowDisplayDotState } from '@/lib/agent-row-dot-state' import { translate } from '@/i18n/i18n' -import type { NotesSendHandOff } from '@/lib/notes-send-in-flight' type OrderedSendTarget = { target: NotesSendAgentTarget @@ -48,7 +47,7 @@ export function ReviewNotesSendMenuContent({ promptDelivery = 'submit-after-ready', launchSource = 'notes_send', onPromptDelivered, - notesHandOff + onPromptHandedOff }: { worktreeId: string groupId: string @@ -56,8 +55,8 @@ export function ReviewNotesSendMenuContent({ promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready' launchSource?: LaunchSource onPromptDelivered?: () => void - /** The notes this prompt was built from: held from each send's hand-off until its result. */ - notesHandOff?: NotesSendHandOff + /** 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 @@ -151,9 +150,9 @@ export function ReviewNotesSendMenuContent({ { id: pending } ) }) - notesHandOff?.handOff(sending) + onPromptHandedOff?.(sending) }, - [notesHandOff] + [onPromptHandedOff] ) const sendToAgentTarget = useCallback( @@ -213,7 +212,7 @@ export function ReviewNotesSendMenuContent({ promptDelivery={promptDelivery} launchSource={launchSource} onPromptDelivered={onPromptDelivered} - notesHandOff={notesHandOff} + onPromptHandedOff={onPromptHandedOff} disabled={!hasPrompt} /> diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-carried-notes.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-carried-notes.ts deleted file mode 100644 index a865e12d744..00000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-carried-notes.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' - -/** How a message left its chat's outbox: sent on (accepted, or handed to the host or the - * composer), or thrown away with the chat. */ -export type StructuredAgentSessionOutboxRemoval = 'spent' | 'discarded' - -// Why: the notes a chat's queued messages carry, read from the saved outboxes once and kept current -// by the outbox's one write funnel. A message's notes go when it does, so nothing here outlives it. -const carriedBySession = new Map() -let loaded = false -let version = 0 -const changeListeners = new Set<() => void>() -const spentListeners = new Set<(keys: readonly string[]) => void>() - -function carriedKeys(entries: readonly StructuredAgentSessionOutboxEntry[]): string[] { - return entries.flatMap((entry) => entry.carriedNoteKeys ?? []) -} - -function setCarried( - sessionId: string, - entries: readonly StructuredAgentSessionOutboxEntry[] -): boolean { - const next = carriedKeys(entries) - const previous = carriedBySession.get(sessionId) ?? [] - if (next.length === 0) { - carriedBySession.delete(sessionId) - } else { - carriedBySession.set(sessionId, next) - } - return next.length !== previous.length || next.some((key, index) => key !== previous[index]) -} - -function changed(): void { - version += 1 - for (const listener of changeListeners) { - listener() - } -} - -/** Reads every saved outbox once, the first time the notes they carry are asked about. */ -export function loadStructuredAgentSessionCarriedNotes( - saved: () => Iterable -): void { - if (loaded) { - return - } - loaded = true - for (const [sessionId, entries] of saved()) { - setCarried(sessionId, entries) - } -} - -// A user's Retry of a refused message gives it a new id; its text and queue time stay. -function stillQueued( - message: StructuredAgentSessionOutboxEntry, - entries: readonly StructuredAgentSessionOutboxEntry[] -): boolean { - const body = JSON.stringify(message.body) - return entries.some( - (entry) => - entry.clientMessageId === message.clientMessageId || - (entry.queuedAt === message.queuedAt && JSON.stringify(entry.body) === body) - ) -} - -/** Run by the outbox's write funnel once `after` replaces `before` as the session's outbox. */ -export function recordStructuredAgentSessionCarriedNotes( - sessionId: string, - before: readonly StructuredAgentSessionOutboxEntry[], - after: readonly StructuredAgentSessionOutboxEntry[], - removal: StructuredAgentSessionOutboxRemoval -): void { - const spent = - removal === 'spent' ? carriedKeys(before.filter((message) => !stillQueued(message, after))) : [] - // A spent message's notes are cleared before they could show as sendable again. - if (spent.length > 0) { - for (const listener of spentListeners) { - listener(spent) - } - } - if (setCarried(sessionId, after)) { - changed() - } -} - -export function carriedNotesInclude(key: string): boolean { - for (const keys of carriedBySession.values()) { - if (keys.includes(key)) { - return true - } - } - return false -} - -export function subscribeToStructuredAgentSessionCarriedNotes(listener: () => void): () => void { - changeListeners.add(listener) - return () => changeListeners.delete(listener) -} - -export function structuredAgentSessionCarriedNotesVersion(): number { - return version -} - -/** Told the notes of every message sent on, whichever send or Retry sent it. */ -export function subscribeToStructuredAgentSessionCarriedNotesSpent( - listener: (keys: readonly string[]) => void -): () => void { - spentListeners.add(listener) - return () => spentListeners.delete(listener) -} - -export function resetStructuredAgentSessionCarriedNotesForTests(): void { - carriedBySession.clear() - loaded = false - changed() -} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-entry-watch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-entry-watch.ts new file mode 100644 index 00000000000..71644f31340 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-entry-watch.ts @@ -0,0 +1,63 @@ +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' + +/** How a watched message left its chat's outbox: sent on (accepted, or handed to the host or the + * composer), or thrown away with the chat. */ +export type StructuredAgentSessionOutboxEntryRemoval = 'spent' | 'discarded' + +type EntryWatch = { + entry: StructuredAgentSessionOutboxEntry + onGone: (removal: StructuredAgentSessionOutboxEntryRemoval) => void +} + +const watchesBySession = new Map>() + +// A user's Retry of a refused message gives it a new id; its text and queue time stay. +function stillQueued( + watched: StructuredAgentSessionOutboxEntry, + entries: readonly StructuredAgentSessionOutboxEntry[] +): boolean { + const body = JSON.stringify(watched.body) + return entries.some( + (entry) => + entry.clientMessageId === watched.clientMessageId || + (entry.queuedAt === watched.queuedAt && JSON.stringify(entry.body) === body) + ) +} + +/** Calls `onGone` once, when `entry` leaves its session's outbox. */ +export function watchStructuredAgentSessionOutboxEntry( + entry: StructuredAgentSessionOutboxEntry, + onGone: (removal: StructuredAgentSessionOutboxEntryRemoval) => void +): () => void { + const watches = watchesBySession.get(entry.sessionId) ?? new Set() + watchesBySession.set(entry.sessionId, watches) + const watch: EntryWatch = { entry, onGone } + watches.add(watch) + return () => { + watches.delete(watch) + if (watches.size === 0 && watchesBySession.get(entry.sessionId) === watches) { + watchesBySession.delete(entry.sessionId) + } + } +} + +/** Run by the outbox's one write funnel after a change takes effect. */ +export function settleStructuredAgentSessionOutboxEntryWatches( + sessionId: string, + entries: readonly StructuredAgentSessionOutboxEntry[], + removal: StructuredAgentSessionOutboxEntryRemoval +): void { + const watches = watchesBySession.get(sessionId) + if (!watches) { + return + } + for (const watch of watches) { + if (!stillQueued(watch.entry, entries)) { + watches.delete(watch) + watch.onGone(removal) + } + } + if (watches.size === 0) { + watchesBySession.delete(sessionId) + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index 2637954aebb..93c38669226 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -7,11 +7,9 @@ import { import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { createBrowserUuid } from '@/lib/browser-uuid' import { - carriedNotesInclude, - loadStructuredAgentSessionCarriedNotes, - recordStructuredAgentSessionCarriedNotes, - type StructuredAgentSessionOutboxRemoval -} from './structured-agent-session-outbox-carried-notes' + settleStructuredAgentSessionOutboxEntryWatches, + type StructuredAgentSessionOutboxEntryRemoval +} from './structured-agent-session-outbox-entry-watch' const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:' @@ -173,9 +171,8 @@ function commitOutbox( sessionId: string, entries: StructuredAgentSessionOutboxEntry[], options: { onlyIfSaved?: boolean }, - removal: StructuredAgentSessionOutboxRemoval + removal: StructuredAgentSessionOutboxEntryRemoval ): boolean { - const before = getStructuredAgentSessionOutbox(sessionId) const saved = writeOutbox(sessionId, entries) if (!saved && options.onlyIfSaved) { return false @@ -187,38 +184,17 @@ function commitOutbox( listener() } } - recordStructuredAgentSessionCarriedNotes(sessionId, before, entries, removal) + settleStructuredAgentSessionOutboxEntryWatches(sessionId, entries, removal) return saved } -function* savedOutboxes(): Generator { - // Storage-less hosts (tests, a blocked profile) have no saved outboxes. - if (typeof localStorage === 'undefined') { - return - } - for (let index = 0; index < localStorage.length; index += 1) { - const key = localStorage.key(index) - if (key?.startsWith(OUTBOX_PREFIX)) { - const sessionId = decodeURIComponent(key.slice(OUTBOX_PREFIX.length)) - yield [sessionId, readOutbox(sessionId, { recoverDispatching: false })] - } - } -} - -/** Whether a saved message still carries this note: it stays out of another send until then. */ -export function structuredAgentSessionOutboxCarriesNote(key: string): boolean { - loadStructuredAgentSessionCarriedNotes(savedOutboxes) - return carriedNotesInclude(key) -} - /** Queues a user message on the session's outbox: the one enqueue the composer, a launch prompt, * and a message sent from outside the chat all share. */ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - source?: 'launch', - carriedNoteKeys?: readonly string[] + source?: 'launch' ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ @@ -228,8 +204,7 @@ export function appendStructuredAgentSessionOutboxMessage( attachments, queuedAt: Date.now() }), - ...(source ? { source } : {}), - ...(carriedNoteKeys?.length ? { carriedNoteKeys: [...carriedNoteKeys] } : {}) + ...(source ? { source } : {}) } return commitStructuredAgentSessionOutbox( sessionId, @@ -242,10 +217,9 @@ export function appendStructuredAgentSessionOutboxMessage( export function enqueueStructuredAgentSessionLaunchPrompt( sessionId: string, - text: string, - carriedNoteKeys?: readonly string[] + text: string ): StructuredAgentSessionOutboxEntry | null { - return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch', carriedNoteKeys) + return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch') } export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void { 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 9de078f885a..1f06c3307c3 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 @@ -180,14 +180,14 @@ describe('QuickLaunchAgentMenuItems launch status', () => { expect(agentRowDisabled('Codex')).toBe('true') }) - // Why: a new chat saves the notes' keys with its message; the result holds them until then. - it("saves the notes' keys with the launch and holds them on its own result", () => { + // 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 outcome to the notes menu", async () => { const delivery = Promise.resolve({ delivered: true, failureNotified: false }) launchMock.mockReturnValue({ surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'codex-session' }, promptDeliveryResult: delivery }) - const notesHandOff = { carriedNoteKeys: ['note-a'], handOff: vi.fn() } + const onPromptHandedOff = vi.fn() render( { onFocusTerminal={vi.fn()} prompt="review notes" promptDelivery="submit-after-ready" - notesHandOff={notesHandOff} + onPromptHandedOff={onPromptHandedOff} /> ) fireEvent.click(document.querySelector('[title="Launch Codex in a new terminal"]')!) - expect(launchMock).toHaveBeenCalledWith( - expect.objectContaining({ prompt: 'review notes', carriedNoteKeys: ['note-a'] }) - ) - expect(notesHandOff.handOff).toHaveBeenCalledWith(delivery) + expect(onPromptHandedOff).toHaveBeenCalledOnce() + await expect(onPromptHandedOff.mock.calls[0][0]).resolves.toEqual({ delivered: true }) }) it('starts no agent when the menu has nothing left to send', () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 411baccf287..a5eda32b0e6 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -18,7 +18,7 @@ import { import { translate } from '@/i18n/i18n' import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' import { structuredLaunchRequest } from '@/lib/structured-agent-session-launch-request' -import type { NotesSendHandOff } from '@/lib/notes-send-in-flight' +import { newAgentPromptOutcome } from '@/lib/new-agent-prompt-outcome' export type QuickLaunchAgentMenuItemsProps = { worktreeId: string @@ -39,9 +39,8 @@ export type QuickLaunchAgentMenuItemsProps = { launchSource?: LaunchSource /** Called after a prompt is queued into the agent, or immediately for argv prompt launches. */ onPromptDelivered?: () => void - /** The notes this prompt was built from: a new chat saves their keys with its message, and the - * launch's own delivery result holds them until then. */ - notesHandOff?: NotesSendHandOff + /** Given the launch's own delivery result while the prompt is still on its way. */ + onPromptHandedOff?: (delivered: Promise) => void /** Nothing to send: e.g. every note is already on its way, so no agent is started. */ disabled?: boolean } @@ -111,7 +110,7 @@ function QuickLaunchAgentMenuItemsInner({ promptDelivery, launchSource, onPromptDelivered, - notesHandOff, + onPromptHandedOff, disabled = false }: QuickLaunchAgentMenuItemsProps): React.JSX.Element | null { // Why: resolving only the SSH connectionId here made paired-runtime @@ -154,8 +153,7 @@ function QuickLaunchAgentMenuItemsInner({ ...(prompt !== undefined ? { prompt } : {}), ...(promptDelivery !== undefined ? { promptDelivery } : {}), ...(launchSource !== undefined ? { launchSource } : {}), - ...(onPromptDelivered !== undefined ? { onPromptDelivered } : {}), - ...(notesHandOff ? { carriedNoteKeys: notesHandOff.carriedNoteKeys } : {}) + ...(onPromptDelivered !== undefined ? { onPromptDelivered } : {}) }) if (!result) { toast.error( @@ -167,8 +165,16 @@ function QuickLaunchAgentMenuItemsInner({ ) return } - if (result.promptDeliveryResult) { - notesHandOff?.handOff(result.promptDeliveryResult) + if (onPromptHandedOff && result.promptDeliveryResult) { + onPromptHandedOff( + newAgentPromptOutcome({ + prompt: prompt ?? '', + ...(result.surface.kind === 'local-agent-session' + ? { sessionId: result.surface.sessionId } + : {}), + delivery: result.promptDeliveryResult + }) + ) } if (result.surface.kind !== 'local-terminal') { return @@ -204,7 +210,7 @@ function QuickLaunchAgentMenuItemsInner({ promptDelivery, launchSource, onPromptDelivered, - notesHandOff, + onPromptHandedOff, disabled ] ) diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 1bc4cc7a14f..d4fa18551d4 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -28,7 +28,6 @@ import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-sessio export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void - carriedNoteKeys?: readonly string[] } /** @@ -46,8 +45,6 @@ export type AgentSessionLaunchVerdict = { promptDelivery?: NativeChatLaunchPromptDelivery resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void - /** Send keys of the notes the prompt was built from; a structured launch saves them with it. */ - carriedNoteKeys?: readonly string[] } export type AgentSessionStructuredFeasibilityRequest = AgentLaunchRouteArgs & { @@ -84,7 +81,6 @@ function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): Structured ...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}), ...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}), ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}), - ...(verdict.carriedNoteKeys ? { carriedNoteKeys: verdict.carriedNoteKeys } : {}), ...(verdict.executionHostId ? { executionHostId: verdict.executionHostId } : {}) } } @@ -174,7 +170,6 @@ export function planAgentSessionLaunch( ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), ...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}), ...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}), - ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}), - ...(request.carriedNoteKeys ? { carriedNoteKeys: request.carriedNoteKeys } : {}) + ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}) }) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 6f565d4cb00..eff738dde7f 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -52,8 +52,6 @@ export type LaunchAgentInNewTabArgs = { launchPlatform?: NodeJS.Platform /** Called after the prompt is actually delivered to the agent input path. */ onPromptDelivered?: () => void - /** Send keys of the notes the prompt was built from; a structured chat saves them with it. */ - carriedNoteKeys?: readonly string[] /** * Called before `onPromptDelivered` when the paste was written without ever observing the * agent's composer, so the launch cannot claim the prompt arrived. Fires only on the @@ -117,7 +115,6 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent launchPlatform, onPromptDelivered, onPromptDeliveryUnconfirmed, - carriedNoteKeys, agentSessionLaunchPlan, pendingActivationSpawn, beforeSurfaceOpen @@ -182,8 +179,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent promptDelivery: viewModePromptDelivery, tuiCustomization: { cwd: initialCwd }, initialSessionOptions: startupPlan.sessionOptions, - onPromptDelivered, - ...(carriedNoteKeys ? { carriedNoteKeys } : {}) + onPromptDelivered }) if (plan?.route === 'structured-native-chat') { const structured = launchStructuredAgentFromNewTab({ diff --git a/src/renderer/src/lib/new-agent-prompt-outcome.test.ts b/src/renderer/src/lib/new-agent-prompt-outcome.test.ts new file mode 100644 index 00000000000..dd26ded38b2 --- /dev/null +++ b/src/renderer/src/lib/new-agent-prompt-outcome.test.ts @@ -0,0 +1,234 @@ +// @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(() => ({ + callStructuredAgentSession: vi.fn(), + createIntent: vi.fn(), + launch: 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: (intent: unknown) => intent, + restoreStructuredAgentSessionLaunchIntent: vi.fn(), + abandonStructuredAgentSessionLaunchIntent: vi.fn(), + 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: vi.fn(), + clearNativeChatLaunchDraft: vi.fn() + }), + 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 { + mutateStructuredAgentSessionLaunchPrompt, + readOutbox +} from '@/components/native-chat/structured-agent-session-outbox-storage' +import { + cancelStructuredAgentLaunch, + getStructuredAgentSessionLaunchLifecycle, + retryStructuredAgentSessionLaunch, + startStructuredAgentLaunch +} from './structured-agent-session-launch' +import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' +import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' +import { + holdNotesForSend, + isNoteInFlight, + resetNotesInFlightForTests +} from './notes-send-in-flight' +import { newAgentPromptOutcome } from './new-agent-prompt-outcome' + +const WORKTREE_ID = 'wt-notes-new-agent' +const NOTES = 'review notes' + +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 published(sessionId: string): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'tab-1', + title: 'Codex', + sessionId, + agent: 'codex', + isActive: false + } + ] + } +} + +async function settle(): Promise { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } +} + +const chat = launchIntent('session-notes') + +/** What the notes menu does with a "New agent" pick: the launch, then the hold on its outcome. */ +function sendNotesToNewAgent(options: { paired?: boolean } = {}) { + const onDelivered = vi.fn() + const launch = startStructuredAgentLaunch(WORKTREE_ID, 'codex', { + prompt: NOTES, + promptDelivery: 'submit-after-ready' + }) + holdNotesForSend( + ['note-a'], + newAgentPromptOutcome({ + prompt: NOTES, + ...(options.paired ? {} : { sessionId: launch.sessionId }), + delivery: launch.promptDeliveryResult! + }), + onDelivered + ) + return { launch, onDelivered } +} + +/** A start the host refused outright: the chat shows it failed, with Retry. */ +async function failTheStart(): Promise { + await settle() + expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, chat.sessionId)).toBe('failed') +} + +describe('notes sent to a new agent', () => { + beforeEach(() => { + vi.resetAllMocks() + localStorage.clear() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentLaunchRegistryForTests() + resetNotesInFlightForTests() + mocks.createIntent.mockReturnValue(chat) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([published(chat.sessionId)]) + mocks.callStructuredAgentSession.mockResolvedValue({ + ok: true, + value: { submission: { dispatchState: 'accepted' } } + }) + }) + + it('leaves the shelf once the new chat delivers them', async () => { + mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 }) + const { onDelivered } = sendNotesToNewAgent() + expect(isNoteInFlight('note-a')).toBe(true) + + await settle() + + expect(onDelivered).toHaveBeenCalledOnce() + expect(isNoteInFlight('note-a')).toBe(false) + }) + + it('stay held, not resendable, while a failed chat keeps them for its Retry', async () => { + mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) + const { onDelivered } = sendNotesToNewAgent() + await failTheStart() + + expect(readOutbox(chat.sessionId)).toHaveLength(1) + expect(isNoteInFlight('note-a')).toBe(true) + expect(onDelivered).not.toHaveBeenCalled() + }) + + it("leave the shelf when that chat's Retry delivers them", async () => { + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) + const { onDelivered } = sendNotesToNewAgent() + await failTheStart() + mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 }) + + expect(retryStructuredAgentSessionLaunch(WORKTREE_ID, chat.sessionId)).toBe(true) + await settle() + // The open chat's own send accepts the staged prompt, as every dispatch does. + const [entry] = readOutbox(chat.sessionId) + mutateStructuredAgentSessionLaunchPrompt(chat.sessionId, entry.clientMessageId, () => null) + await settle() + + expect(onDelivered).toHaveBeenCalledOnce() + expect(isNoteInFlight('note-a')).toBe(false) + }) + + it('come back to the shelf when the failed chat is closed', async () => { + mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) + const { onDelivered } = sendNotesToNewAgent() + await failTheStart() + + cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId) + await settle() + + expect(isNoteInFlight('note-a')).toBe(false) + expect(onDelivered).not.toHaveBeenCalled() + }) + + it('come back when a chat still starting is closed, without waiting on its create', async () => { + mocks.launch.mockImplementation(() => new Promise(() => undefined)) + const { onDelivered } = sendNotesToNewAgent() + + cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId) + await settle() + + expect(isNoteInFlight('note-a')).toBe(false) + expect(onDelivered).not.toHaveBeenCalled() + }) + + it("stay held by a paired server's failed chat, found once its start settles", async () => { + mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) + sendNotesToNewAgent({ paired: true }) + await failTheStart() + + expect(isNoteInFlight('note-a')).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/new-agent-prompt-outcome.ts b/src/renderer/src/lib/new-agent-prompt-outcome.ts new file mode 100644 index 00000000000..8fff9022213 --- /dev/null +++ b/src/renderer/src/lib/new-agent-prompt-outcome.ts @@ -0,0 +1,62 @@ +import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { watchStructuredAgentSessionOutboxEntry } from '@/components/native-chat/structured-agent-session-outbox-entry-watch' +import { structuredLaunchStates } from './structured-agent-session-launch-registry' + +export type NewAgentPromptOutcome = { delivered: boolean } + +function stagedLaunchPrompt( + sessionId: string, + text: string +): StructuredAgentSessionOutboxEntry | undefined { + return getStructuredAgentSessionOutbox(sessionId).findLast( + (entry) => + entry.source === 'launch' && + entry.body.blocks.some((block) => block.type === 'text' && block.text === text) + ) +} + +/** A failed or unconfirmed chat keeps its prompt staged for its own Retry or re-check. */ +function stagedInAnyLaunch(text: string): StructuredAgentSessionOutboxEntry | undefined { + let found: StructuredAgentSessionOutboxEntry | undefined + for (const state of structuredLaunchStates()) { + found = stagedLaunchPrompt(state.intent.sessionId, text) ?? found + } + return found +} + +function outcomeOf(entry: StructuredAgentSessionOutboxEntry): Promise { + return new Promise((resolve) => { + watchStructuredAgentSessionOutboxEntry(entry, (removal) => + resolve({ delivered: removal === 'spent' }) + ) + }) +} + +/** + * Settles once a new agent's prompt is sent on (delivered) or thrown away with its chat. A chat's + * staged prompt is the authority, so a Retry or re-check that sends it later still counts, and a + * failed start does not hand the text back while that chat still holds it. + */ +export function newAgentPromptOutcome(args: { + prompt: string + sessionId?: string + delivery: Promise<{ delivered: boolean }> +}): Promise { + const text = args.prompt.trim() + const staged = args.sessionId ? stagedLaunchPrompt(args.sessionId, text) : undefined + if (staged) { + return outcomeOf(staged) + } + // A paired server's chat exists only once it is admitted, so look again after the start. + const afterStart = ( + delivered: boolean + ): Promise | NewAgentPromptOutcome => { + const kept = delivered ? undefined : stagedInAnyLaunch(text) + return kept ? outcomeOf(kept) : { delivered } + } + return args.delivery.then( + (result) => afterStart(result.delivered), + () => afterStart(false) + ) +} diff --git a/src/renderer/src/lib/notes-carried-by-new-chat.test.ts b/src/renderer/src/lib/notes-carried-by-new-chat.test.ts deleted file mode 100644 index 699d577a7fc..00000000000 --- a/src/renderer/src/lib/notes-carried-by-new-chat.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -// @vitest-environment happy-dom - -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' -import type { DiffComment } from '../../../shared/diff-comment-types' -import type { BrowserPageAnnotation } from '../../../shared/browser-grab-types' -import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' - -type ShelfAnnotation = Pick - -const mocks = vi.hoisted(() => { - const shelf: { notes: DiffComment[]; annotations: ShelfAnnotation[] } = { - notes: [], - annotations: [] - } - return { - callStructuredAgentSession: vi.fn(), - createIntent: vi.fn(), - launch: vi.fn(), - refresh: vi.fn(), - clearDeliveredDiffComments: vi.fn(), - removeDeliveredBrowserPageAnnotations: vi.fn(), - shelf - } -}) - -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: (intent: unknown) => intent, - restoreStructuredAgentSessionLaunchIntent: vi.fn(), - abandonStructuredAgentSessionLaunchIntent: vi.fn(), - launchStructuredAgentSession: mocks.launch, - StructuredAgentSessionCreateRefusalError - } -}) - -vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ - refreshLocalStructuredSessionTabs: mocks.refresh -})) - -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.callStructuredAgentSession -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => ({ - unifiedTabsByWorktree: {}, - seedNativeChatLaunchDraft: vi.fn(), - clearNativeChatLaunchDraft: vi.fn(), - getDiffComments: () => mocks.shelf.notes, - clearDeliveredDiffComments: mocks.clearDeliveredDiffComments, - browserAnnotationsByPageId: { 'page-1': mocks.shelf.annotations }, - removeDeliveredBrowserPageAnnotations: mocks.removeDeliveredBrowserPageAnnotations - }), - subscribe: () => () => undefined - } -})) - -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) - -vi.mock('@/lib/agent-catalog', () => ({ - getAgentLabel: () => 'Codex', - getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }] -})) - -const WORKTREE_ID = 'wt-notes-new-agent' -const NOTES = 'review notes' - -const note: DiffComment = { - id: 'note-a', - worktreeId: WORKTREE_ID, - filePath: 'README.md', - lineNumber: 3, - body: 'tighten this', - createdAt: 1, - side: 'modified' -} - -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 published(sessionId: string): RuntimeMobileSessionTabsResult { - return { - worktree: WORKTREE_ID, - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [ - { - type: 'agent-session', - id: 'tab-1', - title: 'Codex', - sessionId, - agent: 'codex', - isActive: false - } - ] - } -} - -async function settle(): Promise { - for (let i = 0; i < 20; i += 1) { - await Promise.resolve() - } -} - -const chat = launchIntent('session-notes') - -/** A renderer as it starts: fresh modules over the same saved storage, the clearer installed. */ -async function renderer() { - const launch = await import('./structured-agent-session-launch') - const outbox = await import('@/components/native-chat/structured-agent-session-outbox-storage') - const inFlight = await import('./notes-send-in-flight') - const { installNotesDeliveredByChat } = await import('./notes-delivered-by-chat') - installNotesDeliveredByChat() - return { launch, outbox, inFlight, key: inFlight.diffCommentSendKey(note) } -} - -/** What "Send notes > New agent" does: the launch, carrying the notes' keys, held on its result. */ -async function sendNotesToNewAgent() { - const app = await renderer() - const handOff = app.inFlight.notesSendHandOff([app.key]) - const started = app.launch.startStructuredAgentLaunch(WORKTREE_ID, 'codex', { - prompt: NOTES, - promptDelivery: 'submit-after-ready', - carriedNoteKeys: handOff.carriedNoteKeys - }) - handOff.handOff(started.promptDeliveryResult!) - return app -} - -/** The chat's own send accepting its staged message, as the composer's drain or a Retry does. */ -function acceptStagedMessage(app: Awaited>): void { - const [message] = app.outbox.readOutbox(chat.sessionId) - app.outbox.mutateStructuredAgentSessionLaunchPrompt( - chat.sessionId, - message.clientMessageId, - () => null - ) -} - -describe('notes sent to a new agent', () => { - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - localStorage.clear() - mocks.shelf.notes = [note] - mocks.shelf.annotations = [] - mocks.createIntent.mockReturnValue(chat) - mocks.refresh.mockResolvedValue([published(chat.sessionId)]) - mocks.callStructuredAgentSession.mockResolvedValue({ - ok: true, - value: { submission: { dispatchState: 'accepted' } } - }) - }) - - it('save their keys with the staged message and leave the shelf once it is sent', async () => { - mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 }) - const app = await sendNotesToNewAgent() - expect(app.outbox.readOutbox(chat.sessionId)[0].carriedNoteKeys).toEqual([app.key]) - expect(app.inFlight.isNoteInFlight(app.key)).toBe(true) - - await settle() - - expect(mocks.clearDeliveredDiffComments).toHaveBeenCalledWith(WORKTREE_ID, [note]) - expect(app.inFlight.isNoteInFlight(app.key)).toBe(false) - }) - - it('stay held across a reload while the chat can still send them, and clear when it does', async () => { - mocks.launch.mockImplementation(() => new Promise(() => undefined)) - await sendNotesToNewAgent() - - vi.resetModules() - const reloaded = await renderer() - - expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(true) - acceptStagedMessage(reloaded) - expect(mocks.clearDeliveredDiffComments).toHaveBeenCalledWith(WORKTREE_ID, [note]) - expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(false) - }) - - it('come back to the shelf when the chat is closed after a reload', async () => { - mocks.launch.mockImplementation(() => new Promise(() => undefined)) - await sendNotesToNewAgent() - - vi.resetModules() - const reloaded = await renderer() - expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(true) - // Closing a chat throws its outbox away (tab retirement, cancel, workspace teardown). - reloaded.outbox.discardStructuredAgentSessionLaunchOutbox(chat.sessionId) - - expect(reloaded.inFlight.isNoteInFlight(reloaded.key)).toBe(false) - expect(mocks.clearDeliveredDiffComments).not.toHaveBeenCalled() - }) - - it('stay held by a failed chat, clear when its Retry sends them, and return when it closes', async () => { - const { StructuredAgentSessionCreateRefusalError } = - await import('@/lib/launch-structured-agent-session') - mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) - const app = await sendNotesToNewAgent() - await settle() - expect(app.launch.getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, chat.sessionId)).toBe( - 'failed' - ) - expect(app.inFlight.isNoteInFlight(app.key)).toBe(true) - - mocks.launch.mockResolvedValue({ sessionId: chat.sessionId, fence: 1 }) - expect(app.launch.retryStructuredAgentSessionLaunch(WORKTREE_ID, chat.sessionId)).toBe(true) - await settle() - acceptStagedMessage(app) - - expect(mocks.clearDeliveredDiffComments).toHaveBeenCalledWith(WORKTREE_ID, [note]) - expect(app.inFlight.isNoteInFlight(app.key)).toBe(false) - }) - - it('return when a failed chat is closed', async () => { - const { StructuredAgentSessionCreateRefusalError } = - await import('@/lib/launch-structured-agent-session') - mocks.launch.mockRejectedValue(new StructuredAgentSessionCreateRefusalError('unsupported')) - const app = await sendNotesToNewAgent() - await settle() - - app.launch.cancelStructuredAgentLaunch(WORKTREE_ID, chat.sessionId) - - expect(app.inFlight.isNoteInFlight(app.key)).toBe(false) - expect(mocks.clearDeliveredDiffComments).not.toHaveBeenCalled() - }) - - it('clears browser annotations a new chat sends on', async () => { - mocks.launch.mockImplementation(() => new Promise(() => undefined)) - mocks.shelf.annotations = [ - { id: 'annotation-1', browserPageId: 'page-1', comment: 'Fix', intent: 'fix' } - ] - const app = await renderer() - const key = app.inFlight.browserAnnotationSendKey(mocks.shelf.annotations[0]) - app.launch.startStructuredAgentLaunch(WORKTREE_ID, 'codex', { - prompt: NOTES, - carriedNoteKeys: [key] - }) - - acceptStagedMessage(app) - - expect(mocks.removeDeliveredBrowserPageAnnotations).toHaveBeenCalledWith('page-1', [ - mocks.shelf.annotations[0] - ]) - }) -}) diff --git a/src/renderer/src/lib/notes-delivered-by-chat.ts b/src/renderer/src/lib/notes-delivered-by-chat.ts deleted file mode 100644 index 15872b31e19..00000000000 --- a/src/renderer/src/lib/notes-delivered-by-chat.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useAppStore } from '@/store' -import { subscribeToStructuredAgentSessionCarriedNotesSpent } from '@/components/native-chat/structured-agent-session-outbox-carried-notes' -import { - browserAnnotationSendKey, - diffCommentSendKey, - noteSendKeyOwner -} from './notes-send-in-flight' - -/** A new chat's message sent on, by its own start, a Retry or a re-check, clears the notes it - * carries from their shelf as a delivered send does, including after a reload. */ -export function installNotesDeliveredByChat(): () => void { - return subscribeToStructuredAgentSessionCarriedNotesSpent((keys) => { - const sent = new Set(keys) - const state = useAppStore.getState() - const worktreeIds = new Set() - const pageIds = new Set() - for (const key of keys) { - const owner = noteSendKeyOwner(key) - if (owner?.kind === 'diff-comment') { - worktreeIds.add(owner.owner) - } else if (owner?.kind === 'browser-annotation') { - pageIds.add(owner.owner) - } - } - for (const worktreeId of worktreeIds) { - const notes = state - .getDiffComments(worktreeId) - .filter((note) => sent.has(diffCommentSendKey(note))) - if (notes.length > 0) { - void state.clearDeliveredDiffComments(worktreeId, notes) - } - } - for (const pageId of pageIds) { - const annotations = (state.browserAnnotationsByPageId[pageId] ?? []).filter((annotation) => - sent.has(browserAnnotationSendKey(annotation)) - ) - if (annotations.length > 0) { - state.removeDeliveredBrowserPageAnnotations(pageId, annotations) - } - } - }) -} diff --git a/src/renderer/src/lib/notes-send-in-flight.ts b/src/renderer/src/lib/notes-send-in-flight.ts index 88554ad00ed..cd50f23dc7d 100644 --- a/src/renderer/src/lib/notes-send-in-flight.ts +++ b/src/renderer/src/lib/notes-send-in-flight.ts @@ -1,26 +1,13 @@ import { useSyncExternalStore } from 'react' import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments' -import type { BrowserPageAnnotation } from '../../../shared/browser-grab-types' -import { structuredAgentSessionOutboxCarriesNote } from '@/components/native-chat/structured-agent-session-outbox-storage' -import { - structuredAgentSessionCarriedNotesVersion, - subscribeToStructuredAgentSessionCarriedNotes -} from '@/components/native-chat/structured-agent-session-outbox-carried-notes' -// Why: notes handed to a send leave the next send at once and come back only if that send fails, -// as a submitted composer clears and restores on error. A new chat's saved message carries its -// notes' keys, so those stay out for as long as the chat can still send them, reload included; -// other sends hold them in memory until their own result. Delivered notes are removed by their owner. -const holds = new Map() +// 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 -/** What a notes send gives its route: the keys a new chat saves with its message, and the hold. */ -export type NotesSendHandOff = { - carriedNoteKeys: readonly string[] - handOff: (delivered: Promise) => void -} - function changed(): void { version += 1 for (const listener of listeners) { @@ -28,11 +15,32 @@ function changed(): void { } } -/** Takes `keys` out of the next send until `delivered` settles, whatever its result. */ -export function holdNotesForSend(keys: readonly string[], delivered: Promise): void { +function reportsDelivered(result: unknown): boolean { + return typeof result === 'object' && result !== null && 'delivered' in result + ? result.delivered === true + : false +} + +/** Takes `keys` out of the next send until `delivered` settles, whatever its result. A result that + * reports delivery also runs `onDelivered`, for a send whose own callback can no longer fire + * (a Retry of a failed new chat). */ +export function holdNotesForSend( + keys: readonly unknown[], + delivered: Promise, + onDelivered?: () => void +): void { if (keys.length === 0) { return } + // Registered before the release, so delivered notes are gone before they could show again. + void delivered.then( + (result) => { + if (reportsDelivered(result)) { + onDelivered?.() + } + }, + () => undefined + ) for (const key of keys) { holds.set(key, (holds.get(key) ?? 0) + 1) } @@ -51,25 +59,17 @@ export function holdNotesForSend(keys: readonly string[], delivered: Promise holdNotesForSend(keys, delivered) } -} - -export function isNoteInFlight(key: string): boolean { - return holds.has(key) || structuredAgentSessionOutboxCarriesNote(key) +export function isNoteInFlight(key: unknown): boolean { + return holds.has(key) } function subscribe(listener: () => void): () => void { listeners.add(listener) - const unsubscribeCarried = subscribeToStructuredAgentSessionCarriedNotes(listener) - return () => { - listeners.delete(listener) - unsubscribeCarried() - } + return () => listeners.delete(listener) } function getVersion(): number { - return version + structuredAgentSessionCarriedNotesVersion() + return version } /** Changes whenever a hold starts or ends, for memos that filter by `isNoteInFlight`. */ @@ -77,13 +77,9 @@ export function useNotesInFlightVersion(): number { return useSyncExternalStore(subscribe, getVersion, getVersion) } -type DiffCommentSendKeyNote = DiffCommentDeliverySnapshot & { worktreeId: string } - /** A note's identity for delivery: an edit makes it a new pending note, as for its removal. */ -export function diffCommentSendKey(note: DiffCommentSendKeyNote): string { +export function diffCommentSendKey(note: DiffCommentDeliverySnapshot): string { return JSON.stringify([ - 'diff-comment', - note.worktreeId, note.id, note.body, note.filePath, @@ -94,37 +90,6 @@ export function diffCommentSendKey(note: DiffCommentSendKeyNote): string { ]) } -export function browserAnnotationSendKey( - annotation: Pick -): string { - return JSON.stringify([ - 'browser-annotation', - annotation.browserPageId, - annotation.id, - annotation.comment, - annotation.intent - ]) -} - -/** Where a saved key's note lives (its workspace or browser page), or null for a key this build - * does not read. The note itself is found by its key. */ -export function noteSendKeyOwner( - key: string -): { kind: 'diff-comment' | 'browser-annotation'; owner: string } | null { - try { - const parts: unknown = JSON.parse(key) - if (!Array.isArray(parts)) { - return null - } - const [kind, owner] = parts - return (kind === 'diff-comment' || kind === 'browser-annotation') && typeof owner === 'string' - ? { kind, owner } - : null - } catch { - return null - } -} - export function resetNotesInFlightForTests(): void { holds.clear() changed() 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 fa0f02f73d7..0015fbd8f75 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -10,8 +10,6 @@ export type StructuredAgentLaunchOptions = { prompt?: string promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void - /** Send keys of the notes the prompt was built from, saved with its staged message. */ - carriedNoteKeys?: readonly string[] /** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's * identity, not a preference — see `launchIdentity`. */ resumeFrom?: StructuredAgentSessionResumeSource diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 25dba85a1a3..d2a174110c6 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -174,11 +174,7 @@ function joinStructuredLaunchState( // 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, - options.carriedNoteKeys - ) + ? 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) { @@ -230,7 +226,7 @@ function structuredAgentLaunchState( ) const text = outboxPromptText(options) const stagedPrompt = text - ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text, options.carriedNoteKeys) + ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text) : null launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options) const callers = createStructuredLaunchCallerGroup({ diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index b70b662dc8a..36239d52516 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -24,13 +24,11 @@ import { getOrCreateRendererRoot } from './lib/react-renderer-root' import { primeTerminalWebglAddon } from './lib/pane-manager/pane-webgl-renderer' import { SkillWarningPreviewLauncher } from './components/skills/SkillWarningPreviewLauncher' import { installBrowserClientPageRenderer } from './components/browser-pane/browser-client-page-renderer-installation' -import { installNotesDeliveredByChat } from './lib/notes-delivered-by-chat' recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() installTypingLatencyDiagnostic() installAutomationHostDiagnostic() -installNotesDeliveredByChat() if ( import.meta.env.DEV && diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 01a75db22cd..7b3606f3dc8 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -35,9 +35,6 @@ export type StructuredAgentSessionOutboxEntry = { lastAttemptAt: number | null retryAfterUnknownSubmittedAt: number | null source?: 'launch' - /** The notes a launch prompt was built from, as their send keys. Saved with the message so the - * notes stay out of another send for as long as this message can still be sent, reload included. */ - carriedNoteKeys?: string[] /** A Stop landed after this queue send went out: only the user's Retry sends it again, never the * drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */ outlivedStop?: true @@ -301,10 +298,6 @@ export function parseStructuredAgentSessionOutboxEntry( ? entry.retryAfterUnknownSubmittedAt : null, ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), - ...(Array.isArray(entry.carriedNoteKeys) && - entry.carriedNoteKeys.every((key) => typeof key === 'string') - ? { carriedNoteKeys: entry.carriedNoteKeys } - : {}), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) }