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 ( + <> + +