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 @@ -42,7 +42,7 @@ import type {
} from '../../../../shared/agent-session-journal-types'
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
import {
NativeChatInterruptedContinue,
NativeChatInterruptedContinueButton,
useNativeChatInterruptedContinuation
} from './NativeChatInterruptedContinue'

Expand Down Expand Up @@ -78,7 +78,7 @@ function Harness(props: Props): React.JSX.Element {
// The chat's own composer error, as NativeChatStructuredSession holds it.
const [composerError, setComposerError] = useState<string | null>(null)
const continuation = useNativeChatInterruptedContinuation({
composer: { clearError: () => setComposerError(null) },
reportComposerError: setComposerError,
target: props.target ?? PAIRED,
sessionId: 'session-1',
journalItems: props.journalItems ?? cutChat,
Expand All @@ -87,14 +87,17 @@ function Harness(props: Props): React.JSX.Element {
})
return (
<TooltipProvider delayDuration={0}>
<span data-testid="offered">{continuation.offeredTurnItemId ?? 'none'}</span>
<span data-testid="offered">{continuation.view.offeredTurnItemId ?? 'none'}</span>
<span data-testid="available">{String(continuation.view.continueAvailable)}</span>
<span data-testid="remote">{String(continuation.view.remoteHost)}</span>
<span data-testid="error">{composerError ?? continuation.continueError?.text ?? 'none'}</span>
<button type="button" onClick={() => setComposerError(ATTACHMENTS)}>
compose
</button>
<NativeChatInterruptedContinue continuation={continuation} />
{/* As the cut's own row shows it. */}
{continuation.view.offeredTurnItemId === null ? null : (
<NativeChatInterruptedContinueButton onContinue={continuation.view.continueNow} />
)}
</TooltipProvider>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useId, useMemo, useState } from 'react'
import { useCallback, useId, useMemo, useState } from 'react'
import { Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
Expand All @@ -21,14 +21,12 @@ import type { NativeChatComposerNoticeContent } from './native-chat-composer-not
type ContinueAnswer = { outcome?: string }

export type NativeChatInterruptedContinuation = {
/** Told to the chat's rows: the machine's name, whether it is remote, and if it can continue. */
/** Told to the chat's rows: the machine's name, whether it is remote, if it can continue, and the
* cut row that carries Continue. */
view: NativeChatOrcaStopView
/** The cut turn Continue is offered on right now, if any. */
offeredTurnItemId: string | null
/** A Continue that did not go through, for the composer's notice card: shown while the chat
* still sits on that cut and gone once it is continued from anywhere. */
continueError: (NativeChatComposerNoticeContent & { onDismiss: () => void }) | null
continueNow: () => void
}

/**
Expand All @@ -42,10 +40,10 @@ export function useNativeChatInterruptedContinuation(input: {
journalItems: readonly AgentJournalRenderItem[]
submissions: readonly Pick<AgentJournalSubmission, 'dispatchState'>[]
isWorking: boolean
/** The composer's own error; a Continue click is the user's newer action, so it clears it. */
composer: { clearError: () => void }
/** Stable; a Continue click is the user's newer action, so it clears the composer's error. */
reportComposerError: (text: null) => void
}): NativeChatInterruptedContinuation {
const { target, sessionId } = input
const { target, sessionId, reportComposerError } = input
const hostLabel = useStructuredAgentSessionHostLabel(target)
const capability = useStructuredAgentSessionHostCapabilityState(
target,
Expand All @@ -66,7 +64,7 @@ export function useNativeChatInterruptedContinuation(input: {
capability === 'supported' && cut && !input.isWorking && !resuming && asked !== cut.turnItemId
? cut.turnItemId
: null
const continueNow = (): void => {
const continueNow = useCallback((): void => {
if (offered === null) {
return
}
Expand All @@ -78,19 +76,25 @@ export function useNativeChatInterruptedContinuation(input: {
}
setAsked(turnItemId)
setFailedOn(null)
input.composer.clearError()
reportComposerError(null)
void callStructuredAgentSession<ContinueAnswer>(target, 'agentSession.continueInterrupted', {
sessionId,
turnItemId
}).then((answer) => (answer.outcome === 'refused' ? failed() : undefined), failed)
}
}, [offered, target, sessionId, reportComposerError])
// Unknown counts as able: a host that writes cause rows has Continue, and the words stay put.
const continueAvailable = capability !== 'unsupported'
// One object per change, so the chat's rows re-render only when what they show changes.
const remoteHost = target.kind === 'environment'
const view = useMemo(
() => ({ hostLabel, remoteHost, continueAvailable }),
[hostLabel, remoteHost, continueAvailable]
() => ({
hostLabel,
remoteHost,
continueAvailable,
offeredTurnItemId: offered,
continueNow
}),
[hostLabel, remoteHost, continueAvailable, offered, continueNow]
)
const failedHere =
failedOn !== null && cut?.turnItemId === failedOn
Expand All @@ -101,37 +105,27 @@ export function useNativeChatInterruptedContinuation(input: {
: null
return {
view,
offeredTurnItemId: offered,
continueError: failedHere ? { text: failedHere, onDismiss: () => setFailedOn(null) } : null,
continueNow
continueError: failedHere ? { text: failedHere, onDismiss: () => setFailedOn(null) } : null
}
}

export function NativeChatInterruptedContinue({
continuation
/** Continue, on the row that says Orca stopped this reply: the one action that picks it back up. */
export function NativeChatInterruptedContinueButton({
onContinue
}: {
continuation: NativeChatInterruptedContinuation
}): React.JSX.Element | null {
onContinue: () => void
}): React.JSX.Element {
const explanationId = useId()
if (continuation.offeredTurnItemId === null) {
return null
}
const explanation = translate(
'components.native-chat.interruptedContinue.explanation',
'Continue, and the agent first checks whether its last step finished.'
)
return (
<div className="mx-auto flex w-full max-w-4xl items-center justify-end px-4 py-1">
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
aria-describedby={explanationId}
onClick={continuation.continueNow}
>
<Play className="size-3" />
<Button type="button" size="xs" aria-describedby={explanationId} onClick={onContinue}>
<Play />
{translate('components.native-chat.interruptedContinue.continue', 'Continue')}
</Button>
</TooltipTrigger>
Expand All @@ -142,6 +136,6 @@ export function NativeChatInterruptedContinue({
<span id={explanationId} className="sr-only">
{explanation}
</span>
</div>
</>
)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types'
import { TooltipProvider } from '@/components/ui/tooltip'
import { MessageRow } from './NativeChatMessageRow'
import { NativeChatNoticeRow } from './NativeChatNoticeRow'
import { i18n } from '@/i18n/i18n'
Expand Down Expand Up @@ -32,17 +33,21 @@ it('keeps a skipped compaction warning truthful instead of showing the success s
function orcaStopView(
hostLabel: string | null,
continueAvailable: boolean,
remoteHost: boolean
remoteHost: boolean,
offer: Pick<NativeChatOrcaStopView, 'offeredTurnItemId' | 'continueNow'>
): NativeChatOrcaStopView {
return { hostLabel, remoteHost, continueAvailable }
return { hostLabel, remoteHost, continueAvailable, ...offer }
}

const NO_OFFER = { offeredTurnItemId: null, continueNow: () => {} }

function renderStatus(
body: AgentJournalStatusItem,
hostLabel: string | null = null,
continueAvailable = false,
remoteHost = false,
agentName?: string
agentName?: string,
offer: Pick<NativeChatOrcaStopView, 'offeredTurnItemId' | 'continueNow'> = NO_OFFER
) {
const [message] = projectStructuredItemsToNativeChat([
{
Expand All @@ -54,16 +59,18 @@ function renderStatus(
turnScope: { kind: 'turn', turnItemId: 'cut-turn' }
}
])
const view = orcaStopView(hostLabel, continueAvailable, remoteHost)
const view = orcaStopView(hostLabel, continueAvailable, remoteHost, offer)
return render(
<NativeChatOrcaStopContext.Provider value={view}>
<MessageRow
message={message!}
agentName={agentName}
expandSignal={false}
onScrollMessageToTop={vi.fn()}
/>
</NativeChatOrcaStopContext.Provider>
<TooltipProvider>
<NativeChatOrcaStopContext.Provider value={view}>
<MessageRow
message={message!}
agentName={agentName}
expandSignal={false}
onScrollMessageToTop={vi.fn()}
/>
</NativeChatOrcaStopContext.Provider>
</TooltipProvider>
)
}

Expand Down Expand Up @@ -101,6 +108,29 @@ describe('the row an Orca stop leaves', () => {
).toBeInTheDocument()
})

it('carries Continue on the cut it is offered for, and only there', () => {
const continueNow = vi.fn()
renderStatus(orcaStopRow('quit'), 'studio-mac', true, false, undefined, {
offeredTurnItemId: 'cut-turn',
continueNow
})
const sentence = screen.getByText(
'Orca on studio-mac was closed while this response was in progress.'
)
const button = screen.getByRole('button', { name: 'Continue' })
// Beside the words that say why, in the same card.
expect(sentence.parentElement).toContainElement(button)
fireEvent.click(button)
expect(continueNow).toHaveBeenCalledOnce()
cleanup()

renderStatus(orcaStopRow('quit'), 'studio-mac', true, false, undefined, {
offeredTurnItemId: 'another-turn',
continueNow
})
expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull()
})

it("says a remote host's Orca stopped, not that it was closed", () => {
renderStatus(orcaStopRow('quit'), 'QA SSH', true, true)
expect(
Expand Down Expand Up @@ -161,6 +191,16 @@ describe('the row an Orca stop leaves', () => {
)
})

it('calls a paired server with no name the remote server, so its words match the button', () => {
renderStatus(orcaStopRow('update'), null, true, true)
expect(
screen.getByText(
'Orca on the remote server restarted for an update while this response was in progress.'
)
).toBeInTheDocument()
expect(screen.queryByText(LEGACY_TEXT)).toBeNull()
})

it('keeps the host words for a stop Orca did not cause', () => {
const { orcaStop: _orcaStop, presentation: _presentation, ...agentExit } = orcaStopRow('update')
renderStatus(agentExit, 'studio-mac')
Expand Down
20 changes: 16 additions & 4 deletions src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '../../../../shared/agent-session-host-status-rows'
import type { NativeChatTextBlock } from '../../../../shared/native-chat-types'
import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context'
import { NativeChatInterruptedContinueButton } from './NativeChatInterruptedContinue'
import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words'
import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop'
import { ProviderFrameRow } from './NativeChatTranscriptChrome'
Expand Down Expand Up @@ -109,10 +110,18 @@ export function NativeChatNoticeRow({
)
}
// The host's row about an Orca stop names the cause and the machine, muted: Orca stopped, not the
// agent. With no machine to name it keeps the host's own words.
// agent. Outside a chat that knows its machine it keeps the host's own words.
const { orcaStop } = block
const { hostLabel, remoteHost, continueAvailable } = orcaStopView
const named = orcaStop !== undefined && hostLabel !== null
// A paired server with no name to show is still a remote server; its id is not a name.
const machine =
hostLabel ??
(remoteHost
? translate('components.native-chat.notices.orcaStopUnnamedServer', 'the remote server')
: null)
const named = orcaStop !== undefined && machine !== null
const continueOffered =
orcaStop?.turnItemId !== undefined && orcaStop.turnItemId === orcaStopView.offeredTurnItemId
const failure = readWholeAgentSessionFailureFact(block.failure)
// Only reword auth text fully described by its fact; host text may also carry command advice.
const authSurface =
Expand All @@ -122,7 +131,7 @@ export function NativeChatNoticeRow({
)
: undefined
const text = named
? nativeChatOrcaStopRowText(orcaStop.cause, hostLabel, { continueAvailable, remoteHost })
? nativeChatOrcaStopRowText(orcaStop.cause, machine, { continueAvailable, remoteHost })
: failure && authSurface
? agentSessionFailureSentence(
failure,
Expand Down Expand Up @@ -153,7 +162,10 @@ export function NativeChatNoticeRow({
>
<div className="flex items-start gap-2">
{Icon ? <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> : null}
<p className="min-w-0 whitespace-pre-wrap break-words">{text}</p>
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words">{text}</p>
{continueOffered ? (
<NativeChatInterruptedContinueButton onContinue={orcaStopView.continueNow} />
) : null}
</div>
{claudeSignIn && isClaudeSignInFailureKind(block.failure?.kind) ? (
<Button
Expand Down
Loading
Loading