mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
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.
This commit is contained in:
@@ -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<NativeChatComposerHandle, NativeChatCo
|
||||
launchSeed,
|
||||
structuredTransport,
|
||||
steerQueued,
|
||||
inputOwnedByCard = false
|
||||
inputOwnedByCard = false,
|
||||
notices: chatNotices
|
||||
},
|
||||
ref
|
||||
): React.JSX.Element {
|
||||
@@ -96,7 +98,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,
|
||||
@@ -370,7 +372,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
|
||||
autocomplete={autocomplete}
|
||||
mentionFiles={mentionFiles}
|
||||
activeSuggestion={activeSuggestion}
|
||||
notice={notice}
|
||||
notices={notices}
|
||||
imageAttachments={imageAttachments}
|
||||
sendButtonDisabled={sendButtonDisabled}
|
||||
sendBlockedReason={imageBlock.reason}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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'
|
||||
@@ -10,6 +9,8 @@ import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-c
|
||||
import { NativeChatMentionMenu, NativeChatPickerMenu } from './NativeChatAutocompleteMenus'
|
||||
import type { NativeChatMentionFiles } from './use-native-chat-mention-files'
|
||||
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,
|
||||
@@ -45,7 +46,7 @@ export type NativeChatComposerFieldProps = {
|
||||
autocomplete: ComposerAutocomplete
|
||||
mentionFiles: NativeChatMentionFiles
|
||||
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. */
|
||||
@@ -132,7 +133,7 @@ export function NativeChatComposerField({
|
||||
autocomplete,
|
||||
mentionFiles,
|
||||
activeSuggestion,
|
||||
notice,
|
||||
notices,
|
||||
imageAttachments,
|
||||
sendButtonDisabled,
|
||||
sendBlockedReason,
|
||||
@@ -243,12 +244,21 @@ export function NativeChatComposerField({
|
||||
onChoose={onChooseMentionFile}
|
||||
/>
|
||||
) : 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}
|
||||
|
||||
@@ -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.' })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+106
@@ -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(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
isFocusedGroup
|
||||
tabId="structured-notice-card-tab"
|
||||
sessionId={SESSION_ID}
|
||||
target={{ kind: 'local' }}
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -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<HTMLTextAreaElement>(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 (
|
||||
<>
|
||||
<NativeChatComposerNotices notices={props.notices ?? []} />
|
||||
<textarea ref={fieldRef} aria-label="Message" data-testid="structured-composer" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
-20
@@ -1,5 +1,5 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||
import { useImperativeHandle } from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import type { QueuedMessageCard } from './structured-agent-session-queued-cards'
|
||||
@@ -12,6 +12,8 @@ import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard'
|
||||
import type { NativeChatLaunchSeed } from './native-chat-composer-types'
|
||||
import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest'
|
||||
import type { NativeChatFileLinkContext } from './native-chat-file-link'
|
||||
import type { NativeChatComposerNotice } from './native-chat-composer-notice'
|
||||
import { createStructuredSessionComposerMock } from './NativeChatStructuredSession.test-composer'
|
||||
import type { NativeChatOlderPageResult } from './native-chat-pagination'
|
||||
import type { StructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal'
|
||||
import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch'
|
||||
@@ -141,12 +143,16 @@ export function createStructuredSessionMocks() {
|
||||
messageListProps: initialMessageListProps,
|
||||
composerProps: nullable<{
|
||||
launchSeed?: NativeChatLaunchSeed
|
||||
structuredTransport?: Record<string, unknown> & { queueResume?: QueueResumeMock }
|
||||
structuredTransport?: Record<string, unknown> & {
|
||||
queueResume?: QueueResumeMock
|
||||
onError?: (text: string | null, errorText?: string) => void
|
||||
}
|
||||
isWorking?: boolean
|
||||
isStopping?: boolean
|
||||
afterStop?: 'queue' | 'send'
|
||||
steerQueued?: () => boolean
|
||||
onStop?: () => void
|
||||
notices?: readonly NativeChatComposerNotice[]
|
||||
}>(),
|
||||
approvalCardProps: initialApprovalCardProps,
|
||||
questionCardProps: null as NativeChatQuestionCardProps | null,
|
||||
@@ -341,24 +347,7 @@ export function createStructuredSessionMocks() {
|
||||
return <DeliveryNoticesMock notices={props?.deliveryNotices} />
|
||||
}
|
||||
}),
|
||||
nativeChatComposer: () => ({
|
||||
NativeChatComposer: forwardRef((props: typeof mocks.composerProps, ref) => {
|
||||
mocks.composerProps = props
|
||||
const fieldRef = useRef<HTMLTextAreaElement>(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 <textarea ref={fieldRef} data-testid="structured-composer" />
|
||||
})
|
||||
}),
|
||||
nativeChatComposer: () => createStructuredSessionComposerMock(mocks),
|
||||
nativeChatEmptyState: () => ({ NativeChatEmptyState: () => null }),
|
||||
nativeChatApprovalCard: () => ({
|
||||
NativeChatApprovalCard: (props: NativeChatApprovalCardProps) => {
|
||||
|
||||
@@ -26,7 +26,9 @@ import { useStructuredNativeChatPaneCommands } from './use-structured-native-cha
|
||||
import type { NativeChatStructuredViewProps } from './native-chat-view-types'
|
||||
import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSessionStatus'
|
||||
import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption'
|
||||
import { NativeChatLaunchRetry } from './NativeChatLaunchRetry'
|
||||
import { structuredSessionNotices } from './native-chat-structured-session-notices'
|
||||
import { NativeChatPromptSlotNotices } from './NativeChatComposerNotices'
|
||||
import { useNativeChatComposerError } from './use-native-chat-composer-notice'
|
||||
import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch'
|
||||
import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge'
|
||||
import { useNativeChatRewindHost } from './use-native-chat-rewind-host'
|
||||
@@ -83,7 +85,7 @@ export function NativeChatStructuredSession(
|
||||
// phases, that empty list must not become the draft's turn baseline.
|
||||
transcriptLoading: controller.status === 'idle' || controller.status === 'loading'
|
||||
})
|
||||
const [composerError, setComposerError] = useState<string | null>(null)
|
||||
const { composerError, reportComposerError } = useNativeChatComposerError()
|
||||
const [optionPickerRequest, setOptionPickerRequest] = useState<OptionPickerRequest | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const paneCommands = useStructuredNativeChatPaneCommands({
|
||||
@@ -191,11 +193,16 @@ export function NativeChatStructuredSession(
|
||||
worktreeId: ownerWorktreeId ?? undefined,
|
||||
optionPickerRequest,
|
||||
setOptionPickerRequest,
|
||||
onError: setComposerError,
|
||||
onError: reportComposerError,
|
||||
onSubmitted: revealLatest,
|
||||
queuedMessages: submits.queuedMessages
|
||||
})
|
||||
|
||||
// Said once: on the pane when the failure took it, else in the composer's notices.
|
||||
const sessionError =
|
||||
viewState.kind === 'error' || !readFailure ? controller.error : readFailure.text
|
||||
const launch = { ...provisionalLaunch, retry: submits.retryLaunch }
|
||||
const notices = structuredSessionNotices({ launch, agentLabel, sessionError, composerError })
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
@@ -259,12 +266,6 @@ export function NativeChatStructuredSession(
|
||||
</div>
|
||||
{readFailedFinally ? null : (
|
||||
<>
|
||||
<NativeChatLaunchRetry
|
||||
lifecycle={provisionalLaunch.lifecycle}
|
||||
failure={provisionalLaunch.failure}
|
||||
agentLabel={agentLabel}
|
||||
onRetry={submits.retryLaunch}
|
||||
/>
|
||||
{/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */}
|
||||
<NativeChatQueuedMessageList
|
||||
controller={submits.queuedMessages}
|
||||
@@ -275,10 +276,6 @@ export function NativeChatStructuredSession(
|
||||
<NativeChatStructuredSessionStatus
|
||||
sessionId={props.sessionId}
|
||||
paneKey={paneKey}
|
||||
// Said once: on the pane when the failure took it, else here. A loaded chat stores only a
|
||||
// refusal the host sent, so one beside messages is the host's or from before any load.
|
||||
error={viewState.kind === 'error' || !readFailure ? controller.error : readFailure.text}
|
||||
composerError={composerError}
|
||||
isVisible={props.isVisible}
|
||||
backgroundTasks={controller.backgroundTasks}
|
||||
stopBackgroundTask={controller.stopBackgroundTask}
|
||||
@@ -298,6 +295,7 @@ export function NativeChatStructuredSession(
|
||||
/>
|
||||
) : null}
|
||||
{/* Prompt cards take the composer's slot, below the background-task dock. */}
|
||||
{composerShown ? null : <NativeChatPromptSlotNotices notices={notices} />}
|
||||
{prompt && approval ? (
|
||||
<NativeChatApprovalCard
|
||||
key={`${prompt.itemId}:${prompt.revision}`}
|
||||
@@ -353,6 +351,7 @@ export function NativeChatStructuredSession(
|
||||
steerQueued={stopControls.stopping ? undefined : submits.queuedMessages.steerNewest}
|
||||
structuredTransport={structuredTransport}
|
||||
launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }}
|
||||
notices={notices}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -15,8 +15,6 @@ export function NativeChatStructuredSessionStatus(props: {
|
||||
sessionId: string
|
||||
/** The session's own status row, whose verdict the strip's children read. */
|
||||
paneKey: string
|
||||
error: string | null
|
||||
composerError: string | null
|
||||
isVisible: boolean
|
||||
backgroundTasks: StructuredSessionBackgroundTasksView
|
||||
stopBackgroundTask: (taskId?: string) => Promise<unknown>
|
||||
@@ -58,11 +56,6 @@ export function NativeChatStructuredSessionStatus(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.error || props.composerError ? (
|
||||
<p className="mx-auto w-full max-w-(--chat-content-max-width) px-4 py-1 text-xs text-destructive">
|
||||
{props.error ?? props.composerError}
|
||||
</p>
|
||||
) : null}
|
||||
{props.backgroundTasks.show ? (
|
||||
<NativeChatBackgroundTasksStatus
|
||||
isVisible={props.isVisible}
|
||||
|
||||
@@ -55,7 +55,7 @@ function TestField({
|
||||
canSend
|
||||
autocomplete={{ mode: 'none' }}
|
||||
activeSuggestion={0}
|
||||
notice={null}
|
||||
notices={[]}
|
||||
imageAttachments={imageAttachments}
|
||||
sendButtonDisabled={false}
|
||||
isWorking={false}
|
||||
|
||||
@@ -44,7 +44,7 @@ function fieldProps(overrides: Partial<TestFieldProps> = {}): TestFieldProps {
|
||||
canSend: true,
|
||||
autocomplete: { mode: 'none' },
|
||||
activeSuggestion: 0,
|
||||
notice: null,
|
||||
notices: [],
|
||||
imageAttachments: [],
|
||||
sendButtonDisabled: false,
|
||||
isWorking: false,
|
||||
|
||||
@@ -80,7 +80,7 @@ function TestField({
|
||||
canSend
|
||||
autocomplete={{ mode: 'none' }}
|
||||
activeSuggestion={0}
|
||||
notice={null}
|
||||
notices={[]}
|
||||
imageAttachments={imageAttachments}
|
||||
sendButtonDisabled={disabled || (!isWorking && draft === '' && imageAttachments.length === 0)}
|
||||
isWorking={isWorking}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// What the notice card above a native chat's composer shows: Orca's own words, plus, apart from
|
||||
// them, any error text Orca did not write, so it can be read and copied as written.
|
||||
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { readIpcErrorDetail } from '@/lib/ipc-error'
|
||||
import { isClipboardTextTooLargeError } from '../../../../shared/clipboard-text'
|
||||
|
||||
export type NativeChatComposerNoticeContent = {
|
||||
text: string
|
||||
/** Text from outside Orca (a host or system error), shown apart and copyable. */
|
||||
errorText?: string
|
||||
}
|
||||
|
||||
export type NativeChatComposerNotice = NativeChatComposerNoticeContent & {
|
||||
key: string
|
||||
/** `attachment` is the composer's own paste and attachment notice. */
|
||||
kind: 'error' | 'attachment'
|
||||
action?: { label: string; onClick: () => void }
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
/** `headline` in Orca's words, with the error as written beside it. `localErrorIsOrcaWords`: an
|
||||
* error raised in this window (not wrapped by main-process IPC) is already Orca's sentence. */
|
||||
export function nativeChatNoticeFromError(
|
||||
error: unknown,
|
||||
headline: string,
|
||||
options: { localErrorIsOrcaWords?: boolean } = {}
|
||||
): NativeChatComposerNoticeContent {
|
||||
const detail = error instanceof Error ? readIpcErrorDetail(error) : String(error ?? '').trim()
|
||||
if (!detail) {
|
||||
return { text: headline }
|
||||
}
|
||||
const wrapped = error instanceof Error && detail !== error.message.trim()
|
||||
return options.localErrorIsOrcaWords && error instanceof Error && !wrapped
|
||||
? { text: detail }
|
||||
: { text: headline, errorText: detail }
|
||||
}
|
||||
|
||||
/** Says `headline` through a notice setter, with the error's own text apart when it has any. */
|
||||
export function setNativeChatNoticeFromError(
|
||||
setNotice: (text: string | null, errorText?: string) => void,
|
||||
error: unknown,
|
||||
headline: string,
|
||||
options: { localErrorIsOrcaWords?: boolean } = {}
|
||||
): void {
|
||||
const notice = nativeChatNoticeFromError(error, headline, options)
|
||||
if (notice.errorText) {
|
||||
setNotice(notice.text, notice.errorText)
|
||||
} else {
|
||||
setNotice(notice.text)
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed paste: an error this window raised, or Orca's own size limit coming back from the main
|
||||
* process, is already Orca's sentence; any other is kept apart under `headline`. */
|
||||
export function setNativeChatPasteFailure(
|
||||
setNotice: (text: string | null, errorText?: string) => void,
|
||||
error: unknown,
|
||||
headline = translate('components.native-chat.composer.pasteFailed', 'Paste failed.')
|
||||
): void {
|
||||
if (isClipboardTextTooLargeError(error)) {
|
||||
setNotice(readIpcErrorDetail(error) ?? headline)
|
||||
return
|
||||
}
|
||||
setNativeChatNoticeFromError(setNotice, error, headline, { localErrorIsOrcaWords: true })
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NativeChatQueueResume } from './native-chat-composer-primary-action'
|
||||
import type { NativeChatComposerNotice } from './native-chat-composer-notice'
|
||||
import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command'
|
||||
import type { StructuredAgentContextUsage } from '../../../../shared/structured-agent-session-context-usage'
|
||||
import type { AgentSessionSlashCommand } from '../../../../shared/agent-session-wire'
|
||||
@@ -46,7 +47,8 @@ export type NativeChatStructuredComposerTransport = {
|
||||
worktreeId?: string
|
||||
/** Present only where the host can set this session's goal. */
|
||||
threadGoal?: { setObjective: (objective: string) => Promise<boolean> }
|
||||
onError: (message: string | null) => void
|
||||
/** `errorText` is error text Orca did not write, shown apart and copyable. */
|
||||
onError: (message: string | null, errorText?: string) => void
|
||||
/** A local send: brings the latest into view at the press, not when the host answers. */
|
||||
onSubmitted?: () => void
|
||||
runtime: 'local' | 'remote'
|
||||
@@ -116,6 +118,8 @@ export type NativeChatComposerProps = {
|
||||
/** Cmd/Ctrl+Enter from an empty composer: send the newest queued draft now.
|
||||
* False = nothing queued, and the chord falls through to a plain send. */
|
||||
steerQueued?: () => boolean
|
||||
/** The chat's own notices, shown in the composer's notice card above its input. */
|
||||
notices?: readonly NativeChatComposerNotice[]
|
||||
}
|
||||
|
||||
/** Launch context prefilled into the TUI input as an unsent draft, plus the two
|
||||
|
||||
+1
-1
@@ -174,7 +174,7 @@ function ComposerProbe({
|
||||
canSend={!disabled}
|
||||
autocomplete={{ mode: 'none' }}
|
||||
activeSuggestion={0}
|
||||
notice={notice}
|
||||
notices={notice ? [{ key: 'composer', kind: 'attachment', text: notice }] : []}
|
||||
imageAttachments={attachments.imageAttachments}
|
||||
sendButtonDisabled={false}
|
||||
isWorking={false}
|
||||
|
||||
+9
-6
@@ -4,7 +4,8 @@ import { afterEach, expect, it } from 'vitest'
|
||||
import { sayAgentSessionFailureEnglish } from '../../../../shared/agent-session-failure-copy'
|
||||
import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words'
|
||||
import { agentSessionRefusalFailure } from '../../../../shared/agent-session-write-failure'
|
||||
import { NativeChatLaunchRetry } from './NativeChatLaunchRetry'
|
||||
import { NativeChatComposerNotices } from './NativeChatComposerNotices'
|
||||
import { structuredSessionNotices } from './native-chat-structured-session-notices'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -14,11 +15,13 @@ it('names the Command setting when it is not a program Orca can run', () => {
|
||||
details: { reason: 'agentCommandNotRunnable' }
|
||||
})
|
||||
render(
|
||||
<NativeChatLaunchRetry
|
||||
lifecycle="failed"
|
||||
failure={failure}
|
||||
agentLabel="Claude"
|
||||
onRetry={() => {}}
|
||||
<NativeChatComposerNotices
|
||||
notices={structuredSessionNotices({
|
||||
launch: { lifecycle: 'failed', failure, retry: () => {} },
|
||||
agentLabel: 'Claude',
|
||||
sessionError: null,
|
||||
composerError: null
|
||||
})}
|
||||
/>
|
||||
)
|
||||
expect(
|
||||
+48
-17
@@ -1,25 +1,28 @@
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch'
|
||||
import { agentSessionRefusalCauseParts } from '../../../../shared/agent-session-refusal-notice'
|
||||
import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure'
|
||||
import { joinSentences } from '../../../../shared/sentence-joining'
|
||||
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
|
||||
import type {
|
||||
NativeChatComposerNotice,
|
||||
NativeChatComposerNoticeContent
|
||||
} from './native-chat-composer-notice'
|
||||
|
||||
export function NativeChatLaunchRetry({
|
||||
/** A chat whose start failed or went unconfirmed, as a notice with Retry. */
|
||||
function nativeChatLaunchNotice({
|
||||
lifecycle,
|
||||
failure = null,
|
||||
agentLabel,
|
||||
onRetry
|
||||
}: {
|
||||
lifecycle: StructuredAgentSessionLaunchLifecycle | null
|
||||
/** Names the agent in a start failure's words. */
|
||||
agentLabel?: string
|
||||
/** The host's refusal behind the failed start; its message is never shown. */
|
||||
failure?: AgentSessionWriteRefusal | null
|
||||
/** Names the agent in a start failure's words. */
|
||||
agentLabel?: string
|
||||
onRetry: () => void
|
||||
}): React.JSX.Element | null {
|
||||
}): NativeChatComposerNotice | null {
|
||||
if (lifecycle !== 'failed' && lifecycle !== 'visibility-unknown') {
|
||||
return null
|
||||
}
|
||||
@@ -39,17 +42,45 @@ export function NativeChatLaunchRetry({
|
||||
agentSessionRefusalCauseParts(failure, agentLabel ? { agentName: agentLabel } : {})
|
||||
)
|
||||
: ''
|
||||
// An argument problem already says the start failed; the generic lead would repeat it.
|
||||
const saysStartFailure =
|
||||
failure?.code === 'agent_session_operation_invalid' && failure.details?.argumentProblem
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-(--chat-content-max-width) items-center justify-between gap-3 px-4 py-1 text-xs text-destructive">
|
||||
<span className="min-w-0 break-words">
|
||||
{cause ? (saysStartFailure ? cause : joinSentences([message, cause])) : message}
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="xs" onClick={onRetry}>
|
||||
<RotateCcw className="size-3" />
|
||||
{translate('auto.components.native.chat.NativeChatLaunchRetry.retry', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
return {
|
||||
key: 'launch',
|
||||
kind: 'error',
|
||||
text: cause ? (saysStartFailure ? cause : joinSentences([message, cause])) : message,
|
||||
action: {
|
||||
label: translate('auto.components.native.chat.NativeChatLaunchRetry.retry', 'Retry'),
|
||||
onClick: onRetry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A structured chat's own notices, for the card above its composer. */
|
||||
export function structuredSessionNotices({
|
||||
launch,
|
||||
agentLabel,
|
||||
sessionError,
|
||||
composerError
|
||||
}: {
|
||||
launch: {
|
||||
lifecycle: StructuredAgentSessionLaunchLifecycle | null
|
||||
failure: AgentSessionWriteRefusal | null
|
||||
retry: () => void
|
||||
}
|
||||
agentLabel: string
|
||||
sessionError: string | null
|
||||
composerError: (NativeChatComposerNoticeContent & { onDismiss: () => void }) | null
|
||||
}): NativeChatComposerNotice[] {
|
||||
const launchNotice = nativeChatLaunchNotice({
|
||||
lifecycle: launch.lifecycle,
|
||||
failure: launch.failure,
|
||||
agentLabel,
|
||||
onRetry: launch.retry
|
||||
})
|
||||
return [
|
||||
...(launchNotice ? [launchNotice] : []),
|
||||
...(sessionError ? [{ key: 'session', kind: 'error' as const, text: sessionError }] : []),
|
||||
...(composerError ? [{ key: 'composer-error', kind: 'error' as const, ...composerError }] : [])
|
||||
]
|
||||
}
|
||||
@@ -199,8 +199,6 @@ function Surfaces(props: { roster: AgentSessionBackgroundTaskState | null }): Re
|
||||
<NativeChatStructuredSessionStatus
|
||||
sessionId={tab.entityId}
|
||||
paneKey={PANE_KEY}
|
||||
error={null}
|
||||
composerError={null}
|
||||
isVisible
|
||||
backgroundTasks={structuredSessionBackgroundTasksView(props.roster, null)}
|
||||
stopBackgroundTask={async () => undefined}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { useNativeChatComposerNotice } from './use-native-chat-composer-notice'
|
||||
import type { NativeChatComposerNotice } from './native-chat-composer-notice'
|
||||
|
||||
const CHAT_ERROR: NativeChatComposerNotice = {
|
||||
key: 'composer-error',
|
||||
kind: 'error',
|
||||
text: 'sonnet-9 is not an available model for this chat session.'
|
||||
}
|
||||
|
||||
describe('useNativeChatComposerNotice', () => {
|
||||
it("adds the composer's own notice after the chat's, each under its own key", () => {
|
||||
const chatNotices = [CHAT_ERROR]
|
||||
const { result } = renderHook(() => useNativeChatComposerNotice(chatNotices))
|
||||
act(() => result.current.setNotice('Paste failed.', 'sftp down'))
|
||||
expect(result.current.notices.map((notice) => notice.key)).toEqual([
|
||||
'composer-error',
|
||||
'composer-attachment'
|
||||
])
|
||||
expect(result.current.notices[1]).toMatchObject({
|
||||
text: 'Paste failed.',
|
||||
errorText: 'sftp down'
|
||||
})
|
||||
})
|
||||
|
||||
it("clears its own notice on dismiss and leaves the chat's", () => {
|
||||
const chatNotices = [CHAT_ERROR]
|
||||
const { result } = renderHook(() => useNativeChatComposerNotice(chatNotices))
|
||||
act(() => result.current.setNotice('Worktree not ready.'))
|
||||
act(() => result.current.notices[1]?.onDismiss?.())
|
||||
expect(result.current.notices).toEqual([CHAT_ERROR])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type {
|
||||
NativeChatComposerNotice,
|
||||
NativeChatComposerNoticeContent
|
||||
} from './native-chat-composer-notice'
|
||||
|
||||
/** The composer's own paste and attachment notice, after the chat's notices. */
|
||||
export function useNativeChatComposerNotice(chatNotices?: readonly NativeChatComposerNotice[]): {
|
||||
notices: readonly NativeChatComposerNotice[]
|
||||
setNotice: (text: string | null, errorText?: string) => void
|
||||
} {
|
||||
const [notice, setNoticeContent] = useState<NativeChatComposerNoticeContent | null>(null)
|
||||
const setNotice = useCallback(
|
||||
(text: string | null, errorText?: string) =>
|
||||
setNoticeContent(text === null ? null : { text, ...(errorText ? { errorText } : {}) }),
|
||||
[]
|
||||
)
|
||||
const notices = useMemo(
|
||||
() => [
|
||||
...(chatNotices ?? []),
|
||||
...(notice
|
||||
? [
|
||||
{
|
||||
key: 'composer-attachment',
|
||||
kind: 'attachment' as const,
|
||||
...notice,
|
||||
onDismiss: () => setNotice(null)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
[chatNotices, notice, setNotice]
|
||||
)
|
||||
return { notices, setNotice }
|
||||
}
|
||||
|
||||
/** A chat's last send or command error: shown in the card until dismissed or the next send clears
|
||||
* it. */
|
||||
export function useNativeChatComposerError(): {
|
||||
composerError: (NativeChatComposerNoticeContent & { onDismiss: () => void }) | null
|
||||
reportComposerError: (text: string | null, errorText?: string) => void
|
||||
} {
|
||||
const [content, setContent] = useState<NativeChatComposerNoticeContent | null>(null)
|
||||
const reportComposerError = useCallback(
|
||||
(text: string | null, errorText?: string) =>
|
||||
setContent(text === null ? null : { text, ...(errorText ? { errorText } : {}) }),
|
||||
[]
|
||||
)
|
||||
const composerError = useMemo(
|
||||
() => (content ? { ...content, onDismiss: () => setContent(null) } : null),
|
||||
[content]
|
||||
)
|
||||
return { composerError, reportComposerError }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership'
|
||||
import { assertClipboardTextWithinLimit } from '../../../../shared/clipboard-text'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { setNativeChatPasteFailure } from './native-chat-composer-notice'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target'
|
||||
import { nativeChatPasteUnavailableNotice } from '@/lib/native-chat-paste-request'
|
||||
@@ -34,7 +34,7 @@ export type UseNativeChatComposerPasteArgs = {
|
||||
dropPendingImageAttachment: (id: string) => void
|
||||
insertTypedText: (text: string) => boolean
|
||||
setCaret: (caret: number) => void
|
||||
setNotice: (notice: string | null) => void
|
||||
setNotice: (notice: string | null, errorText?: string) => void
|
||||
}
|
||||
|
||||
/** Owners whose attachment path is a file this client can write right now. */
|
||||
@@ -111,11 +111,10 @@ export function useNativeChatComposerPaste({
|
||||
// A failed save must be visible: over SSH it fails whenever the
|
||||
// connection drops, and a silent no-op reads as a broken paste.
|
||||
if (canPaste()) {
|
||||
setNotice(
|
||||
extractIpcErrorMessage(
|
||||
error,
|
||||
translate('components.native-chat.composer.imagePasteFailed', 'Image paste failed.')
|
||||
)
|
||||
setNativeChatPasteFailure(
|
||||
setNotice,
|
||||
error,
|
||||
translate('components.native-chat.composer.imagePasteFailed', 'Image paste failed.')
|
||||
)
|
||||
}
|
||||
return { status: 'failed' }
|
||||
@@ -179,7 +178,7 @@ export function useNativeChatComposerPaste({
|
||||
showPasteUnavailable()
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(extractIpcErrorMessage(error, 'Paste failed.'))
|
||||
setNativeChatPasteFailure(setNotice, error)
|
||||
}
|
||||
}
|
||||
if (!imageFile) {
|
||||
@@ -262,7 +261,7 @@ export function useNativeChatComposerPaste({
|
||||
})
|
||||
.catch((error) => {
|
||||
if (canPaste()) {
|
||||
setNotice(extractIpcErrorMessage(error, 'Paste failed.'))
|
||||
setNativeChatPasteFailure(setNotice, error)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ export function answerNativeChatCommandInComposer(args: {
|
||||
setDraft: (value: string) => void
|
||||
setCaret: Dispatch<SetStateAction<number>>
|
||||
clearSkillOrigin: () => void
|
||||
setNotice: Dispatch<SetStateAction<string | null>>
|
||||
setNotice: (notice: string | null) => void
|
||||
}): boolean {
|
||||
const command = args.draft.trim()
|
||||
const answer =
|
||||
|
||||
@@ -38,7 +38,7 @@ export function useNativeChatPickerCommandDispatch(args: {
|
||||
setActiveSuggestion: Dispatch<SetStateAction<number>>
|
||||
clearSkillOrigin: () => void
|
||||
clearImageAttachments: () => void
|
||||
setNotice: Dispatch<SetStateAction<string | null>>
|
||||
setNotice: (notice: string | null) => void
|
||||
}): (command: Extract<NativeChatPickerItem, { kind: 'command' }>) => void {
|
||||
const {
|
||||
agent,
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useNativeChatPtyComposerSend(args: {
|
||||
setCaret: Dispatch<SetStateAction<number>>
|
||||
clearSkillOrigin: () => void
|
||||
clearImageAttachments: () => void
|
||||
setNotice: Dispatch<SetStateAction<string | null>>
|
||||
setNotice: (notice: string | null) => void
|
||||
}): () => void {
|
||||
return useCallback(() => {
|
||||
const text = args.draft
|
||||
|
||||
+18
@@ -128,3 +128,21 @@ describe('reports the sends that bring the latest into view', () => {
|
||||
expect(onSubmitted).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("says a failed send in Orca's words, with the error it hit apart", () => {
|
||||
it('keeps a main-process failure apart from the words saying the message was not sent', async () => {
|
||||
const { send, structuredTransport } = harness('claude')
|
||||
vi.mocked(structuredTransport.send).mockImplementation(() => {
|
||||
throw new Error(
|
||||
"Error invoking remote method 'agentSession:send': Error: connect ECONNREFUSED /tmp/a.sock"
|
||||
)
|
||||
})
|
||||
send('hello', [])
|
||||
await vi.waitFor(() =>
|
||||
expect(structuredTransport.onError).toHaveBeenCalledWith(
|
||||
'Your message was not sent.',
|
||||
'connect ECONNREFUSED /tmp/a.sock'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+16
-4
@@ -12,6 +12,9 @@ import { pushHistory, type HistoryState } from './native-chat-composer-state'
|
||||
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
|
||||
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
|
||||
import { nativeChatAttachImagesAgainReason } from './native-chat-image-reattach'
|
||||
import { nativeChatNoticeFromError } from './native-chat-composer-notice'
|
||||
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
readNativeChatComposerDraft,
|
||||
updateNativeChatComposerDraft
|
||||
@@ -77,7 +80,12 @@ export function useNativeChatStructuredComposerSend({
|
||||
return
|
||||
}
|
||||
if (attachments.length > 0 && hostCommand) {
|
||||
structuredTransport.onError('Remove attachments before using a chat-session command.')
|
||||
structuredTransport.onError(
|
||||
translate(
|
||||
'components.native-chat.composer.commandAttachmentsUnsupported',
|
||||
'Remove attachments before using a chat-session command.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
// A conversation command reveals at the press, not after its round trip; options move nothing.
|
||||
@@ -119,9 +127,13 @@ export function useNativeChatStructuredComposerSend({
|
||||
setCaret(composing ? 0 : left.text.length)
|
||||
clearSkillOrigin()
|
||||
})
|
||||
.catch((error) =>
|
||||
structuredTransport.onError(error instanceof Error ? error.message : String(error))
|
||||
)
|
||||
.catch((error) => {
|
||||
const notice = nativeChatNoticeFromError(
|
||||
error,
|
||||
agentSessionWriteNoticeText([hostCommand ? 'notDoneCommand' : 'notDoneSend'])
|
||||
)
|
||||
structuredTransport.onError(notice.text, notice.errorText)
|
||||
})
|
||||
},
|
||||
[
|
||||
agent,
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ export function useNativeChatStructuredComposerTransport(args: {
|
||||
worktreeId: string | undefined
|
||||
optionPickerRequest: NativeChatOptionPickerRequest | null
|
||||
setOptionPickerRequest: Dispatch<SetStateAction<NativeChatOptionPickerRequest | null>>
|
||||
onError: (message: string | null) => void
|
||||
onError: NativeChatStructuredComposerTransport['onError']
|
||||
onSubmitted: () => void
|
||||
queuedMessages: Pick<StructuredAgentSessionQueuedMessagesController, 'queueHold' | 'queueResume'>
|
||||
}): NativeChatStructuredComposerTransport {
|
||||
|
||||
@@ -17947,7 +17947,9 @@
|
||||
"plan": "Plan",
|
||||
"historyRepaired": "Part of this chat's history couldn't be loaded.",
|
||||
"historyItemTooLarge": "This part of the chat was too large to show.",
|
||||
"stoppedBeforeStart": "Stopped before the agent started"
|
||||
"stoppedBeforeStart": "Stopped before the agent started",
|
||||
"dismiss": "Dismiss",
|
||||
"copyError": "Copy error"
|
||||
},
|
||||
"composer": {
|
||||
"imageUnsupported": "Image paste is not supported for this agent.",
|
||||
@@ -18028,7 +18030,9 @@
|
||||
"workspaceAttachmentMismatch": "Files can only be attached to their source workspace.",
|
||||
"attachmentOwnerChanged": "This workspace changed hosts while attaching — drop the files again.",
|
||||
"attachmentUnreadable": "Couldn't read the dropped files.",
|
||||
"tooManyAttachments": "Attach {{value0}} or fewer files at a time."
|
||||
"tooManyAttachments": "Attach {{value0}} or fewer files at a time.",
|
||||
"pasteFailed": "Paste failed.",
|
||||
"commandAttachmentsUnsupported": "Remove attachments before using a chat-session command."
|
||||
},
|
||||
"tool": {
|
||||
"exitCode": "exit {{value0}}",
|
||||
|
||||
@@ -17765,7 +17765,9 @@
|
||||
"workspaceAttachmentMismatch": "Los archivos solo se pueden adjuntar a su espacio de trabajo de origen.",
|
||||
"attachmentOwnerChanged": "Este espacio de trabajo cambió de host durante la conexión; vuelve a soltar los archivos.",
|
||||
"attachmentUnreadable": "No se pudieron leer los archivos soltados.",
|
||||
"tooManyAttachments": "Adjunta {{value0}} archivos o menos a la vez."
|
||||
"tooManyAttachments": "Adjunta {{value0}} archivos o menos a la vez.",
|
||||
"pasteFailed": "Error al pegar.",
|
||||
"commandAttachmentsUnsupported": "Quita los archivos adjuntos antes de usar un comando de la sesión de chat."
|
||||
},
|
||||
"tool": {
|
||||
"running": "Ejecutando…",
|
||||
@@ -18084,7 +18086,9 @@
|
||||
"plan": "Plan",
|
||||
"historyRepaired": "No se pudo cargar parte del historial de este chat.",
|
||||
"historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla.",
|
||||
"stoppedBeforeStart": "Detenido antes de que el agente empezara"
|
||||
"stoppedBeforeStart": "Detenido antes de que el agente empezara",
|
||||
"dismiss": "Descartar",
|
||||
"copyError": "Copiar error"
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}} bytes"
|
||||
|
||||
@@ -17866,7 +17866,9 @@
|
||||
"workspaceAttachmentMismatch": "Les fichiers ne peuvent être joints qu'à leur espace de travail source.",
|
||||
"attachmentOwnerChanged": "Cet espace de travail a changé d'hôte lors de la connexion : supprimez à nouveau les fichiers.",
|
||||
"attachmentUnreadable": "Impossible de lire les fichiers supprimés.",
|
||||
"tooManyAttachments": "Joignez {{value0}} fichiers maximum à la fois."
|
||||
"tooManyAttachments": "Joignez {{value0}} fichiers maximum à la fois.",
|
||||
"pasteFailed": "Échec du collage.",
|
||||
"commandAttachmentsUnsupported": "Retirez les pièces jointes avant d'utiliser une commande de session de chat."
|
||||
},
|
||||
"tool": {
|
||||
"running": "En cours…",
|
||||
@@ -18072,7 +18074,9 @@
|
||||
"plan": "Plan",
|
||||
"historyRepaired": "Une partie de l'historique de ce chat n'a pas pu être chargée.",
|
||||
"historyItemTooLarge": "Cette partie du chat était trop volumineuse pour être affichée.",
|
||||
"stoppedBeforeStart": "Arrêté avant le démarrage de l'agent"
|
||||
"stoppedBeforeStart": "Arrêté avant le démarrage de l'agent",
|
||||
"dismiss": "Ignorer",
|
||||
"copyError": "Copier l'erreur"
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}} octets"
|
||||
|
||||
@@ -17800,7 +17800,9 @@
|
||||
"workspaceAttachmentMismatch": "ファイルはソースワークスペースにのみ添付できます。",
|
||||
"attachmentOwnerChanged": "このワークスペースは接続中にホストを変更しました。ファイルを再度ドロップしてください。",
|
||||
"attachmentUnreadable": "ドロップされたファイルを読み取れませんでした。",
|
||||
"tooManyAttachments": "一度に添付できるファイルは {{value0}} 個以下です。"
|
||||
"tooManyAttachments": "一度に添付できるファイルは {{value0}} 個以下です。",
|
||||
"pasteFailed": "貼り付けに失敗しました。",
|
||||
"commandAttachmentsUnsupported": "チャットセッションのコマンドを使う前に添付ファイルを削除してください。"
|
||||
},
|
||||
"tool": {
|
||||
"running": "実行中…",
|
||||
@@ -18006,7 +18008,9 @@
|
||||
"plan": "プラン",
|
||||
"historyRepaired": "このチャットの履歴の一部を読み込めませんでした。",
|
||||
"historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。",
|
||||
"stoppedBeforeStart": "Agent の開始前に停止しました"
|
||||
"stoppedBeforeStart": "Agent の開始前に停止しました",
|
||||
"dismiss": "閉じる",
|
||||
"copyError": "エラーをコピー"
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}}バイト"
|
||||
|
||||
@@ -17800,7 +17800,9 @@
|
||||
"workspaceAttachmentMismatch": "파일은 소스 워크스페이스에만 첨부할 수 있습니다.",
|
||||
"attachmentOwnerChanged": "이 워크스페이스는 첨부하는 동안 호스트를 변경했습니다. 파일을 다시 삭제하세요.",
|
||||
"attachmentUnreadable": "삭제된 파일을 읽을 수 없습니다.",
|
||||
"tooManyAttachments": "한 번에 {{value0}}개 이하의 파일을 첨부하세요."
|
||||
"tooManyAttachments": "한 번에 {{value0}}개 이하의 파일을 첨부하세요.",
|
||||
"pasteFailed": "붙여넣기 실패.",
|
||||
"commandAttachmentsUnsupported": "채팅 세션 명령을 사용하기 전에 첨부 파일을 제거하세요."
|
||||
},
|
||||
"tool": {
|
||||
"running": "실행 중…",
|
||||
@@ -18006,7 +18008,9 @@
|
||||
"plan": "계획",
|
||||
"historyRepaired": "이 채팅 기록의 일부를 불러올 수 없습니다.",
|
||||
"historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다.",
|
||||
"stoppedBeforeStart": "에이전트가 시작되기 전에 중지됨"
|
||||
"stoppedBeforeStart": "에이전트가 시작되기 전에 중지됨",
|
||||
"dismiss": "닫기",
|
||||
"copyError": "오류 복사"
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}}바이트"
|
||||
|
||||
@@ -17765,7 +17765,9 @@
|
||||
"workspaceAttachmentMismatch": "文件只能附加到其源工作区。",
|
||||
"attachmentOwnerChanged": "此工作区在附加时更改了主机 - 再次删除文件。",
|
||||
"attachmentUnreadable": "无法读取删除的文件。",
|
||||
"tooManyAttachments": "每次最多附加 {{value0}} 个文件。"
|
||||
"tooManyAttachments": "每次最多附加 {{value0}} 个文件。",
|
||||
"pasteFailed": "粘贴失败。",
|
||||
"commandAttachmentsUnsupported": "使用聊天会话命令前,请先移除附件。"
|
||||
},
|
||||
"tool": {
|
||||
"running": "正在运行…",
|
||||
@@ -17971,7 +17973,9 @@
|
||||
"plan": "计划",
|
||||
"historyRepaired": "此聊天的部分历史记录无法加载。",
|
||||
"historyItemTooLarge": "聊天的这一部分过大,无法显示。",
|
||||
"stoppedBeforeStart": "在智能体开始前已停止"
|
||||
"stoppedBeforeStart": "在智能体开始前已停止",
|
||||
"dismiss": "关闭",
|
||||
"copyError": "复制错误"
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}} 字节"
|
||||
|
||||
Reference in New Issue
Block a user