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