From 37c2b4387600ec4e4f8647b9c74affe119925407 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:01:52 -0700 Subject: [PATCH] fix(native-chat): put Continue in the card that says the reply stopped (#27097) * fix(native-chat): put Continue in the card that says Orca stopped the reply Continue sat in its own row above the message box, away from the reply it continues and often outside the chat column. It now sits inside the transcript's notice card that says why the reply stopped, as a filled primary button beside that sentence. * fix(native-chat): one Continue that the stop card never repeats - A paired server with no name is called "the remote server", so the card words the stop from its cause instead of the host's stored "You can continue in this conversation." beside the Continue button. - Continue is an xs primary button that fits the text row without a margin workaround. - continueNow is a plain useCallback: its inputs (target, session id, the composer error reporter) are already stable. - Test the real chain: a cut journal through the structured session shows exactly one Continue, inside the stop card. --- .../NativeChatInterruptedContinue.test.tsx | 11 +- .../NativeChatInterruptedContinue.tsx | 60 ++++----- .../native-chat/NativeChatNoticeRow.test.tsx | 66 +++++++-- .../native-chat/NativeChatNoticeRow.tsx | 20 ++- ...ucturedSession.orca-stop-continue.test.tsx | 126 ++++++++++++++++++ .../NativeChatStructuredSession.tsx | 8 +- .../native-chat-orca-stop-context.ts | 7 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 13 files changed, 249 insertions(+), 67 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 6c35294a4ff..126e3f7da04 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 ae8a9a503ad..bd37b1e0670 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 388b60b20c0..ecf4a2c5731 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 56a01d93563..454ba816cc1 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) ? (