From a3ecee0dce1ef5c4406e0916de49b027232aecec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:30:55 -0700 Subject: [PATCH] feat(native-chat): one notice card above the composer for the chat's own errors (#25835) Orca's own errors in a native chat were loose lines between the transcript and the message box: a red "Chat could not be started." line with Retry, a red paragraph for the chat's line and for a send or slash-command error (where the second was hidden whenever the first showed), and a muted attachment notice inside the composer that always used a broken-image icon. A send that threw showed the raw IPC error as the whole message. They are now rows of one card attached above the message box, errors first: each row has an icon, Orca's words, and its action (Retry for a failed start, dismiss for errors the user caused). Error text Orca did not write, such as a host or system error, is shown apart in a monospace block with a copy button, under the words saying what didn't happen. The card also shows where a question or approval card takes the composer's place. Also translates "Paste failed." and the attachments-with-a-command refusal. --- .../native-chat/NativeChatComposer.tsx | 8 +- .../native-chat/NativeChatComposerField.tsx | 28 ++-- .../NativeChatComposerNotices.test.tsx | 132 ++++++++++++++++++ .../native-chat/NativeChatComposerNotices.tsx | 110 +++++++++++++++ ...ChatStructuredSession.notice-card.test.tsx | 106 ++++++++++++++ ...iveChatStructuredSession.test-composer.tsx | 37 +++++ ...tiveChatStructuredSession.test-harness.tsx | 29 ++-- .../NativeChatStructuredSession.tsx | 25 ++-- .../NativeChatStructuredSessionStatus.tsx | 7 - .../native-chat-composer-autogrow.test.tsx | 2 +- .../native-chat-composer-composition.test.tsx | 2 +- ...native-chat-composer-field-resume.test.tsx | 2 +- .../native-chat-composer-notice.ts | 66 +++++++++ .../native-chat/native-chat-composer-types.ts | 6 +- ...chat-composer-workspace-file-drop.test.tsx | 2 +- ...-session-notices.command-failure.test.tsx} | 15 +- ...native-chat-structured-session-notices.ts} | 65 ++++++--- .../structured-child-records-switch.test.tsx | 2 - .../use-native-chat-composer-notice.test.tsx | 35 +++++ .../use-native-chat-composer-notice.ts | 54 +++++++ .../use-native-chat-composer-paste.ts | 17 ++- .../use-native-chat-local-command-answer.ts | 2 +- ...use-native-chat-picker-command-dispatch.ts | 2 +- .../use-native-chat-pty-composer-send.ts | 2 +- ...ive-chat-structured-composer-send.test.tsx | 18 +++ ...se-native-chat-structured-composer-send.ts | 20 ++- ...tive-chat-structured-composer-transport.ts | 2 +- src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/fr.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- 33 files changed, 733 insertions(+), 111 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-notice.ts rename src/renderer/src/components/native-chat/{NativeChatLaunchRetry.command-failure.test.tsx => native-chat-structured-session-notices.command-failure.test.tsx} (77%) rename src/renderer/src/components/native-chat/{NativeChatLaunchRetry.tsx => native-chat-structured-session-notices.ts} (50%) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-notice.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-notice.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 941c3f2a134..dac43fa42c1 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,3 +1,4 @@ +import { useNativeChatComposerNotice } from './use-native-chat-composer-notice' import type { NativeChatComposerInput } from './native-chat-composer-input' import { forwardRef, useCallback, useState } from 'react' import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' @@ -70,7 +71,8 @@ const NativeChatComposerPane = forwardRef(EMPTY_HISTORY) const [activeSuggestion, setActiveSuggestion] = useState(0) - const [notice, setNotice] = useState(null) + const { notices, setNotice } = useNativeChatComposerNotice(chatNotices) const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing) const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, @@ -370,7 +372,7 @@ const NativeChatComposerPane = forwardRef ) : null} - {notice ? ( -
- - {notice} -
- ) : null} + + // A dismissed row takes its focused × with it; the message box gets focus back. + notice.onDismiss + ? { + ...notice, + onDismiss: () => { + notice.onDismiss?.() + textareaRef.current?.focus() + } + } + : notice + )} + className="mb-1.5" + />
{ + it('draws no card when there is nothing to say, but keeps its live region for the next notice', () => { + const { container } = render() + expect(container.querySelectorAll('li')).toHaveLength(0) + expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument() + }) + + it('puts errors first and keeps every notice, so one never hides another', () => { + const { container } = render( + + ) + const texts = screen.getAllByText(/./, { selector: 'p' }).map((node) => node.textContent) + expect(texts).toEqual([ + 'Orca couldn’t save your message.', + 'sonnet-9 is not an available model.', + 'Worktree not ready.' + ]) + expect(container.querySelectorAll('[data-notice-kind="error"]')).toHaveLength(2) + }) + + it('shows error text Orca did not write in its own block, with a copy button', () => { + const write = vi.fn().mockResolvedValue(undefined) + Object.assign(window, { api: { ui: { writeClipboardText: write } } }) + render( + + ) + const row = screen.getByRole('listitem') + expect(within(row).getByText('Your message was not sent.').tagName).toBe('P') + expect(within(row).getByText('connect ECONNREFUSED /tmp/agent-host.sock').tagName).toBe('PRE') + fireEvent.click(within(row).getByRole('button', { name: 'Copy error' })) + expect(write).toHaveBeenCalledWith('connect ECONNREFUSED /tmp/agent-host.sock') + }) + + it('offers the notice action and dismiss only where given', () => { + const retry = vi.fn() + const dismiss = vi.fn() + render( + + ) + expect(screen.getAllByRole('button', { name: 'Dismiss' })).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(retry).toHaveBeenCalledTimes(1) + expect(dismiss).toHaveBeenCalledTimes(1) + }) +}) + +describe('nativeChatNoticeFromError', () => { + it('keeps a wrapped main-process error apart from the headline, without its wrapper', () => { + expect( + nativeChatNoticeFromError( + new Error( + "Error invoking remote method 'agentSession:send': Error: connect ECONNREFUSED /tmp/a.sock" + ), + 'Your message was not sent.' + ) + ).toEqual({ text: 'Your message was not sent.', errorText: 'connect ECONNREFUSED /tmp/a.sock' }) + }) + + it('keeps a local error apart unless the caller says it is already Orca’s words', () => { + const error = new Error('Pasted text is too large for this message.') + expect(nativeChatNoticeFromError(error, 'Paste failed.')).toEqual({ + text: 'Paste failed.', + errorText: 'Pasted text is too large for this message.' + }) + expect( + nativeChatNoticeFromError(error, 'Paste failed.', { localErrorIsOrcaWords: true }) + ).toEqual({ text: 'Pasted text is too large for this message.' }) + }) + + it("says a paste over the size limit in Orca's words, even when it comes back from the main process", () => { + const setNotice = vi.fn() + setNativeChatPasteFailure( + setNotice, + new Error( + `Error invoking remote method 'ui:readClipboardText': Error: ${CLIPBOARD_TEXT_TOO_LARGE_ERROR}` + ) + ) + expect(setNotice).toHaveBeenCalledWith(CLIPBOARD_TEXT_TOO_LARGE_ERROR) + }) + + it('keeps any other main-process paste failure apart under "Paste failed."', () => { + const setNotice = vi.fn() + setNativeChatPasteFailure( + setNotice, + new Error("Error invoking remote method 'ui:readClipboardText': Error: sftp down") + ) + expect(setNotice).toHaveBeenCalledWith('Paste failed.', 'sftp down') + }) + + it('says only the headline when the error carries no words', () => { + expect(nativeChatNoticeFromError(new Error(' '), 'Paste failed.')).toEqual({ + text: 'Paste failed.' + }) + expect(nativeChatNoticeFromError(undefined, 'Paste failed.')).toEqual({ text: 'Paste failed.' }) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx new file mode 100644 index 00000000000..de15a5a5630 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx @@ -0,0 +1,110 @@ +import { AlertCircle, Paperclip, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { NativeChatCopyButton } from './NativeChatCopyButton' +import type { NativeChatComposerNotice } from './native-chat-composer-notice' + +/** One card above the composer, one row per notice, errors first. The live region stays mounted so + * a screen reader hears the first notice too. */ +export function NativeChatComposerNotices({ + notices, + className +}: { + notices: readonly NativeChatComposerNotice[] + className?: string +}): React.JSX.Element { + if (notices.length === 0) { + return
    + } + const ordered = [...notices].sort( + (a, b) => Number(b.kind === 'error') - Number(a.kind === 'error') + ) + return ( +
      + {ordered.map((notice) => ( + + ))} +
    + ) +} + +/** The card where a question or approval card has taken the composer's place. */ +export function NativeChatPromptSlotNotices({ + notices +}: { + notices: readonly NativeChatComposerNotice[] +}): React.JSX.Element { + return ( +
    0 && 'mx-auto w-full max-w-(--chat-content-max-width) px-3 pt-2 sm:px-4' + )} + > + +
    + ) +} + +function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX.Element { + const isError = notice.kind === 'error' + const Icon = isError ? AlertCircle : Paperclip + const dismissLabel = translate('components.native-chat.notices.dismiss', 'Dismiss') + return ( +
  • +
    + +

    + {notice.text} +

    + {notice.action ? ( + + ) : null} + {notice.onDismiss ? ( + + ) : null} +
    + {notice.errorText ? ( +
    +
    +            {notice.errorText}
    +          
    + +
    + ) : null} +
  • + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx new file mode 100644 index 00000000000..e2e762a3ec0 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/lib/structured-agent-session-launch', () => + moduleFactories.structuredAgentSessionLaunch() +) +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const SESSION_ID = 'notice-card-session' + +afterEach(() => { + cleanup() + resetStructuredSessionMocks() +}) + +function renderPane(): void { + render( + + ) +} + +function composerOnError(): (text: string | null, errorText?: string) => void { + const onError = mocks.composerProps?.structuredTransport?.onError + if (!onError) { + throw new Error('composer transport has no onError') + } + return onError +} + +it("shows the chat's own error and a composer error together, where one used to hide the other", () => { + // A history read that failed beside a transcript it keeps: the chat's own line. + mocks.status = 'error' + mocks.readRefusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + renderPane() + act(() => { + composerOnError()('sonnet-9 is not an available model for this chat session.') + }) + expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() + expect(screen.getByText('sonnet-9 is not an available model for this chat session.')).toBeTruthy() + expect(document.querySelectorAll('[data-notice-kind="error"]')).toHaveLength(2) +}) + +it('keeps a send failure’s raw error apart and lets the user dismiss it', () => { + renderPane() + act(() => { + composerOnError()('Your message was not sent.', 'connect ECONNREFUSED /tmp/agent-host.sock') + }) + expect(screen.getByText('connect ECONNREFUSED /tmp/agent-host.sock').tagName).toBe('PRE') + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(screen.queryByText('Your message was not sent.')).toBeNull() +}) + +const APPROVAL: AgentJournalRenderItem = JSON.parse( + JSON.stringify({ + itemId: 'approval-item', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Run the plan?', + detail: null, + subject: { kind: 'plan', text: 'x' }, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) +) + +it("keeps the notices above an approval card that takes the composer's place", () => { + mocks.launchLifecycle = 'failed' + mocks.promptItems = [APPROVAL] + renderPane() + expect(screen.queryByTestId('structured-composer')).toBeNull() + expect(screen.getByText('Chat could not be started.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx new file mode 100644 index 00000000000..5a0a6fd5365 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx @@ -0,0 +1,37 @@ +import { forwardRef, useImperativeHandle, useRef } from 'react' +import type { NativeChatComposerNotice } from './native-chat-composer-notice' +import { NativeChatComposerNotices } from './NativeChatComposerNotices' + +/** The structured session tests' composer: records its props, takes real DOM focus, and draws the + * chat's notices above its input as the real composer does. */ +export function createStructuredSessionComposerMock(mocks: { + composerProps: unknown + handlePasteEvent: unknown + pasteFromClipboard: unknown +}) { + return { + NativeChatComposer: forwardRef( + (props: { notices?: readonly NativeChatComposerNotice[] }, ref) => { + mocks.composerProps = props + const fieldRef = useRef(null) + useImperativeHandle(ref, () => ({ + // Real DOM focus: the reveal-focus loop retries until focus lands in the pane. + focus: () => { + fieldRef.current?.focus() + return true + }, + insertTypedText: () => true, + handlePasteEvent: mocks.handlePasteEvent, + pasteFromClipboard: mocks.pasteFromClipboard, + contains: (node: Node | null) => fieldRef.current?.contains(node) === true + })) + return ( + <> + +