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 } : {}) }