From f9a01653c8575b93affda8240d9ad50dcfcd3d31 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:52:26 -0700 Subject: [PATCH 1/2] 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. --- .../NativeChatInterruptedContinue.test.tsx | 9 ++- .../NativeChatInterruptedContinue.tsx | 54 ++++++++++-------- .../native-chat/NativeChatNoticeRow.test.tsx | 56 ++++++++++++++----- .../native-chat/NativeChatNoticeRow.tsx | 8 ++- .../NativeChatStructuredSession.tsx | 6 +- .../native-chat-orca-stop-context.ts | 7 ++- 6 files changed, 93 insertions(+), 47 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 6c35294a4ffe..9e94c0e04791 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' @@ -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..5d70c6a2b6eb 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, useLayoutEffect, useMemo, useRef, 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 } /** @@ -86,11 +84,23 @@ export function useNativeChatInterruptedContinuation(input: { } // 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. + // One object per change, so the chat's rows re-render only when what they show changes; the rows + // get a stable handler that runs the latest continueNow. + const latestContinueNow = useRef(continueNow) + useLayoutEffect(() => { + latestContinueNow.current = continueNow + }) + const stableContinueNow = useCallback(() => latestContinueNow.current(), []) const remoteHost = target.kind === 'environment' const view = useMemo( - () => ({ hostLabel, remoteHost, continueAvailable }), - [hostLabel, remoteHost, continueAvailable] + () => ({ + hostLabel, + remoteHost, + continueAvailable, + offeredTurnItemId: offered, + continueNow: stableContinueNow + }), + [hostLabel, remoteHost, continueAvailable, offered, stableContinueNow] ) const failedHere = failedOn !== null && cut?.turnItemId === failedOn @@ -101,37 +111,33 @@ 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 +148,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..9046de17b8a4 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( diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 56a01d93563f..e63805e3d9c4 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' @@ -113,6 +114,8 @@ export function NativeChatNoticeRow({ const { orcaStop } = block const { hostLabel, remoteHost, continueAvailable } = orcaStopView const named = orcaStop !== undefined && hostLabel !== 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 = @@ -153,7 +156,10 @@ export function NativeChatNoticeRow({ >
{Icon ?
{claudeSignIn && isClaudeSignInFailureKind(block.failure?.kind) ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index 9046de17b8a4..ecf4a2c5731e 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -191,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 e63805e3d9c4..454ba816cc1f 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -110,10 +110,16 @@ 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) @@ -125,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, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx new file mode 100644 index 000000000000..86fceb7065a4 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { useImperativeHandle } from 'react' +import { afterEach, expect, it, vi } from 'vitest' +import type * as HostCapabilityModule from '@/runtime/structured-agent-session-host-capability' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/lib/structured-agent-session-launch', () => + moduleFactories.structuredAgentSessionLaunch() +) +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) +vi.mock('@/runtime/structured-agent-session-host-capability', async (importOriginal) => { + const original = await importOriginal() + const { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } = + await import('../../../../shared/agent-session-continue-interrupted-capability') + return { + ...original, + useStructuredAgentSessionHostCapabilityState: ( + ...args: Parameters + ) => + args[1] === AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY + ? 'supported' + : original.useStructuredAgentSessionHostCapabilityState(...args) + } +}) +// The transcript's real rows, projected from the journal; only the list's windowing is left out. +vi.mock('./NativeChatMessageList', async () => { + const { MessageRow } = await import('./NativeChatMessageRow') + const { projectStructuredItemsToNativeChat } = + await import('../../../../shared/structured-agent-session-projection') + return { + NativeChatMessageList: (props: { + ref?: React.Ref + journalItems?: Parameters[0] + }) => { + useImperativeHandle(props.ref, () => ({ revealLatest: () => {}, revealFindMatch: () => {} })) + return ( +
+ {projectStructuredItemsToNativeChat(props.journalItems ?? []).map((message) => ( + {}} + /> + ))} +
+ ) + } + } +}) + +import { TooltipProvider } from '@/components/ui/tooltip' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const TURN = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 }) + +const cutChat: AgentJournalRenderItem[] = [ + { + itemId: TURN, + revision: 2, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 'cut', state: 'interrupted', startedAt: 1, completedAt: 5 } + }, + { + itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: 'stale-session:s:death-1-5' }), + revision: 1, + sequence: 2, + observedAt: 6, + turnScope: { kind: 'turn', turnItemId: TURN }, + body: { + kind: 'status', + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error', + presentation: 'orca-stop', + orcaStop: { cause: 'crash' } + } + } +] + +afterEach(() => { + cleanup() + resetStructuredSessionMocks() +}) + +it('offers one Continue, in the card that says Orca stopped, and the card does not say it again', () => { + mocks.journalItems = cutChat + render( + + + + ) + + // Throws on a second Continue anywhere in the pane, such as the old row above the composer. + const button = screen.getByRole('button', { name: 'Continue' }) + const sentence = screen.getByText( + 'Orca on the remote server stopped unexpectedly while this response was in progress.' + ) + expect(sentence.parentElement).toContainElement(button) + expect(screen.getByTestId('transcript')).toContainElement(button) + expect(screen.queryByText(/You can continue in this conversation/)).toBeNull() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 67cc018633a3..b3efd9fa4c65 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -139,7 +139,7 @@ export function NativeChatStructuredSession( journalItems: controller.journalItems, submissions: controller.submissions, isWorking: controller.isWorking, - composer: { clearError: () => reportComposerError(null) } + reportComposerError }) const needsFailureFacts = submits.queuedMessages.cards.some((card) => card.state === 'returned') || diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7a9ad4a4a46f..f7b3a7a2e1b9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18226,7 +18226,8 @@ "orcaStopQuit": "Orca on {{machine}} was closed while this response was in progress.", "orcaStopCrash": "Orca on {{machine}} stopped unexpectedly while this response was in progress.", "orcaStopRemote": "Orca on {{machine}} stopped while this response was in progress.", - "orcaStopCanContinue": "You can continue in this conversation." + "orcaStopCanContinue": "You can continue in this conversation.", + "orcaStopUnnamedServer": "the remote server" }, "composer": { "imageUnsupported": "Image paste is not supported for this agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cc49e7c4ea7a..40a1ed864972 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18379,7 +18379,8 @@ "orcaStopQuit": "Orca en {{machine}} se cerró mientras esta respuesta estaba en curso.", "orcaStopCrash": "Orca en {{machine}} se detuvo inesperadamente mientras esta respuesta estaba en curso.", "orcaStopRemote": "Orca en {{machine}} se detuvo mientras esta respuesta estaba en curso.", - "orcaStopCanContinue": "Puedes continuar en esta conversación." + "orcaStopCanContinue": "Puedes continuar en esta conversación.", + "orcaStopUnnamedServer": "el servidor remoto" }, "providerFrame": { "byteLength": "{{value0}} bytes" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 50517f06569f..a9a232a78592 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18356,7 +18356,8 @@ "orcaStopQuit": "Orca sur {{machine}} a été fermé pendant que cette réponse était en cours.", "orcaStopCrash": "Orca sur {{machine}} s'est arrêté de manière inattendue pendant que cette réponse était en cours.", "orcaStopRemote": "Orca sur {{machine}} s'est arrêté pendant que cette réponse était en cours.", - "orcaStopCanContinue": "Vous pouvez continuer dans cette conversation." + "orcaStopCanContinue": "Vous pouvez continuer dans cette conversation.", + "orcaStopUnnamedServer": "le serveur distant" }, "providerFrame": { "byteLength": "{{value0}} octets" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 020e3c48d8a0..21dca6d6d5fe 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18289,7 +18289,8 @@ "orcaStopQuit": "この応答の途中で、{{machine}} の Orca が終了しました。", "orcaStopCrash": "この応答の途中で、{{machine}} の Orca が予期せず停止しました。", "orcaStopRemote": "この応答の途中で、{{machine}} の Orca が停止しました。", - "orcaStopCanContinue": "この会話で続行できます。" + "orcaStopCanContinue": "この会話で続行できます。", + "orcaStopUnnamedServer": "リモートサーバー" }, "providerFrame": { "byteLength": "{{value0}}バイト" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index fccd5a553a43..e50e6863da7d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18289,7 +18289,8 @@ "orcaStopQuit": "이 응답이 진행되는 동안 {{machine}}의 Orca가 종료되었습니다.", "orcaStopCrash": "이 응답이 진행되는 동안 {{machine}}의 Orca가 예기치 않게 중지되었습니다.", "orcaStopRemote": "이 응답이 진행되는 동안 {{machine}}의 Orca가 중지되었습니다.", - "orcaStopCanContinue": "이 대화에서 계속할 수 있습니다." + "orcaStopCanContinue": "이 대화에서 계속할 수 있습니다.", + "orcaStopUnnamedServer": "원격 서버" }, "providerFrame": { "byteLength": "{{value0}}바이트" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b543ca0359c7..0435b6f421d2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18254,7 +18254,8 @@ "orcaStopQuit": "此回复进行期间,{{machine}} 上的 Orca 已关闭。", "orcaStopCrash": "此回复进行期间,{{machine}} 上的 Orca 意外停止。", "orcaStopRemote": "此回复进行期间,{{machine}} 上的 Orca 已停止。", - "orcaStopCanContinue": "你可以在此对话中继续。" + "orcaStopCanContinue": "你可以在此对话中继续。", + "orcaStopUnnamedServer": "远程服务器" }, "providerFrame": { "byteLength": "{{value0}} 字节"