From c5b9e38a52ed30ecec58c74fba1dd44cf1c5df32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:05:46 -0700 Subject: [PATCH 1/4] Render native chat host outages in the notice card --- .../NativeChatComposerNotices.test.tsx | 4 +- .../native-chat/NativeChatComposerNotices.tsx | 21 ++- .../NativeChatHostOutageNotice.tsx | 64 --------- ...ChatStructuredSession.host-outage.test.tsx | 130 +++++++++++++++++- .../NativeChatStructuredSession.tsx | 7 +- .../native-chat-composer-notice.ts | 6 +- .../use-native-chat-host-outage-notice.ts | 50 +++++++ 7 files changed, 202 insertions(+), 80 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-host-outage-notice.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx index 767580f359c8..3acba880bab5 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerNotices.test.tsx @@ -20,6 +20,7 @@ describe('NativeChatComposerNotices', () => { { expect(texts).toEqual([ 'Orca couldn’t save your message.', 'sonnet-9 is not an available model.', - 'Worktree not ready.' + 'Worktree not ready.', + 'Build box is offline' ]) expect(container.querySelectorAll('[data-notice-kind="error"]')).toHaveLength(2) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx index de15a5a5630e..9b77d20760e0 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, Paperclip, X } from 'lucide-react' +import { AlertCircle, Paperclip, WifiOff, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -55,7 +55,8 @@ export function NativeChatPromptSlotNotices({ function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX.Element { const isError = notice.kind === 'error' - const Icon = isError ? AlertCircle : Paperclip + const isHostWarning = notice.kind === 'host' && notice.tone === 'warning' + const Icon = notice.kind === 'host' ? WifiOff : isError ? AlertCircle : Paperclip const dismissLabel = translate('components.native-chat.notices.dismiss', 'Dismiss') return (
  • @@ -64,19 +65,29 @@ function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX. aria-hidden className={cn( 'mt-0.5 size-3.5 shrink-0', - isError ? 'text-destructive' : 'text-muted-foreground' + 'text-muted-foreground', + isError && 'text-destructive', + isHostWarning && 'text-status-warning' )} />

    {notice.text}

    {notice.action ? ( - ) : null} diff --git a/src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx b/src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx deleted file mode 100644 index 50b96f36f4cd..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatHostOutageNotice.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState } from 'react' -import { WifiOff } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { connectRuntimeHostAndReloadProjects } from '@/components/status-bar/runtime-environment-explicit-connect' -import { useMountedRef } from '@/hooks/useMountedRef' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { NativeChatHostOutage } from './use-native-chat-host-outage' - -/** One line naming the chat's host while Orca can't reach it; offline offers the status bar's - * Connect unless the host refused us. The live region stays mounted while empty, so it is announced. */ -export function NativeChatHostOutageNotice({ - outage -}: { - outage: NativeChatHostOutage | null -}): React.JSX.Element { - const [connecting, setConnecting] = useState(false) - const mountedRef = useMountedRef() - const reconnect = (environmentId: string): void => { - setConnecting(true) - void connectRuntimeHostAndReloadProjects(environmentId).finally(() => { - if (mountedRef.current) { - setConnecting(false) - } - }) - } - const offline = outage?.kind === 'offline' - return ( -
    - {outage ? ( -
    - -

    - {offline - ? translate('components.native-chat.hostOutage.offline', '{{hostName}} is offline', { - hostName: outage.hostLabel - }) - : translate( - 'components.native-chat.hostOutage.reconnecting', - '{{hostName}} is reconnecting…', - { hostName: outage.hostLabel } - )} -

    - {outage.canReconnect ? ( - - ) : null} -
    - ) : null} -
    - ) -} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx index b3f4b5a0f0fc..0551619ec31a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx @@ -1,8 +1,10 @@ // @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() @@ -70,8 +72,8 @@ function setHost(status: RuntimeEnvironmentStatus): void { }) } -function renderPane(): void { - render( +function paneElement(): React.JSX.Element { + return ( { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) useAppStore.setState({ @@ -127,7 +160,8 @@ it('adds no host line and no read line to a loaded chat while its host stays con it('names a reconnecting host above the composer only once the grace has passed', () => { renderPane() // Mounted before its words arrive, so a screen reader announces them. - const region = screen.getByRole('status') + const region = screen.getByRole('list') + expect(region).toHaveAttribute('aria-live', 'polite') expect(region.textContent).toBe('') setHost(hostStatus('unavailable', 'disconnected')) act(() => { @@ -139,11 +173,15 @@ it('names a reconnecting host above the composer only once the grace has passed' vi.advanceTimersByTime(1) }) expect(region.textContent).toBe('Build box is reconnecting…') + expect(screen.getByText('Build box is reconnecting…')).toHaveClass('text-muted-foreground') + expect(screen.getByRole('listitem')).toHaveAttribute('data-notice-kind', 'host') + expect(screen.queryByRole('status')).toBeNull() expect(screen.queryByRole('button', { name: 'Reconnect' })).toBeNull() expect(screen.getByTestId('structured-composer')).toBeTruthy() setHost(hostStatus('verified', 'ready')) expect(screen.queryByText(/Build box/)).toBeNull() + expect(screen.getByRole('list')).toBe(region) }) it('says a disconnected host is offline at once and offers Reconnect', async () => { @@ -151,7 +189,8 @@ it('says a disconnected host is offline at once and offers Reconnect', async () renderPane() setHost(hostStatus('blocked', 'disconnected', true)) - expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(screen.getByText('Build box is offline')).toHaveClass('text-status-warning') + expect(document.querySelector('.lucide-wifi-off')).toHaveClass('text-status-warning') expect(mocks.messageListProps?.session?.hasMore).toBe(false) reconnectHost.mockResolvedValue(true) @@ -159,6 +198,36 @@ it('says a disconnected host is offline at once and offers Reconnect', async () fireEvent.click(screen.getByRole('button', { name: 'Reconnect' })) }) expect(reconnectHost).toHaveBeenCalledWith('remote-host') + setHost(hostStatus('verified', 'ready')) + expect(mocks.messageListProps?.session?.hasMore).toBe(true) +}) + +it('disables Reconnect until its attempt settles and allows another attempt after failure', async () => { + let finish: (reachable: boolean) => void = () => {} + reconnectHost.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + renderPane() + setHost(hostStatus('blocked', 'disconnected', true)) + const reconnect = screen.getByRole('button', { name: 'Reconnect' }) + fireEvent.click(reconnect) + expect(reconnect).toBeDisabled() + fireEvent.click(reconnect) + expect(reconnectHost).toHaveBeenCalledTimes(1) + + await act(async () => { + finish(false) + }) + expect(reconnect).toBeEnabled() + expect(screen.getAllByText('Build box is offline')).toHaveLength(1) + fireEvent.click(reconnect) + expect(reconnectHost).toHaveBeenCalledTimes(2) + await act(async () => { + finish(true) + }) }) it('offers no Reconnect for a host that refused us, and promises no delivery in the composer', () => { @@ -172,6 +241,57 @@ it('offers no Reconnect for a host that refused us, and promises no delivery in expect(screen.queryByText(/Messages send when/)).toBeNull() }) +it.each(['approval', 'question'] as const)( + 'keeps the empty %s notice region mounted when its first host row arrives', + (kind) => { + mocks.promptItems = [promptItem(kind)] + renderPane() + const region = screen.getByRole('list') + expect(region.textContent).toBe('') + expect(region).toHaveAttribute('aria-live', 'polite') + setHost(hostStatus('blocked', 'disconnected', true)) + expect(screen.getByRole('list')).toBe(region) + expect(region.textContent).toBe('Build box is offlineReconnect') + expect(screen.getByRole('listitem')).toHaveAttribute('data-notice-kind', 'host') + expect(screen.queryByRole('status')).toBeNull() + } +) + +it.each(['approval', 'question'] as const)( + 'keeps the host row and pending Reconnect when a %s takes the composer slot', + async (kind) => { + let finish: () => void = () => {} + reconnectHost.mockImplementation( + () => + new Promise((resolve) => { + finish = () => resolve(false) + }) + ) + const pane = renderPane() + setHost(hostStatus('blocked', 'disconnected', true)) + fireEvent.click(screen.getByRole('button', { name: 'Reconnect' })) + mocks.promptItems = [promptItem(kind)] + pane.rerender(paneElement()) + + expect(screen.queryByTestId('structured-composer')).toBeNull() + expect(document.querySelector(`[data-native-chat-${kind}-card-mock]`)).toBeTruthy() + expect(screen.getAllByText('Build box is offline')).toHaveLength(1) + expect(screen.getByRole('listitem')).toHaveAttribute('data-notice-kind', 'host') + expect(screen.getByRole('button', { name: 'Reconnect' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull() + const region = screen.getByRole('list') + + await act(async () => { + finish() + }) + expect(screen.getByRole('button', { name: 'Reconnect' })).toBeEnabled() + setHost(hostStatus('verified', 'ready')) + expect(screen.queryByText('Build box is offline')).toBeNull() + expect(screen.getByRole('list')).toBe(region) + expect(region).toHaveAttribute('aria-live', 'polite') + } +) + it('says the outage once: a lost read beside messages adds no line of its own', () => { mocks.status = 'error' renderPane() @@ -180,6 +300,8 @@ it('says the outage once: a lost read beside messages adds no line of its own', expect(screen.getByTestId('message-list')).toBeTruthy() expect(screen.getByText('Build box is offline')).toBeTruthy() expect(screen.queryByText(/couldn't be loaded/)).toBeNull() + expect(document.querySelectorAll('[data-notice-kind="host"]')).toHaveLength(1) + expect(document.querySelectorAll('[data-notice-kind="error"]')).toHaveLength(0) }) it('says the outage once: a chat that never loaded waits quietly instead of failing', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b8926ec61770..949c3c230bac 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -43,7 +43,7 @@ import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' import { useNativeChatHostOutage } from './use-native-chat-host-outage' -import { NativeChatHostOutageNotice } from './NativeChatHostOutageNotice' +import { useNativeChatHostOutageNotice } from './use-native-chat-host-outage-notice' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' type OptionPickerRequest = { id: string; sequence: number } @@ -100,6 +100,7 @@ export function NativeChatStructuredSession( }) const historyPhase = structuredChatHistoryPhase(provisionalLaunch, controller.status) const hostOutage = useNativeChatHostOutage(props.target) + const hostNotice = useNativeChatHostOutageNotice(hostOutage) const session = useMemo( () => ({ messages: controller.messages, @@ -203,6 +204,9 @@ export function NativeChatStructuredSession( viewState.kind === 'error' || !readFailure ? controller.error : readFailure.text const launch = { ...provisionalLaunch, retry: submits.retryLaunch } const notices = structuredSessionNotices({ launch, agentLabel, sessionError, composerError }) + if (hostNotice) { + notices.push(hostNotice) + } return (
    - void } + action?: { label: string; onClick: () => void; disabled?: boolean } onDismiss?: () => void -} +} & ({ kind: 'error' | 'attachment' } | { kind: 'host'; tone: 'warning' | 'muted' }) /** `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. */ diff --git a/src/renderer/src/components/native-chat/use-native-chat-host-outage-notice.ts b/src/renderer/src/components/native-chat/use-native-chat-host-outage-notice.ts new file mode 100644 index 000000000000..694b65d3c180 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-host-outage-notice.ts @@ -0,0 +1,50 @@ +import { useState } from 'react' +import { connectRuntimeHostAndReloadProjects } from '@/components/status-bar/runtime-environment-explicit-connect' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import type { NativeChatComposerNotice } from './native-chat-composer-notice' +import type { NativeChatHostOutage } from './use-native-chat-host-outage' + +export function useNativeChatHostOutageNotice( + outage: NativeChatHostOutage | null +): NativeChatComposerNotice | null { + const [connecting, setConnecting] = useState(false) + const mountedRef = useMountedRef() + const reconnect = (environmentId: string): void => { + setConnecting(true) + void connectRuntimeHostAndReloadProjects(environmentId).finally(() => { + if (mountedRef.current) { + setConnecting(false) + } + }) + } + if (!outage) { + return null + } + const offline = outage.kind === 'offline' + return { + key: 'host-outage', + kind: 'host', + tone: offline ? 'warning' : 'muted', + text: offline + ? translate('components.native-chat.hostOutage.offline', '{{hostName}} is offline', { + hostName: outage.hostLabel + }) + : translate( + 'components.native-chat.hostOutage.reconnecting', + '{{hostName}} is reconnecting…', + { + hostName: outage.hostLabel + } + ), + ...(outage.canReconnect + ? { + action: { + label: translate('components.native-chat.hostOutage.reconnect', 'Reconnect'), + onClick: () => reconnect(outage.environmentId), + disabled: connecting + } + } + : {}) + } +} From 4331763e59ec8409f508f0a8ec664c6869bda308 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:36:25 -0700 Subject: [PATCH 2/4] fix(native-chat): truncate host notice text --- .../src/components/native-chat/NativeChatComposerNotices.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx index 9b77d20760e0..ecdb62b71f8b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx @@ -72,7 +72,8 @@ function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX. />

    Date: Wed, 7 Oct 2026 13:25:10 -0700 Subject: [PATCH 3/4] fix(native-chat): match remote host outage icon --- .../components/native-chat/NativeChatComposerNotices.tsx | 6 +++--- .../NativeChatStructuredSession.host-outage.test.tsx | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx index ecdb62b71f8b..c29325008576 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, Paperclip, WifiOff, X } from 'lucide-react' +import { AlertCircle, Paperclip, ServerOff, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -56,7 +56,7 @@ export function NativeChatPromptSlotNotices({ function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX.Element { const isError = notice.kind === 'error' const isHostWarning = notice.kind === 'host' && notice.tone === 'warning' - const Icon = notice.kind === 'host' ? WifiOff : isError ? AlertCircle : Paperclip + const Icon = notice.kind === 'host' ? ServerOff : isError ? AlertCircle : Paperclip const dismissLabel = translate('components.native-chat.notices.dismiss', 'Dismiss') return (

  • @@ -67,7 +67,7 @@ function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX. 'mt-0.5 size-3.5 shrink-0', 'text-muted-foreground', isError && 'text-destructive', - isHostWarning && 'text-status-warning' + isHostWarning && 'text-destructive' )} />

    Date: Wed, 7 Oct 2026 14:47:25 -0700 Subject: [PATCH 4/4] fix(native-chat): show an offline host's notice text in red --- .../src/components/native-chat/NativeChatComposerNotices.tsx | 2 +- .../NativeChatStructuredSession.host-outage.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx index c29325008576..0aefd2903326 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx @@ -76,7 +76,7 @@ function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX. notice.kind === 'host' ? 'truncate' : '[overflow-wrap:anywhere]', 'text-muted-foreground', isError && 'text-foreground', - isHostWarning && 'text-status-warning' + isHostWarning && 'text-destructive' )} > {notice.text} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx index 1f67e2f8cea7..f3e2a6ac4bde 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx @@ -190,7 +190,7 @@ it('says a disconnected host is offline at once and offers Reconnect', async () renderPane() setHost(hostStatus('blocked', 'disconnected', true)) - expect(screen.getByText('Build box is offline')).toHaveClass('text-status-warning') + expect(screen.getByText('Build box is offline')).toHaveClass('text-destructive') expect(document.querySelector('.lucide-server-off')).toHaveClass('text-destructive') expect(mocks.messageListProps?.session?.hasMore).toBe(false)