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
Expand Up @@ -20,6 +20,7 @@ describe('NativeChatComposerNotices', () => {
<NativeChatComposerNotices
notices={[
{ key: 'composer', kind: 'attachment', text: 'Worktree not ready.' },
{ key: 'host', kind: 'host', tone: 'warning', text: 'Build box is offline' },
{ key: 'session', kind: 'error', text: 'Orca couldn’t save your message.' },
{ key: 'slash', kind: 'error', text: 'sonnet-9 is not an available model.' }
]}
Expand All @@ -29,7 +30,8 @@ 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)
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AlertCircle, Paperclip, 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'
Expand Down Expand Up @@ -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' ? ServerOff : 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')}>
Expand All @@ -64,19 +65,30 @@ 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-destructive'
)}
/>
<p
className={cn(
'min-w-0 flex-1 select-text py-px leading-5 [overflow-wrap:anywhere]',
isError ? 'text-foreground' : 'text-muted-foreground'
'min-w-0 flex-1 select-text py-px leading-5',
notice.kind === 'host' ? 'truncate' : '[overflow-wrap:anywhere]',
'text-muted-foreground',
isError && 'text-foreground',
isHostWarning && 'text-destructive'
)}
>
{notice.text}
</p>
{notice.action ? (
<Button type="button" variant="outline" size="xs" onClick={notice.action.onClick}>
<Button
type="button"
variant="outline"
size="xs"
disabled={notice.action.disabled}
onClick={notice.action.onClick}
>
{notice.action.label}
</Button>
) : null}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -70,8 +72,8 @@ function setHost(status: RuntimeEnvironmentStatus): void {
})
}

function renderPane(): void {
render(
function paneElement(): React.JSX.Element {
return (
<NativeChatStructuredSession
isVisible
isFocusedGroup
Expand All @@ -83,6 +85,37 @@ function renderPane(): void {
)
}

function renderPane() {
return render(paneElement())
}

function promptItem(kind: 'approval' | 'question'): AgentJournalRenderItem {
const options = [{ id: 'allow', label: 'Approve' }]
const resolution = {
state: 'pending' as const,
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}
return {
itemId: 'host-outage-prompt',
revision: 1,
sequence: 1,
observedAt: 1,
body:
kind === 'approval'
? {
kind,
title: 'Run the plan?',
detail: null,
subject: { kind: 'plan', text: 'x' },
options,
resolution
}
: { kind, question: 'Run the plan?', options, resolution }
}
}

beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
useAppStore.setState({
Expand Down Expand Up @@ -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(() => {
Expand All @@ -139,26 +173,62 @@ 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(document.querySelector('.lucide-server-off')).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 () => {
mocks.hasOlder = true
renderPane()
setHost(hostStatus('blocked', 'disconnected', true))

expect(screen.getByText('Build box is offline')).toBeTruthy()
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)

reconnectHost.mockResolvedValue(true)
await act(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<boolean>((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', () => {
Expand All @@ -172,6 +242,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<boolean>((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()
Expand All @@ -180,6 +301,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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,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'

export function NativeChatStructuredSession(
Expand Down Expand Up @@ -108,6 +108,7 @@ export function NativeChatStructuredSession(
})
const historyPhase = structuredChatHistoryPhase(provisionalLaunch, controller.status)
const hostOutage = useNativeChatHostOutage(props.target)
const hostNotice = useNativeChatHostOutageNotice(hostOutage)
const session = useStructuredChatLiveSession(
controller,
historyPhase,
Expand Down Expand Up @@ -200,6 +201,9 @@ export function NativeChatStructuredSession(
sessionError,
composerError: composerError ?? continuation.continueError
})
if (hostNotice) {
notices.push(hostNotice)
}
return (
<div
ref={rootRef}
Expand Down Expand Up @@ -266,7 +270,6 @@ export function NativeChatStructuredSession(
steerHeld={stopControls.stopping}
focusComposer={focusComposer}
/>
<NativeChatHostOutageNotice outage={hostOutage} />
<NativeChatStructuredSessionStatus
sessionId={props.sessionId}
paneKey={paneKey}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@ export type NativeChatComposerNoticeContent = {

export type NativeChatComposerNotice = NativeChatComposerNoticeContent & {
key: string
/** `attachment` is the composer's own paste and attachment notice. */
kind: 'error' | 'attachment'
action?: { label: string; onClick: () => 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. */
Expand Down
Loading
Loading