diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 6c35294a4ffe..126e3f7da04b 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -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' @@ -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(null) const continuation = useNativeChatInterruptedContinuation({ - composer: { clearError: () => setComposerError(null) }, + reportComposerError: setComposerError, target: props.target ?? PAIRED, sessionId: 'session-1', journalItems: props.journalItems ?? cutChat, @@ -87,14 +87,17 @@ function Harness(props: Props): React.JSX.Element { }) return ( - {continuation.offeredTurnItemId ?? 'none'} + {continuation.view.offeredTurnItemId ?? 'none'} {String(continuation.view.continueAvailable)} {String(continuation.view.remoteHost)} {composerError ?? continuation.continueError?.text ?? 'none'} - + {/* As the cut's own row shows it. */} + {continuation.view.offeredTurnItemId === null ? null : ( + + )} ) } diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx index ae8a9a503adb..bd37b1e06703 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -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' @@ -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 } /** @@ -42,10 +40,10 @@ export function useNativeChatInterruptedContinuation(input: { journalItems: readonly AgentJournalRenderItem[] submissions: readonly Pick[] 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, @@ -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 } @@ -78,19 +76,25 @@ export function useNativeChatInterruptedContinuation(input: { } setAsked(turnItemId) setFailedOn(null) - input.composer.clearError() + reportComposerError(null) void callStructuredAgentSession(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 @@ -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 ( -
+ <> - @@ -142,6 +136,6 @@ export function NativeChatInterruptedContinue({ {explanation} -
+ ) } diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index 388b60b20c06..ecf4a2c5731e 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -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' @@ -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 { - 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 = NO_OFFER ) { const [message] = projectStructuredItemsToNativeChat([ { @@ -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( - - - + + + + + ) } @@ -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( @@ -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') diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 56a01d93563f..454ba816cc1f 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -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' @@ -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 = @@ -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, @@ -153,7 +162,10 @@ export function NativeChatNoticeRow({ >
{Icon ?
{claudeSignIn && isClaudeSignInFailureKind(block.failure?.kind) ? (