Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -73,7 +74,8 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
launchSeed,
structuredTransport,
steerQueued,
inputOwnedByCard = false
inputOwnedByCard = false,
notices: chatNotices
},
ref
): React.JSX.Element {
Expand All @@ -99,7 +101,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
})
const [history, setHistory] = useState<HistoryState>(EMPTY_HISTORY)
const [activeSuggestion, setActiveSuggestion] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const { notices, setNotice } = useNativeChatComposerNotice(chatNotices)
const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing)
const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle(
terminalTabId,
Expand Down Expand Up @@ -363,7 +365,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
canSend={canSend}
autocomplete={autocomplete}
activeSuggestion={activeSuggestion}
notice={notice}
notices={notices}
imageAttachments={imageAttachments}
sendButtonDisabled={sendButtonDisabled}
sendBlockedReason={imageBlock.reason}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { NativeChatPromptEditor } from './NativeChatPromptEditor'
import type { NativeChatComposerInput } from './native-chat-composer-input'
import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react'
import { useLayoutEffect, useRef } from 'react'
import { ImageOff } from 'lucide-react'
import type { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
import { cn } from '@/lib/utils'
import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop'
import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state'
import { NativeChatMentionHint, NativeChatPickerMenu } from './NativeChatAutocompleteMenus'
import { NativeChatComposerActions } from './NativeChatComposerActions'
import { NativeChatComposerNotices } from './NativeChatComposerNotices'
import type { NativeChatComposerNotice } from './native-chat-composer-notice'
import type { NativeChatContextUsageSummary } from './native-chat-context-usage-summary'
import {
nativeChatComposerPlaceholder,
Expand Down Expand Up @@ -43,7 +44,7 @@ export type NativeChatComposerFieldProps = {
canSend: boolean
autocomplete: ComposerAutocomplete
activeSuggestion: number
notice: string | null
notices: readonly NativeChatComposerNotice[]
imageAttachments: readonly NativeChatComposerImageAttachment[]
sendButtonDisabled: boolean
/** Why the send button is disabled, when the user can do something about it. */
Expand Down Expand Up @@ -129,7 +130,7 @@ export function NativeChatComposerField({
canSend,
autocomplete,
activeSuggestion,
notice,
notices,
imageAttachments,
sendButtonDisabled,
sendBlockedReason,
Expand Down Expand Up @@ -229,12 +230,21 @@ export function NativeChatComposerField({
{autocomplete.mode === 'mention' ? (
<NativeChatMentionHint query={autocomplete.query} onAccept={onAcceptMention} />
) : null}
{notice ? (
<div className="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<ImageOff className="size-3.5 shrink-0" />
<span>{notice}</span>
</div>
) : null}
<NativeChatComposerNotices
notices={notices.map((notice) =>
// 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"
/>
<div
data-native-file-drop-target={NATIVE_FILE_DROP_TARGET.composer}
data-composer-scope-key={dropScopeKey}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeChatComposerNotices } from './NativeChatComposerNotices'
import { CLIPBOARD_TEXT_TOO_LARGE_ERROR } from '../../../../shared/clipboard-text'
import { nativeChatNoticeFromError, setNativeChatPasteFailure } from './native-chat-composer-notice'

afterEach(cleanup)

describe('NativeChatComposerNotices', () => {
it('draws no card when there is nothing to say, but keeps its live region for the next notice', () => {
const { container } = render(<NativeChatComposerNotices notices={[]} />)
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(
<NativeChatComposerNotices
notices={[
{ key: 'composer', kind: 'attachment', text: 'Worktree not ready.' },
{ key: 'session', kind: 'error', text: 'Orca couldn’t save your message.' },
{ key: 'slash', kind: 'error', text: 'sonnet-9 is not an available model.' }
]}
/>
)
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(
<NativeChatComposerNotices
notices={[
{
key: 'composer',
kind: 'error',
text: 'Your message was not sent.',
errorText: 'connect ECONNREFUSED /tmp/agent-host.sock'
}
]}
/>
)
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(
<NativeChatComposerNotices
notices={[
{
key: 'launch',
kind: 'error',
text: 'Chat could not be started.',
action: { label: 'Retry', onClick: retry }
},
{ key: 'composer', kind: 'attachment', text: 'Paste failed.', onDismiss: dismiss }
]}
/>
)
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.' })
})
})
110 changes: 110 additions & 0 deletions src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx
Original file line number Diff line number Diff line change
@@ -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 <ul aria-live="polite" />
}
const ordered = [...notices].sort(
(a, b) => Number(b.kind === 'error') - Number(a.kind === 'error')
)
return (
<ul
aria-live="polite"
className={cn(
'divide-y divide-border overflow-hidden rounded-md border border-border bg-card text-xs text-card-foreground',
ordered[0]?.kind === 'error' && 'border-destructive/30',
className
)}
>
{ordered.map((notice) => (
<NoticeRow key={notice.key} notice={notice} />
))}
</ul>
)
}

/** 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 (
<div
className={cn(
notices.length > 0 && 'mx-auto w-full max-w-(--chat-content-max-width) px-3 pt-2 sm:px-4'
)}
>
<NativeChatComposerNotices notices={notices} />
</div>
)
}

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 (
<li data-notice-kind={notice.kind} className={cn(isError && 'bg-destructive/5')}>
<div className="flex items-start gap-2 px-2.5 py-1.5">
<Icon
aria-hidden
className={cn(
'mt-0.5 size-3.5 shrink-0',
isError ? 'text-destructive' : 'text-muted-foreground'
)}
/>
<p
className={cn(
'min-w-0 flex-1 select-text py-px leading-5 [overflow-wrap:anywhere]',
isError ? 'text-foreground' : 'text-muted-foreground'
)}
>
{notice.text}
</p>
{notice.action ? (
<Button type="button" variant="outline" size="xs" onClick={notice.action.onClick}>
{notice.action.label}
</Button>
) : null}
{notice.onDismiss ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={dismissLabel}
title={dismissLabel}
onClick={notice.onDismiss}
>
<X />
</Button>
) : null}
</div>
{notice.errorText ? (
<div className="relative mb-2 ml-8 mr-2.5 rounded-md border border-border bg-muted">
<pre className="scrollbar-sleek max-h-28 select-text overflow-auto whitespace-pre-wrap break-words py-1.5 pl-2 pr-8 font-mono text-[11px] text-foreground">
{notice.errorText}
</pre>
<NativeChatCopyButton
text={notice.errorText}
label={translate('components.native-chat.notices.copyError', 'Copy error')}
className="absolute right-0.5 top-0.5"
/>
</div>
) : null}
</li>
)
}
Loading
Loading