From 62ef8e780428d835f01821d50946c4e9bf5fc6b6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:58:21 -0700 Subject: [PATCH] fix(native-chat): every chat send ends one of three ways, decided by the host's answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A send now ends only by what the host said: it holds a record (the host's row shows the message from then on), it proved there is no record (the text goes back into the conversation's draft and the reason is said once), or nothing answered yet (the same id goes again, quietly, as "Sending…"). One shared settlement decides it for the open chat and a launch prompt alike. Removed: the Retry control and its id rotation, the rejected and held-for-Retry outbox states, the Stop latch, the per-window failure memory, the parked "unknown" entry, the launch prompt's own send path, and the error-text regex (errors are read by code). A Stop stamps a send already on its way with the Stop's own id; the Stop's answer settles it. Drafts of a structured chat are keyed by the conversation and survive their tab closing. Entries older builds left waiting for a Retry are settled once the journal loads, never sent again. --- .../mobile-structured-queued-message-cards.ts | 2 +- .../mobile-structured-send-delivery.ts | 2 +- .../native-chat/NativeChatComposer.tsx | 16 +- .../native-chat/NativeChatComposerField.tsx | 13 +- ...iveChatMessageList.unsent-message.test.tsx | 216 ----- .../native-chat/NativeChatMessageRow.test.tsx | 12 +- .../native-chat/NativeChatMessageRow.tsx | 23 +- .../NativeChatQueuedMessageCard.tsx | 2 +- ...turedSession.start-failure-notice.test.tsx | 43 +- ...tiveChatStructuredSession.test-harness.tsx | 11 +- .../NativeChatStructuredSession.test.tsx | 11 +- .../NativeChatStructuredSession.tsx | 10 +- ...tiveChatStructuredSessionDelivery.test.tsx | 314 +------ .../agent-session-failure-words-text.test.ts | 2 +- .../native-chat-composer-autogrow.test.tsx | 3 +- .../native-chat-composer-composition.test.tsx | 11 +- ...ive-chat-composer-draft-comparison.test.ts | 39 + .../native-chat-composer-draft-comparison.ts | 46 + ...ive-chat-composer-draft-lifecycle.test.tsx | 80 +- .../native-chat-composer-draft-store.test.ts | 44 + .../native-chat-composer-draft-store.ts | 74 +- .../native-chat-composer-drop-scope.test.tsx | 45 +- .../native-chat/native-chat-composer-types.ts | 3 + ...chat-composer-workspace-file-drop.test.tsx | 3 +- .../native-chat-draft-cache.test.ts | 51 +- .../native-chat/native-chat-draft-cache.ts | 35 +- ...red-agent-session-delivery-notices.test.ts | 870 +++--------------- ...ructured-agent-session-delivery-notices.ts | 180 +--- ...d-agent-session-message-projection.test.ts | 8 +- ...ssion-outbox-dispatch.owner-change.test.ts | 11 +- ...tructured-agent-session-outbox-dispatch.ts | 269 ++++-- .../structured-agent-session-outbox-retry.ts | 83 -- ...tured-agent-session-outbox-storage.test.ts | 139 +-- ...structured-agent-session-outbox-storage.ts | 25 - ...uctured-agent-session-queued-cards.test.ts | 16 +- .../structured-agent-session-queued-cards.ts | 8 +- ...ctured-agent-session-returned-send.test.ts | 82 ++ .../structured-agent-session-returned-send.ts | 91 ++ ...session-withdrawn-message-restore.test.tsx | 475 ---------- ...agent-session-withdrawn-message-restore.ts | 83 -- ...ructured-conversation-command-send.test.ts | 14 +- ...tructured-session-takeover-report.test.tsx | 2 +- .../use-native-chat-composer-attachments.ts | 13 +- ...ive-chat-structured-composer-send.test.tsx | 2 +- ...se-native-chat-structured-composer-send.ts | 43 +- ...d-agent-session-conversation-stop.test.tsx | 215 +++++ ...uctured-agent-session-conversation-stop.ts | 123 +++ ...ructured-agent-session-delivery-notices.ts | 34 +- .../use-structured-agent-session-mutate.ts | 41 +- ...ed-agent-session-outbox-admission.test.tsx | 118 +-- ...ctured-agent-session-outbox-failed-here.ts | 63 -- ...ctured-agent-session-outbox-fence.test.tsx | 40 +- ...agent-session-outbox-owner-change.test.tsx | 62 +- ...ructured-agent-session-outbox-ownership.ts | 73 -- ...nt-session-outbox-rejection-cause.test.tsx | 543 ----------- ...gent-session-outbox-relaunch-hold.test.tsx | 565 ------------ ...-agent-session-outbox-unconfirmed-probe.ts | 60 +- ...d-agent-session-outbox-withdrawal.test.tsx | 75 +- ...ent-session-outbox.draft-hand-off.test.tsx | 20 +- ...ent-session-outbox.queue-delivery.test.tsx | 36 +- ...d-agent-session-outbox.settlement.test.tsx | 291 ++++++ ...ession-outbox.stop-parks-in-doubt.test.tsx | 187 ---- ...d-agent-session-outbox.stop-stamp.test.tsx | 265 ++++++ ...e-structured-agent-session-outbox.test.tsx | 340 ++----- .../use-structured-agent-session-outbox.ts | 274 +++--- ...use-structured-agent-session-stop.test.tsx | 41 +- ...tured-agent-session.queued-gating.test.tsx | 8 +- .../use-structured-agent-session.ts | 33 +- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- ...ctured-agent-session-launch-prompt.test.ts | 40 +- .../structured-agent-session-launch-prompt.ts | 96 +- ...tructured-agent-session-host-capability.ts | 31 + .../native-chat-launch-draft-teardown.test.ts | 38 +- .../store/slices/tabs/tabs-close-actions.ts | 3 +- .../removed-worktree-renderer-teardown.ts | 14 +- .../agent-session-refusal-notice.test.ts | 2 +- .../agent-session-refusal-retry.test.ts | 105 --- src/shared/agent-session-refusal-retry.ts | 17 +- src/shared/agent-session-write-failure.ts | 2 +- ...tured-agent-session-draft-hand-off.test.ts | 40 +- ...ctured-agent-session-message-projection.ts | 4 - ...ructured-agent-session-outbox-admission.ts | 26 +- ...tructured-agent-session-outbox-delivery.ts | 12 +- ...ed-agent-session-outbox-retry-hold.test.ts | 103 --- ...ed-agent-session-outbox-stop-withdrawal.ts | 97 +- ...agent-session-outbox-unconfirmed-resend.ts | 10 +- ...gent-session-outbox.queue-delivery.test.ts | 84 +- src/shared/structured-agent-session-outbox.ts | 237 ++--- ...-agent-session-recorded-rejection-words.ts | 2 +- ...red-agent-session-send-disposition.test.ts | 468 ---------- ...ructured-agent-session-send-disposition.ts | 329 ------- ...d-agent-session-send-failure-words.test.ts | 136 +++ ...ctured-agent-session-send-failure-words.ts | 104 +++ ...ured-agent-session-send-settlement.test.ts | 444 +++++++++ ...tructured-agent-session-send-settlement.ts | 280 ++++++ 100 files changed, 3758 insertions(+), 6029 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-returned-send.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-returned-send.ts delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-conversation-stop.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-conversation-stop.ts delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-failed-here.ts delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox.settlement.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-stamp.test.tsx delete mode 100644 src/shared/agent-session-refusal-retry.test.ts delete mode 100644 src/shared/structured-agent-session-outbox-retry-hold.test.ts delete mode 100644 src/shared/structured-agent-session-send-disposition.test.ts delete mode 100644 src/shared/structured-agent-session-send-disposition.ts create mode 100644 src/shared/structured-agent-session-send-failure-words.test.ts create mode 100644 src/shared/structured-agent-session-send-failure-words.ts create mode 100644 src/shared/structured-agent-session-send-settlement.test.ts create mode 100644 src/shared/structured-agent-session-send-settlement.ts diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index f3edb13e53c..6dc38372582 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -6,7 +6,7 @@ import { readWholeAgentSessionFailureFact } from '../../../src/shared/agent-sess import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' +import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-failure-words' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index a348b55a2c5..ea384cffccf 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -29,7 +29,7 @@ import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' -import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' +import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-failure-words' import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index bd9e6862788..b016dc98351 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -55,6 +55,7 @@ const NativeChatComposerPane = forwardRef draft: string disabled: boolean @@ -101,7 +103,8 @@ function imeComposedSegment(base: string, settled: string): string { } export function NativeChatComposerField({ - composerScopeKey, + dropScopeKey, + draftScopeKey, textareaRef, draft, disabled, @@ -198,7 +201,7 @@ export function NativeChatComposerField({ ) : null}
) : null} {} -beforeAll(() => { - restoreViewport = installNativeChatMessageListTestViewport() -}) -afterAll(() => restoreViewport()) -afterEach(cleanup) - -type Phase = 'in flight' | 'running' | 'done' - -const SEED = agentJournalSubmissionKey('seed') -const NEW = agentJournalSubmissionKey('new') - -function said(role: 'user' | 'assistant', text: string): AgentJournalItemBody { - return { kind: 'message', role, blocks: [{ type: 'text', text }] } -} - -function journal(phase: Phase, scoped: boolean): AgentJournalRenderItem[] { - const thread: AgentJournalTurnScope = { kind: 'thread' } - const row = ( - itemId: string, - body: AgentJournalItemBody, - turnScope: AgentJournalTurnScope - ): AgentJournalRenderItem => ({ - itemId, - revision: 0, - sequence: rows.length + 1, - observedAt: 1000 + rows.length, - body, - ...(scoped ? { turnScope } : {}) - }) - const rows: AgentJournalRenderItem[] = [] - rows.push(row(SEED, said('user', 'SEED PROMPT'), thread)) - rows.push( - row( - 'turn-seed', - { kind: 'turn', turnId: 'turn-seed', state: 'completed', userItemId: SEED }, - thread - ) - ) - rows.push( - row('seed-answer', said('assistant', 'SEED OK'), { kind: 'turn', turnItemId: 'turn-seed' }) - ) - rows.push(row(NEW, said('user', 'NEW PROMPT'), thread)) - if (phase !== 'in flight') { - rows.push( - row( - 'turn-new', - { - kind: 'turn', - turnId: 'turn-new', - state: phase === 'done' ? 'completed' : 'running', - userItemId: NEW - }, - thread - ) - ) - } - if (phase === 'done') { - rows.push( - row('new-answer', said('assistant', 'NEW OK'), { kind: 'turn', turnItemId: 'turn-new' }) - ) - } - return rows -} - -function submission( - id: string, - dispatchState: AgentJournalSubmission['dispatchState'] -): AgentJournalSubmission { - return { - clientMessageId: id, - fence: 1, - payloadFingerprint: id, - dispatchState, - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - } -} - -function unsentEntry(kind: 'held' | 'rejected'): StructuredAgentSessionOutboxEntry { - const entry = createStructuredAgentSessionOutboxEntry({ - clientMessageId: 'held', - sessionId: 'session-1', - text: 'HELD PROMPT', - attachments: [], - queuedAt: 500 - }) - return kind === 'held' - ? { - ...entry, - lastAttemptAt: 500, - lastFailure: { kind: 'refused', code: 'agent_session_journal_unreadable' } - } - : { ...entry, state: 'rejected', lastFailure: { kind: 'rejected', reason: null } } -} - -function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutboxEntry[]) { - const items = journal(phase, scoped) - const submissions = [ - submission('seed', 'accepted'), - submission('new', phase === 'done' ? 'accepted' : 'pending') - ] - const settledTurns: NativeChatSettledTurns = new Map([ - [SEED, { startedAt: 1, workedSeconds: 3 }], - ...(phase === 'done' ? [[NEW, { startedAt: 2, workedSeconds: 5 }] as const] : []) - ]) - return ( - - ) -} - -/** The drawn sequence of the prompts, bars and live activity line, top to bottom. */ -function drawn(container: HTMLElement): string[] { - const out: string[] = [] - for (const element of container.querySelectorAll('*')) { - if (element.hasAttribute('data-native-chat-turn-activity')) { - out.push('ACTIVITY') - } else if (element.hasAttribute('data-native-chat-turn-status')) { - out.push( - element.getAttribute('data-native-chat-turn-status') === 'active' ? 'WORKING' : 'WORKED' - ) - } else if (element.childElementCount === 0 && (element.textContent ?? '').endsWith('PROMPT')) { - out.push(element.textContent ?? '') - } - } - return out -} - -describe.each([ - ['held for its Retry', 'held'], - ['rejected', 'rejected'] -] as const)('a message %s, below a newer turn', (_label, kind) => { - it.each([ - ['states each row turn', true], - ['states no turn scope', false] - ])( - 'keeps the newer turn bar with that turn while it runs and once done (host %s)', - (_host, scoped) => { - const outbox = [unsentEntry(kind)] - const { container, rerender } = render(list('in flight', scoped, outbox)) - expect(drawn(container)).toEqual([ - 'SEED PROMPT', - 'WORKED', - 'NEW PROMPT', - 'WORKING', - 'ACTIVITY', - 'HELD PROMPT' - ]) - rerender(list('running', scoped, outbox)) - expect(drawn(container)).toEqual([ - 'SEED PROMPT', - 'WORKED', - 'NEW PROMPT', - 'WORKING', - 'ACTIVITY', - 'HELD PROMPT' - ]) - rerender(list('done', scoped, outbox)) - expect(drawn(container)).toEqual([ - 'SEED PROMPT', - 'WORKED', - 'NEW PROMPT', - 'WORKED', - 'HELD PROMPT' - ]) - expect(container.textContent).toContain('Worked for 5s') - } - ) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index 530e490c44a..0f324482a1d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -181,15 +181,13 @@ describe('what a user message says about its delivery', () => { ) } - it('says why under the message, with a Retry that sends this one', () => { - const onRetry = vi.fn() - renderUser({ text: "The agent couldn't restart. Your message was not sent.", onRetry }) + it('says why under the message, with no Retry: sending again is a new message', () => { + renderUser({ text: "The agent couldn't restart. Your message was not sent." }) expect( screen.getByText("The agent couldn't restart. Your message was not sent.") ).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Retry' })) - expect(onRetry).toHaveBeenCalledOnce() + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() }) it('offers no Retry where the surface cannot send it again', () => { @@ -199,9 +197,9 @@ describe('what a user message says about its delivery', () => { expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull() }) - // A plain "not sent" is a label, not an error; a doubt to check keeps the error color. + // A plain "not sent" is a label, not an error; anything else keeps the error color. it('reads muted when it says only that the message was not sent', () => { - renderUser({ text: 'Your message was not sent.', notSent: true }) + renderUser({ text: 'Your message was not sent.', muted: true }) const notSent = screen.getByText('Your message was not sent.').parentElement expect(notSent).toHaveClass('text-muted-foreground') expect(notSent).not.toHaveClass('text-destructive/80') diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index f6756003119..ab10aa495b6 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useRef } from 'react' -import { Goal, RotateCcw } from 'lucide-react' +import { Goal } from 'lucide-react' import { Button } from '@/components/ui/button' import CommentMarkdown, { type CommentMarkdownLinkClickHandler @@ -29,16 +29,14 @@ import type { import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' /** What a user message says about its delivery: that nothing has confirmed it yet, quietly in - * place of its time, or that it did not go through, with its own Retry when the surface can send - * it again. */ + * place of its time, or how it ended when it did not go through. */ export type NativeChatDeliveryNotice = - | { sending: true; text?: never; notSent?: never; onRetry?: never; onDismiss?: never } + | { sending: true; text?: never; muted?: never; onDismiss?: never } | { sending?: never text: string - /** Says only that the message did not go out, so it reads muted, not as an error. */ - notSent?: true - onRetry?: () => void + /** Says only that the message did not go out, or is not confirmed: muted, not an error. */ + muted?: true onDismiss?: () => void } @@ -227,7 +225,7 @@ export const MessageRow = memo(function MessageRow({
{deliveryNotice.text} @@ -236,15 +234,6 @@ export const MessageRow = memo(function MessageRow({ {translate('components.native-chat.dismissDeliveryNotice', 'Dismiss')} ) : null} - {deliveryNotice.onRetry ? ( - - ) : null}
) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 29432e64330..8fd9058c3cb 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -17,7 +17,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { translate } from '@/i18n/i18n' -import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' +import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-failure-words' import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx index 762f0f90989..b4674d2ad56 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx @@ -61,17 +61,6 @@ function startFailureRow(fact: AgentSessionFailureFact): AgentJournalRenderItem function rejected(clientMessageId: string, reason: string, rejection: AgentSessionFailureFact) { return { - outbox: { - clientMessageId, - sessionId: SESSION_ID, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, - previewUris: [], - state: 'rejected', - queuedAt: 1, - lastAttemptAt: null, - retryAfterUnknownSubmittedAt: null, - lastFailure: { kind: 'rejected', reason, rejection: { kind: rejection.kind } } - }, submission: { clientMessageId, fence: 1, @@ -86,13 +75,26 @@ function rejected(clientMessageId: string, reason: string, rejection: AgentSessi } } +function userRow(clientMessageId: string, sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(clientMessageId), + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] } + } +} + +// The sender's own message: its entry left the outbox when the host answered, so the host's +// record alone shows it, as in every other window. function renderPane(messages: ReturnType[]): void { - mocks.mode = 'outbox' - mocks.submissions = messages.map((message) => message.submission) - localStorage.setItem( - `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, - JSON.stringify(messages.map((message) => message.outbox)) - ) + mocks.journalItems = [ + ...mocks.journalItems, + ...messages.map((message, index) => userRow(message.submission.clientMessageId, index + 2)) + ] + const submissions = messages.map((message) => message.submission) + mocks.submissions = submissions + mocks.messages = projectStructuredAgentSessionMessages(mocks.journalItems, [], submissions) render( { } // The start's own row says why, so its rejected messages say only that they were not sent. -it("says only 'not sent', with its Retry, on each message the failed start's row explains", async () => { +it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => { mocks.journalItems = [startFailureRow(START_FAILED)] renderPane([ @@ -129,7 +131,7 @@ it("says only 'not sent', with its Retry, on each message the failed start's row for (const id of ['first', 'second']) { const row = await notice(id) expect(within(row).getByText('Your message was not sent.')).toBeTruthy() - expect(within(row).getByRole('button', { name: 'Retry' })).toBeTruthy() + expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull() } expect(screen.queryByText(/stopped before it finished starting/)).toBeNull() }) @@ -162,7 +164,8 @@ it("keeps the start failure's own words when its row is not loaded", async () => renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) expect( - within(await notice('first')).getByText('Claude stopped before it finished starting.') + // No Retry here, so the words carry the step: sending again is the user's new message. + within(await notice('first')).getByText(START_FAILED_REASON) ).toBeTruthy() }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 9ce0a000634..15f995e097d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -27,8 +27,8 @@ function absent(): T | undefined { return undefined } -/** Stands in for the transcript: renders only each message's delivery notice and its Retry, or the - * row's quiet "Sending…" while nothing has confirmed it. */ +/** Stands in for the transcript: renders only each message's delivery notice, or the row's quiet + * "Sending…" while nothing has confirmed it. */ export function DeliveryNoticesMock({ notices }: { @@ -39,11 +39,6 @@ export function DeliveryNoticesMock({ {[...(notices ?? [])].map(([id, notice]) => (
{notice.sending ? 'Sending…' : notice.text} - {notice.onRetry ? ( - - ) : null}
))} @@ -175,10 +170,8 @@ export function createStructuredSessionMocks() { loadOlder: mocks.loadOlder, prompts: mocks.promptItems, outbox: outbox.outbox, - failedHere: outbox.failedHere, submissions: mocks.submissions, send: outbox.send, - retry: outbox.retry, isWorking: mocks.isWorking, backgroundTasks: { show: mocks.showBackgroundTasks || mocks.monitoringBackgroundTasks, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index e2c4c6ff030..1913c9a5e11 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -28,6 +28,7 @@ vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' describe('NativeChatStructuredSession', () => { afterEach(() => { @@ -35,7 +36,7 @@ describe('NativeChatStructuredSession', () => { resetStructuredSessionMocks() }) - it('gives what a Stop withdrew back to the composer this pane shows', () => { + it("gives the composer this pane shows the conversation's own draft", () => { render( { /> ) const paneKey = structuredAgentSessionPaneKey('structured-tab-1', 'session-1') - expect(mocks.composerProps).toMatchObject({ paneKey }) - expect(mocks.controllerProps).toMatchObject({ composerScopeKey: paneKey }) + // The pane routes drops and pickers; the draft belongs to the conversation, whatever pane shows it. + expect(mocks.composerProps).toMatchObject({ + paneKey, + draftScopeKey: structuredAgentSessionDraftScopeKey('session-1') + }) + expect(mocks.controllerProps).not.toHaveProperty('composerScopeKey') }) it('routes the launch draft and app-menu paste to the structured composer', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b56732871db..ad04a25ac59 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -5,6 +5,7 @@ import { structuredAgentSessionPaneKey } from '../../../../shared/structured-age import type { NativeChatLiveSession } from './use-native-chat-live-session' import { NativeChatApprovalCard } from './NativeChatApprovalCard' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' +import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' import { NativeChatEmptyState } from './NativeChatEmptyState' import { NativeChatLoadingCue } from './NativeChatLoadingCue' import { NativeChatMessageList } from './NativeChatMessageList' @@ -50,7 +51,6 @@ export function NativeChatStructuredSession( const queueFollowUps = useAppStore((store) => store.settings?.nativeChatQueueFollowUps !== false) const controller = useStructuredAgentSession({ ...props, - composerScopeKey: paneKey, queueFollowUps, providerStarting: startupPhase === 'starting', transportEnabled: provisionalLaunch.transportEnabled, @@ -117,9 +117,10 @@ export function NativeChatStructuredSession( journalItems: controller.journalItems, submissions: controller.submissions, outbox: controller.outbox, - failedHere: controller.failedHere, - retry: controller.retry, - agentLabel + agentLabel, + // A lost outcome may still resolve while the agent works or starts. + agentActive: + controller.isWorking || startupPhase === 'starting' || !provisionalLaunch.transportEnabled }) const viewState = selectNativeChatViewState(session, { readRetries: true }) // Nothing reads an unread history, so its pane stays blank beside the Retry line. @@ -361,6 +362,7 @@ export function NativeChatStructuredSession( ref={composerRef} terminalTabId={props.tabId} paneKey={paneKey} + draftScopeKey={structuredAgentSessionDraftScopeKey(props.sessionId)} targetPtyId={null} agent={props.agent} canSend={!prompt} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index f13b3b4d816..9e3385f68ea 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -1,9 +1,9 @@ -// The delivery notice and the outbox queue behind it: which entry a Retry acts -// on, when no notice is owed at all, and how a host-confirmed unknown is probed. +// The delivery notice and the outbox queue behind it: a send with no answer reads as sending and +// goes again under its id, and a send the host holds a row for leaves, so nothing waits on it. // @vitest-environment happy-dom -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import React, { forwardRef, useImperativeHandle, useRef } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' @@ -79,10 +79,8 @@ vi.mock('./use-structured-agent-session', async () => { loadOlder: vi.fn(), prompts: mocks.promptItems, outbox: outbox.outbox, - failedHere: outbox.failedHere, submissions: mocks.submissions, send: outbox.send, - retry: outbox.retry, isWorking: false, isMonitoringBackgroundTasks: mocks.monitoringBackgroundTasks, supportsBackgroundTaskStop: mocks.supportsBackgroundTaskStop, @@ -183,10 +181,7 @@ vi.mock('./NativeChatQuestionCard', () => ({ })) import { NativeChatStructuredSession } from './NativeChatStructuredSession' -import { - appendStructuredAgentSessionOutboxMessage, - getStructuredAgentSessionOutbox -} from './structured-agent-session-outbox-storage' +import { getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage' describe('NativeChatStructuredSession delivery', () => { afterEach(() => { @@ -221,10 +216,7 @@ describe('NativeChatStructuredSession delivery', () => { previewUris: [], state, queuedAt: clientMessageId === 'op-head' ? 1 : 2, - lastAttemptAt: null, - // Already force-retried once, so the automatic probe leaves the head alone - // and only the user's Retry moves it. - retryAfterUnknownSubmittedAt: -1 + lastAttemptAt: null } } @@ -281,7 +273,6 @@ describe('NativeChatStructuredSession delivery', () => { ...seededEntry(sessionId, 'op-sent', 'first', 'queued'), state: 'dispatching', lastAttemptAt: 1, - retryAfterUnknownSubmittedAt: null, ...patch } ]) @@ -326,12 +317,14 @@ describe('NativeChatStructuredSession delivery', () => { expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() }, 10000) + // The host has a record of it, so the row shows it from there: the entry leaves, and nothing + // waits on a Retry that no longer exists. it.each([ ['a live unknown', {}], ['a recovered unknown', { recovered: true }], ["an older host's recovered unknown", { reason: 'host_restarted_before_acknowledgement' }] ])( - 'says a send reopened mid-send is unconfirmed, with its Retry, once the journal holds %s', + 'lets a send reopened mid-send leave once the journal holds %s, with no Retry and no resend', async (label, patch) => { mocks.mode = 'outbox' const sessionId = `session-reopened-${label.replace(/\W+/g, '-')}` @@ -352,9 +345,9 @@ describe('NativeChatStructuredSession delivery', () => { renderSession(sessionId) - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - expect(screen.getByRole('button', { name: /Retry/ })).toBeTruthy() - expect(screen.queryByText('Sending…')).toBeNull() + await waitFor(() => expect(getStructuredAgentSessionOutbox(sessionId)).toEqual([])) + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull() + expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1500)) }) @@ -363,214 +356,22 @@ describe('NativeChatStructuredSession delivery', () => { 10000 ) - it('says a send a Stop outlived is unconfirmed when reopened, as nothing resends it', async () => { + it('never sends a send an older build said a Stop outlived, and offers no Retry', async () => { mocks.mode = 'outbox' mocks.submissions = [] seedMidSend('session-reopened-stopped', { outlivedStop: true }) renderSession('session-reopened-stopped') - expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy() - expect(screen.getByRole('button', { name: /Retry/ })).toBeTruthy() - expect(screen.queryByText('Sending…')).toBeNull() + // Its settlement waits for the journal; until then it reads as not confirmed yet. + expect(screen.getByText('Sending…')).toBeTruthy() + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull() await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1500)) }) expect(mocks.call).not.toHaveBeenCalled() }, 10000) - it('retries the head, not a later stuck message', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - mocks.call.mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'client-1', dispatchState: 'accepted' } } - }) - seedOutbox('session-retry-head', [ - seededEntry('session-retry-head', 'op-head', 'first', 'unconfirmed'), - seededEntry('session-retry-head', 'op-later', 'second', 'unconfirmed') - ]) - - render( - - ) - - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - const request = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } } - expect(request.envelope.clientOperationId).toBe('op-head') - }) - - it('offers a rejected message no Retry while the queue is stopped, and gives it back once it moves', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - mocks.call.mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'op-head', dispatchState: 'accepted' } } - }) - seedOutbox('session-held-rejected', [ - seededEntry('session-held-rejected', 'op-head', 'first', 'unconfirmed'), - { - ...seededEntry('session-held-rejected', 'op-rejected', 'second', 'queued'), - state: 'rejected', - lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } - } - ]) - - render( - - ) - - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy() - // One Retry, the stopped message's: it sends only that one. - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({ - envelope: { clientOperationId: 'op-head' } - }) - - // The queue moved, so the rejected message offers its own Retry again. - await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) - expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy() - expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(1) - expect(mocks.call).toHaveBeenCalledOnce() - }) - - it("words a failed start on each message by the chat's agent, leaving the resend to its Retry", async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - const startFailed = (clientMessageId: string, text: string) => ({ - ...seededEntry('session-start-failed', clientMessageId, text, 'queued'), - state: 'rejected' as const, - lastFailure: { - kind: 'rejected' as const, - reason: 'Codex stopped before it finished starting. Send your message to try again.', - rejection: { kind: 'providerStartFailed' as const } - } - }) - seedOutbox('session-start-failed', [ - startFailed('op-first', 'first'), - startFailed('op-second', 'second') - ]) - - render( - - ) - - await waitFor(() => - expect(screen.getAllByText('Codex stopped before it finished starting.')).toHaveLength(2) - ) - expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(2) - expect(screen.queryByText(/Send your message to try again/)).toBeNull() - }) - - it("words a rejected message from its loaded journal row, not the message's own copy", async () => { - mocks.mode = 'outbox' - const reason = "Claude couldn't start. Send your message to try again." - mocks.submissions = [ - { - clientMessageId: 'op-recorded', - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'rejected', - providerItemId: null, - reason, - rejection: { - kind: 'startFailed', - refusal: { code: 'agent_session_identity_required', details: { reason: 'recordMissing' } } - }, - submittedAt: 1, - resolvedAt: 1 - } - ] - seedOutbox('session-recorded', [ - { - ...seededEntry('session-recorded', 'op-recorded', 'first', 'queued'), - state: 'rejected', - lastFailure: { kind: 'rejected', reason, rejection: { kind: 'startFailed' } } - } - ]) - - render( - - ) - - await waitFor(() => - expect(screen.getByText("Codex couldn't start. Start a new chat to continue.")).toBeTruthy() - ) - expect(screen.queryByText(reason)).toBeNull() - }) - - it('names the stuck message behind an admitted head, and its Retry sends that one', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - // The head is admitted -- written and awaiting the provider -- so the entry behind it is the - // one holding the queue, and Retry acts on it instead of waiting for the head to clear. - mocks.call.mockResolvedValue({ - ok: true, - value: { submission: { clientMessageId: 'op-head', dispatchState: 'pending' } } - }) - seedOutbox('session-quiet-head', [ - seededEntry('session-quiet-head', 'op-head', 'first', 'queued'), - seededEntry('session-quiet-head', 'op-later', 'second', 'unconfirmed') - ]) - - render( - - ) - - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({ - envelope: { clientOperationId: 'op-head' } - }) - - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({ - envelope: { clientOperationId: 'op-later' } - }) - }) - it('resends a transport-unconfirmed head so later messages are not wedged', async () => { mocks.mode = 'outbox' mocks.submissions = [] @@ -636,7 +437,7 @@ describe('NativeChatStructuredSession delivery', () => { ) }, 20000) - it('parks a host-confirmed unknown instead of probing it', async () => { + it('lets a head the host answers in doubt leave, so the message behind it goes out (no parked head)', async () => { mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ ok: true, @@ -661,7 +462,7 @@ describe('NativeChatStructuredSession delivery', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } } - // The host now reports an unresolved unknown: another replay is the user's call. + // The host now reports the row in doubt: it has a record, so the row shows it from here. mocks.submissions = [ { clientMessageId: sent.envelope.clientOperationId, @@ -674,17 +475,17 @@ describe('NativeChatStructuredSession delivery', () => { resolvedAt: null } ] - // Queue a second message purely to re-render so the effect observes the - // new submissions; it must stay wedged behind the parked head. + // A later send, with no user action: it goes out instead of waiting behind the head. await act(async () => { send?.('second', []) }) - // From the row on, only the user's Retry moves it, so it says so. - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) - expect(mocks.call).toHaveBeenCalledOnce() + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 5000 }) + const texts = mocks.call.mock.calls.map( + (call) => (call[2] as { body: { blocks: { text: string }[] } }).body.blocks[0]?.text + ) + expect(texts).toEqual(['first', 'second']) + expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull() }, 20000) it('still probes while streaming batches rebuild the submissions array', async () => { @@ -777,31 +578,6 @@ describe('NativeChatStructuredSession delivery', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2), { timeout: 1500 }) }, 10000) - it('never auto-probes an entry the user already force-retried', async () => { - mocks.mode = 'outbox' - mocks.submissions = [] - mocks.call.mockRejectedValue(new Error('socket closed')) - seedOutbox('session-forced', [seededEntry('session-forced', 'op-head', 'first', 'unconfirmed')]) - - render( - - ) - - // Only the user's Retry moves it, so it says so. - await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)) - }) - expect(mocks.call).not.toHaveBeenCalled() - }, 20000) - it('does not hot-loop when the host answers pending', async () => { mocks.mode = 'outbox' mocks.call.mockResolvedValue({ @@ -867,44 +643,4 @@ describe('NativeChatStructuredSession delivery', () => { vi.useRealTimers() } }, 30000) - - // A cause seen while the chat was open is worded in full; one read back after the chat is - // reopened may have cleared, until a Retry it still stops brings it back. - it('words a refusal seen here in full, and after a reopen only once its Retry is refused', async () => { - const newerOrca = - 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' - mocks.mode = 'outbox' - mocks.call.mockResolvedValue({ - ok: false, - refusal: { - code: 'agent_session_journal_unreadable', - message: 'newer', - details: { reason: 'journalWrittenByNewerOrca' } - } - }) - const view = (): React.JSX.Element => ( - - ) - const first = render(view()) - // The composer's own enqueue. - act(() => { - expect(appendStructuredAgentSessionOutboxMessage('session-held', 'hello')).not.toBeNull() - }) - await waitFor(() => expect(screen.getByText(newerOrca)).toBeTruthy()) - first.unmount() - - render(view()) - await waitFor(() => expect(screen.getByText('Your message was not sent.')).toBeTruthy()) - expect(mocks.call).toHaveBeenCalledOnce() - fireEvent.click(screen.getByRole('button', { name: /Retry/ })) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - await waitFor(() => expect(screen.getByText(newerOrca)).toBeTruthy()) - }) }) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts index 47ce0552c59..5866e668a4a 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.test.ts @@ -20,7 +20,7 @@ import { import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy' import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure' -import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-send-disposition' +import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-send-failure-words' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' diff --git a/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx index 826121db975..52b1e09346f 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-autogrow.test.tsx @@ -46,7 +46,8 @@ function TestField({ const imeEnterGesture = useImeEnterGestureOwnership() return ( ()} draft={draft} disabled={false} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx index 8749d20ad39..c1f6dd24cb5 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-composition.test.tsx @@ -35,7 +35,8 @@ function TestField(props: TestFieldProps): React.JSX.Element { function fieldProps(overrides: Partial = {}): TestFieldProps { return { - composerScopeKey: 'pane-test', + dropScopeKey: 'pane-test', + draftScopeKey: 'pane-test', textareaRef: createRef(), draft: '', disabled: false, @@ -79,8 +80,12 @@ function textarea(): HTMLTextAreaElement { describe('native chat composer drop-scope marker', () => { // The drop pipeline stops walking at the drop-target marker, so a scope key on // any other element would never reach the payload. - it('publishes the scope key on the same element as the drop-target marker', () => { - const view = render() + it('publishes the pane key, not the draft key, on the same element as the drop-target marker', () => { + const view = render( + + ) const marker = view.container.querySelector('[data-native-file-drop-target="composer"]') expect(marker).not.toBeNull() expect(marker?.getAttribute('data-composer-scope-key')).toBe('tab-7:pane-9') diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.test.ts new file mode 100644 index 00000000000..55ed8516227 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { nativeChatComposerDraftLeftAfterSend } from './native-chat-composer-draft-comparison' + +const SHOT = { id: 'a-1', path: '/repo/shot.png' } +const DIAGRAM = { id: 'a-2', path: '/repo/diagram.png' } +const SENT = { text: 'abcd', images: [SHOT] } + +describe('nativeChatComposerDraftLeftAfterSend', () => { + it('leaves nothing of a draft still as sent', () => { + expect(nativeChatComposerDraftLeftAfterSend(SENT, SENT)).toEqual({ text: '', images: [] }) + }) + + it('keeps text typed after, and images attached, since the send', () => { + expect( + nativeChatComposerDraftLeftAfterSend({ text: 'abcd more', images: [SHOT, DIAGRAM] }, SENT) + ).toEqual({ text: ' more', images: [DIAGRAM] }) + }) + + it('keeps text composed inside the sent text, away from its end', () => { + expect(nativeChatComposerDraftLeftAfterSend({ text: 'ab가cd', images: [] }, SENT)).toEqual({ + text: '가', + images: [] + }) + }) + + it('leaves a draft replaced or edited inside the sent text alone', () => { + expect(nativeChatComposerDraftLeftAfterSend({ text: 'new', images: [SHOT] }, SENT)).toBeNull() + expect(nativeChatComposerDraftLeftAfterSend({ text: 'abXd', images: [] }, SENT)).toBeNull() + }) + + it('keeps the whole text when only images were sent', () => { + expect( + nativeChatComposerDraftLeftAfterSend( + { text: 'typed', images: [SHOT] }, + { text: '', images: [SHOT] } + ) + ).toEqual({ text: 'typed', images: [] }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts new file mode 100644 index 00000000000..6898edc7830 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-comparison.ts @@ -0,0 +1,46 @@ +// How two copies of a composer draft compare, such as the draft now against the one that was sent. + +import type { + NativeChatComposerDraft, + NativeChatComposerDraftImage +} from './native-chat-composer-draft-storage' + +export function sameNativeChatComposerDraftImages( + left: readonly NativeChatComposerDraftImage[], + right: readonly NativeChatComposerDraftImage[] +): boolean { + return ( + left.length === right.length && + left.every( + (image, index) => + image.id === right[index].id && + image.path === right[index].path && + image.connectionId === right[index].connectionId && + image.unavailableName === right[index].unavailableName + ) + ) +} + +/** + * What a draft keeps once its send is accepted: anything added since, meaning text inserted into + * the sent text (typing or a composition begun before the send settled) and images attached + * meanwhile. Null when the draft was otherwise changed since (replaced, or edited inside the sent + * text), which is then left as it is. + */ +export function nativeChatComposerDraftLeftAfterSend( + current: NativeChatComposerDraft, + sent: NativeChatComposerDraft +): NativeChatComposerDraft | null { + let prefix = 0 + while (prefix < sent.text.length && sent.text[prefix] === current.text[prefix]) { + prefix += 1 + } + const rest = sent.text.slice(prefix) + if (current.text.length < sent.text.length || !current.text.endsWith(rest)) { + return null + } + return { + text: current.text.slice(prefix, current.text.length - rest.length), + images: current.images.filter((image) => !sent.images.some((held) => held.id === image.id)) + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 8f50952821c..7d742eecfba 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, createElement, useState } from 'react' +import { act, createElement, Fragment, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' import type * as DraftHook from './use-native-chat-draft' @@ -149,7 +149,7 @@ function composer( draftScopeKey: scopeKey, imageAttachments: attachments.imageAttachments, structuredTransport: transport, - clearImageAttachments: attachments.clearImageAttachments, + isComposing: () => false, clearSkillOrigin: () => {}, setHistory: () => {}, setDraft, @@ -299,6 +299,82 @@ describe('native-chat composer draft lifecycle', () => { ]) }) + it('clears an accepted send’s text and images from the shown composer', async () => { + const hooks = await loadHooks() + const held = heldTransport() + const seen: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (seen.api = next), held.transport), + { scopeKey: 'agent-session:s1' } + ) + ) + await act(async () => { + seen.api?.setDraft('sent text') + seen.api?.attachments.attachResolvedPaths(['/repo/a.png']) + }) + await act(async () => seen.api?.send('sent text')) + + await held.settle() + + expect(seen.api?.draft).toBe('') + expect(seen.api?.attachments.imageAttachments).toEqual([]) + expect(storedDraft('agent-session:s1')).toBeNull() + }) + + it('leaves a draft replaced before the send was accepted as it is', async () => { + const hooks = await loadHooks() + const held = heldTransport() + const seen: { api?: ComposerApi } = {} + await mount( + createElement( + composer(hooks, (next) => (seen.api = next), held.transport), + { scopeKey: 'agent-session:s1' } + ) + ) + await act(async () => seen.api?.setDraft('sent text')) + await act(async () => seen.api?.send('sent text')) + await act(async () => seen.api?.setDraft('typed while it was on its way')) + + await held.settle() + + expect(seen.api?.draft).toBe('typed while it was on its way') + }) + + it('shares one conversation’s draft between its composers, and keeps what the other added', async () => { + const hooks = await loadHooks() + const held = heldTransport() + const sender: { api?: ComposerApi } = {} + const other: { api?: ComposerApi } = {} + await mount( + createElement( + Fragment, + null, + createElement( + composer(hooks, (next) => (sender.api = next), held.transport), + { + scopeKey: 'agent-session:s1' + } + ), + createElement( + composer(hooks, (next) => (other.api = next)), + { + scopeKey: 'agent-session:s1' + } + ) + ) + ) + await act(async () => sender.api?.setDraft('sent text')) + expect(other.api?.draft).toBe('sent text') + await act(async () => sender.api?.send('sent text')) + await act(async () => other.api?.setDraft((previous) => `${previous} and more`)) + + await held.settle() + + expect(sender.api?.draft).toBe(' and more') + expect(other.api?.draft).toBe(' and more') + }) + it('keeps a shown composer’s pasted image when many other drafts are written', async () => { const hooks = await loadHooks() const drafts = await import('./native-chat-draft-cache') diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index f8a7ec1f830..e200da5e18a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -484,6 +484,50 @@ describe('native-chat composer draft store', () => { expect(reloaded.drafts.readNativeChatDraftCache('tab-10:pane')).toBe('other tab') }) + it('keeps a conversation’s draft when a tab is closed, and drops it when deleted by its key', async () => { + const conversation = modules.store.structuredAgentSessionDraftScopeKey('session-1') + expect(conversation).toBe('agent-session:session-1') + modules.drafts.writeNativeChatDraftCache(conversation, 'unsent') + modules.drafts.writeNativeChatDraftCache('tab-1:pane', 'pane draft') + modules.store.flushNativeChatComposerDrafts() + // The prefix before the conversation key's ':' must never read as a tab id. + modules.store.deleteNativeChatComposerDraftsForTab('agent-session') + modules.store.deleteNativeChatComposerDraftsForTab('tab-1') + expect(modules.drafts.readNativeChatDraftCache(conversation)).toBe('unsent') + + const listener = vi.fn() + modules.store.subscribeToNativeChatComposerDraft(conversation, listener) + modules.drafts.writeNativeChatDraftCache(conversation, 'unsent, still deferred') + modules.store.deleteNativeChatComposerDraft(conversation) + expect(listener).toHaveBeenCalled() + expect(modules.drafts.readNativeChatDraftCache(conversation)).toBe('') + modules.store.flushNativeChatComposerDrafts() + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache(conversation)).toBe('') + }) + + it('gives text back once, even when the hand-back repeats, with no composer shown', async () => { + const conversation = modules.store.structuredAgentSessionDraftScopeKey('session-1') + modules.drafts.returnNativeChatDraftText(conversation, 'withdrawn message') + modules.drafts.returnNativeChatDraftText(conversation, ' withdrawn message\n') + expect(modules.drafts.readNativeChatDraftCache(conversation)).toBe('withdrawn message') + // Saved at once: the copy it came from goes right after. + const reloaded = await reload() + expect(reloaded.drafts.readNativeChatDraftCache(conversation)).toBe('withdrawn message') + }) + + it('adds an image given back again only once', () => { + const image = { id: 'withdrawn-m1-0', path: '/repo/shot.png' } + modules.attachments.appendNativeChatAttachmentCache('agent-session:s1', [image]) + modules.attachments.appendNativeChatAttachmentCache('agent-session:s1', [ + image, + { id: 'withdrawn-m1-1', path: '/repo/other.png' } + ]) + expect( + modules.attachments.readNativeChatAttachmentCache('agent-session:s1').map(({ id }) => id) + ).toEqual(['withdrawn-m1-0', 'withdrawn-m1-1']) + }) + it('keeps the drafts of a tab whose id extends the closed one', async () => { // A second chat for one session gets `:history-1`, so a prefix match would reach it. const closed = 'structured-agent-session-claude_1' diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index e5b79ba9b09..9c9e9720fad 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -5,6 +5,7 @@ import type { JSONContent } from '@tiptap/react' import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' +import { sameNativeChatComposerDraftImages } from './native-chat-composer-draft-comparison' import { basename } from '@/lib/path' import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' import { @@ -107,22 +108,6 @@ function isEmptyDraft(draft: NativeChatComposerDraft): boolean { return draft.text === '' && draft.images.length === 0 } -function sameImages( - left: readonly NativeChatComposerDraftImage[], - right: readonly NativeChatComposerDraftImage[] -): boolean { - return ( - left.length === right.length && - left.every( - (image, index) => - image.id === right[index].id && - image.path === right[index].path && - image.connectionId === right[index].connectionId && - image.unavailableName === right[index].unavailableName - ) - ) -} - /** Monotonic within a run, so drafts changed in the same millisecond still age in order. */ function nextSavedAt(): number { lastSavedAt = Math.max(Date.now(), lastSavedAt + 1) @@ -274,7 +259,7 @@ export function updateNativeChatComposerDraft( text === current.text && document === current.document && unsavedText === current.unsavedText && - sameImages(images, current.images) + sameNativeChatComposerDraftImages(images, current.images) ) { if (persist === 'immediate' && dirtyScopes.has(scopeKey)) { flushNativeChatComposerDrafts() @@ -318,36 +303,55 @@ export function clearNativeChatComposerDraftIfUnchanged( sent: NativeChatComposerDraft ): boolean { const current = readNativeChatComposerDraft(scopeKey) - if (current.text !== sent.text || !sameImages(current.images, sent.images)) { + if ( + current.text !== sent.text || + !sameNativeChatComposerDraftImages(current.images, sent.images) + ) { return false } updateNativeChatComposerDraft(scopeKey, { text: '', images: [] }, 'immediate') return true } +const STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX = 'agent-session:' + +/** A structured chat's draft belongs to its conversation, so it outlives the tab showing it. */ +export function structuredAgentSessionDraftScopeKey(sessionId: string): string { + return `${STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX}${sessionId}` +} + +function deleteDraftsWhere(owned: (scopeKey: string) => boolean): void { + const storage = nativeChatComposerDraftStorage() + if (storage) { + removeStoredNativeChatComposerDraftsWhere(storage, owned) + } + for (const scopeKey of new Set([...records.keys(), ...scopeListeners.keys()])) { + if (owned(scopeKey)) { + records.delete(scopeKey) + dirtyScopes.delete(scopeKey) + notifyScope(scopeKey) + } + } +} + +/** Drops one scope's draft, for an owner that is gone for good. */ +export function deleteNativeChatComposerDraft(scopeKey: string): void { + deleteDraftsWhere((key) => key === scopeKey) +} + /** A pane key is `:`; the leaf is a UUID, while a tab id may hold ':' itself. */ function scopeTabId(scopeKey: string): string { return scopeKey.slice(0, scopeKey.lastIndexOf(':')) } -/** Drops the drafts of every pane in a tab the user closed; its pane keys never come back. */ +/** Drops the drafts of every pane in a tab the user closed; its pane keys never come back. A + * conversation's draft is never a tab's. */ export function deleteNativeChatComposerDraftsForTab(tabId: string): void { - const inTab = (scopeKey: string): boolean => scopeTabId(scopeKey) === tabId - for (const scopeKey of records.keys()) { - if (inTab(scopeKey)) { - records.delete(scopeKey) - dirtyScopes.delete(scopeKey) - } - } - const storage = nativeChatComposerDraftStorage() - if (storage) { - removeStoredNativeChatComposerDraftsWhere(storage, inTab) - } - for (const scopeKey of scopeListeners.keys()) { - if (inTab(scopeKey)) { - notifyScope(scopeKey) - } - } + deleteDraftsWhere( + (scopeKey) => + !scopeKey.startsWith(STRUCTURED_AGENT_SESSION_DRAFT_SCOPE_PREFIX) && + scopeTabId(scopeKey) === tabId + ) } export function clearNativeChatComposerDraftsForTests(): void { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index 508a7c20e5e..25782623dfd 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -53,11 +53,20 @@ import { } from '../../../../preload/preload-runtime-support' // Uses the production drop listener, subscriber fan-out, attachment hook, and scope cache. -function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolean }) { +function ComposerProbe({ + pane, + draft = pane, + hidden = false +}: { + pane: string + /** The draft's owner; a structured chat's composers share their conversation's. */ + draft?: string + hidden?: boolean +}) { const textareaRef = useRef(null) const [notice, setNotice] = useState(null) const attachments = useNativeChatComposerAttachments({ - attachmentScopeKey: pane, + attachmentScopeKey: draft, allowWithoutTarget: true, caret: 0, disabled: false, @@ -79,7 +88,7 @@ function ComposerProbe({ pane, hidden = false }: { pane: string; hidden?: boolea