diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index bb21029e1c..f6442226fa 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -18,6 +18,7 @@ import { MarkdownRenderer } from "@/browser/features/Messages/MarkdownRenderer"; import { useTranscriptContextMenu } from "@/browser/features/Messages/useTranscriptContextMenu"; import type { UserMessageNavigation } from "@/browser/features/Messages/UserMessage"; import { InterruptedBarrier } from "@/browser/features/Messages/ChatBarrier/InterruptedBarrier"; +import { useResumeStream } from "@/browser/hooks/useResumeStream"; import { EditCutoffBarrier } from "@/browser/features/Messages/ChatBarrier/EditCutoffBarrier"; import { StreamingBarrier } from "@/browser/features/Messages/ChatBarrier/StreamingBarrier"; import { RetryBarrier } from "@/browser/features/Messages/ChatBarrier/RetryBarrier"; @@ -47,6 +48,7 @@ import { getInterruptionContext, getLastMainRetryCandidateMessage, getLastNonDecorativeMessage, + isPreTokenInterruptedUserTurn, } from "@/common/utils/messages/retryEligibility"; import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; @@ -1056,6 +1058,34 @@ const ChatPaneContent: React.FC = (props) => { interruptedBarrierMessageIds.add(message.id); } } + // A turn interrupted before its first token leaves the user message as the tail + // with no assistant row, so the loop above never marks it. Mark it here (subject + // to the same hydration/auto-retry/streaming suppression) so the divider still + // offers to continue. interruptedTailResumable/render both key off this set. + if ( + !isHydratingTranscript && + !isAutoRetryActive && + !shouldShowStreamingBarrier && + lastRetryCandidateMessage != null && + isPreTokenInterruptedUserTurn(lastRetryCandidateMessage, workspaceState.lastAbortReason) + ) { + interruptedBarrierMessageIds.add(lastRetryCandidateMessage.id); + } + + // Owned here so the click and keybind paths share one resume/error. resetKey is + // the resume target, so error/spinner reset when the interrupted turn changes. + const { resume: resumeInterruptedStreamAsync, error: resumeInterruptedError } = useResumeStream( + workspaceId, + lastRetryCandidateMessage?.id + ); + const resumeInterruptedStream = () => void resumeInterruptedStreamAsync(); + // Resumable only on the writable tail and only when RetryBarrier is suppressed + // (user-aborted case). When RetryBarrier is visible, its button owns resume. + const interruptedTailResumable = + !transcriptOnly && + !showRetryBarrierUI && + lastRetryCandidateMessage != null && + interruptedBarrierMessageIds.has(lastRetryCandidateMessage.id); const transcriptTailItems: TranscriptTailStackItem[] = []; if (shouldMountRetryBarrier) { transcriptTailItems.push( @@ -1126,6 +1156,8 @@ const ChatPaneContent: React.FC = (props) => { aggregator, setEditingMessage, vimEnabled, + canResumeInterruptedStream: interruptedTailResumable, + resumeInterruptedStream, }); // Clear editing state if the message being edited no longer exists @@ -1259,7 +1291,13 @@ const ChatPaneContent: React.FC = (props) => { /> )} {isAtCutoff && } - {interruptedBarrierMessageIds.has(message.id) && } + {interruptedBarrierMessageIds.has(message.id) && ( + + )} ); }; diff --git a/src/browser/features/Messages/ChatBarrier/BaseBarrier.tsx b/src/browser/features/Messages/ChatBarrier/BaseBarrier.tsx index becde41fd5..c29a16e740 100644 --- a/src/browser/features/Messages/ChatBarrier/BaseBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/BaseBarrier.tsx @@ -6,13 +6,21 @@ interface BaseBarrierProps { color: string; animate?: boolean; className?: string; + /** When set, the centered label becomes a clickable button (gradient lines stay inert). */ + onClick?: () => void; + /** Accessible label for the clickable variant (defaults to `text`). */ + ariaLabel?: string; } +const LABEL_CLASS = "font-mono text-[10px] tracking-wide whitespace-nowrap uppercase"; + export const BaseBarrier: React.FC = ({ text, color, animate = false, className, + onClick, + ariaLabel, }) => { return (
= ({ background: `linear-gradient(to right, transparent, ${color} 20%, ${color} 80%, transparent)`, }} /> -
- {text} -
+ {onClick ? ( + + ) : ( +
+ {text} +
+ )}
{ + beforeEach(() => { + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + }); + + afterEach(() => { + cleanup(); + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + }); + + test("clicking the resumable label invokes onResume", () => { + const onResume = mock(() => undefined); + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Continue interrupted response" })); + + expect(onResume).toHaveBeenCalledTimes(1); + }); + + test("a non-resumable divider renders no clickable control", () => { + const onResume = mock(() => undefined); + const view = render(); + + expect(view.queryByRole("button")).toBeNull(); + expect(view.getByText("interrupted")).toBeTruthy(); + expect(onResume).not.toHaveBeenCalled(); + }); + + test("surfaces a resume failure so the action is not a silent no-op", () => { + const view = render( + undefined} error="Runtime failed to start" /> + ); + + expect(view.getByText("Couldn't continue:")).toBeTruthy(); + expect(view.getByText(/Runtime failed to start/)).toBeTruthy(); + }); + + test("does not surface an error on a non-resumable divider", () => { + const view = render(); + + expect(view.queryByText("Couldn't continue:")).toBeNull(); + }); +}); diff --git a/src/browser/features/Messages/ChatBarrier/InterruptedBarrier.tsx b/src/browser/features/Messages/ChatBarrier/InterruptedBarrier.tsx index d543c0e011..2a6662c25d 100644 --- a/src/browser/features/Messages/ChatBarrier/InterruptedBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/InterruptedBarrier.tsx @@ -2,9 +2,36 @@ import React from "react"; import { BaseBarrier } from "./BaseBarrier"; interface InterruptedBarrierProps { + /** True on the writable resume target (history tail); only then is the label clickable. */ + resumable?: boolean; + /** Resume handler (owned by ChatPane); used only when `resumable`. */ + onResume?: () => void; + /** Last resume failure, surfaced inline so a click/keybind isn't a silent no-op. */ + error?: string | null; className?: string; } -export const InterruptedBarrier: React.FC = ({ className }) => { - return ; +/** + * "interrupted" divider on a partial assistant turn. On the resumable tail the + * label continues the stream (the only continue affordance for Esc interrupts, + * where RetryBarrier is suppressed). + */ +export const InterruptedBarrier: React.FC = (props) => { + return ( + <> + + {props.resumable && props.error && ( + // Surface failures: this is the only continue affordance for an Esc interrupt. +
+ Couldn't continue: {props.error} +
+ )} + + ); }; diff --git a/src/browser/features/Settings/Sections/KeybindsSection.tsx b/src/browser/features/Settings/Sections/KeybindsSection.tsx index a6e122e7ea..223b894a49 100644 --- a/src/browser/features/Settings/Sections/KeybindsSection.tsx +++ b/src/browser/features/Settings/Sections/KeybindsSection.tsx @@ -17,6 +17,7 @@ const KEYBIND_LABELS: Record = { SAVE_EDIT: "Save edit", INTERRUPT_STREAM_VIM: "Interrupt stream (Vim mode)", INTERRUPT_STREAM_NORMAL: "Interrupt stream", + RESUME_STREAM: "Continue interrupted stream", FOCUS_INPUT_I: "Focus input (i)", FOCUS_INPUT_A: "Focus input (a)", NEW_WORKSPACE: "New workspace", @@ -117,6 +118,7 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array "CANCEL", "INTERRUPT_STREAM_NORMAL", "INTERRUPT_STREAM_VIM", + "RESUME_STREAM", "TOGGLE_VOICE_INPUT", ], }, diff --git a/src/browser/hooks/useAIViewKeybinds.test.tsx b/src/browser/hooks/useAIViewKeybinds.test.tsx index e019bb1b97..09a2277afd 100644 --- a/src/browser/hooks/useAIViewKeybinds.test.tsx +++ b/src/browser/hooks/useAIViewKeybinds.test.tsx @@ -421,4 +421,106 @@ describe("useAIViewKeybinds", () => { expect(interruptStream.mock.calls.length).toBe(0); }); + + test("Shift+R resumes when a resumable interrupted turn is shown", () => { + const resumeInterruptedStream = mock(() => undefined); + const chatInputAPI: RefObject = { current: null }; + + renderUseAIViewKeybinds({ + workspaceId: "ws", + canInterrupt: false, + showRetryBarrier: false, + chatInputAPI, + jumpToBottom: () => undefined, + loadOlderHistory: null, + handleOpenTerminal: () => undefined, + handleOpenInEditor: () => undefined, + aggregator: undefined, + setEditingMessage: () => undefined, + vimEnabled: false, + canResumeInterruptedStream: true, + resumeInterruptedStream, + }); + + document.body.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "R", + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ); + + expect(resumeInterruptedStream.mock.calls.length).toBe(1); + }); + + test("Shift+R does nothing when no resumable turn is shown", () => { + const resumeInterruptedStream = mock(() => undefined); + const chatInputAPI: RefObject = { current: null }; + + renderUseAIViewKeybinds({ + workspaceId: "ws", + canInterrupt: false, + showRetryBarrier: false, + chatInputAPI, + jumpToBottom: () => undefined, + loadOlderHistory: null, + handleOpenTerminal: () => undefined, + handleOpenInEditor: () => undefined, + aggregator: undefined, + setEditingMessage: () => undefined, + vimEnabled: false, + canResumeInterruptedStream: false, + resumeInterruptedStream, + }); + + document.body.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "R", + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ); + + expect(resumeInterruptedStream.mock.calls.length).toBe(0); + }); + + test("Shift+R does not resume while typing in an input (types normally)", () => { + const resumeInterruptedStream = mock(() => undefined); + const chatInputAPI: RefObject = { current: null }; + + renderUseAIViewKeybinds({ + workspaceId: "ws", + canInterrupt: false, + showRetryBarrier: false, + chatInputAPI, + jumpToBottom: () => undefined, + loadOlderHistory: null, + handleOpenTerminal: () => undefined, + handleOpenInEditor: () => undefined, + aggregator: undefined, + setEditingMessage: () => undefined, + vimEnabled: false, + canResumeInterruptedStream: true, + resumeInterruptedStream, + }); + + // Composer/terminal are editable elements, so the transcript-scoped key must + // type "R" instead of resuming. + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + input.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "R", + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ); + + expect(resumeInterruptedStream.mock.calls.length).toBe(0); + }); }); diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index 7d0efb942d..d26d20dc18 100644 --- a/src/browser/hooks/useAIViewKeybinds.ts +++ b/src/browser/hooks/useAIViewKeybinds.ts @@ -26,11 +26,16 @@ interface UseAIViewKeybindsParams { aggregator: StreamingMessageAggregator | undefined; // For compaction detection setEditingMessage: (editing: EditingMessageState | undefined) => void; vimEnabled: boolean; // For vim-aware interrupt keybind + // RESUME_STREAM keybind: continue an interrupted stream. Optional so the hook + // stays drop-in for callers/tests that don't surface a resume affordance. + canResumeInterruptedStream?: boolean; // Whether a resumable interrupted turn is shown + resumeInterruptedStream?: () => void; // Continue the interrupted stream } /** * Manages keyboard shortcuts for AIView: * - Esc (non-vim) or Ctrl+C (vim): Interrupt stream (Escape skips text inputs by default) + * - Shift+R: Resume an interrupted stream (when a resumable turn is shown) * - Ctrl+I: Focus chat input * - Shift+H: Load older transcript messages (when available) * - Shift+G: Jump to bottom @@ -52,6 +57,8 @@ export function useAIViewKeybinds({ aggregator, setEditingMessage, vimEnabled, + canResumeInterruptedStream = false, + resumeInterruptedStream, }: UseAIViewKeybindsParams): void { const { api } = useAPI(); @@ -145,6 +152,19 @@ export function useAIViewKeybinds({ return; } + // Resume an interrupted stream. Like Shift+G/Shift+H, this is a + // transcript-scoped key: gated below the editable guard so Shift+R types + // normally while composing. Only acts when a resumable turn is shown. + if ( + matchesKeybind(e, KEYBINDS.RESUME_STREAM) && + canResumeInterruptedStream && + resumeInterruptedStream + ) { + e.preventDefault(); + resumeInterruptedStream(); + return; + } + if (matchesKeybind(e, KEYBINDS.LOAD_OLDER_MESSAGES) && loadOlderHistory) { e.preventDefault(); loadOlderHistory(); @@ -180,6 +200,8 @@ export function useAIViewKeybinds({ aggregator, setEditingMessage, vimEnabled, + canResumeInterruptedStream, + resumeInterruptedStream, api, ]); } diff --git a/src/browser/hooks/useResumeStream.test.tsx b/src/browser/hooks/useResumeStream.test.tsx new file mode 100644 index 0000000000..5425654ef2 --- /dev/null +++ b/src/browser/hooks/useResumeStream.test.tsx @@ -0,0 +1,134 @@ +import React from "react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { GlobalWindow } from "happy-dom"; + +import type * as WorkspaceStoreModule from "@/browser/stores/WorkspaceStore"; + +interface MockWorkspaceState { + autoRetryStatus: null; + isStreamStarting: boolean; + canInterrupt: boolean; + messages: Array>; +} + +const currentWorkspaceState: MockWorkspaceState = { + autoRetryStatus: null, + isStreamStarting: false, + canInterrupt: false, + messages: [{ type: "user", id: "user-1", content: "Hi", historySequence: 1 }], +}; + +type ResumeStreamResult = + | { success: true; data: { started: boolean } } + | { success: false; error: { type: "runtime_start_failed"; message: string } }; +let resumeStreamResult: ResumeStreamResult = { success: true, data: { started: true } }; +const resumeStream = mock((_input: unknown) => Promise.resolve(resumeStreamResult)); +const setAutoRetryEnabled = mock((_input: unknown) => + Promise.resolve({ success: true as const, data: { previousEnabled: false, enabled: true } }) +); + +void mock.module("@/browser/contexts/API", () => ({ + useAPI: () => ({ + api: { workspace: { resumeStream, setAutoRetryEnabled } }, + status: "connected" as const, + error: null, + authenticate: () => undefined, + retry: () => undefined, + }), +})); + +/* eslint-disable @typescript-eslint/no-require-imports */ +const actualWorkspaceStore = + require("@/browser/stores/WorkspaceStore?real=1") as typeof WorkspaceStoreModule; +/* eslint-enable @typescript-eslint/no-require-imports */ + +void mock.module("@/browser/stores/WorkspaceStore", () => ({ + ...actualWorkspaceStore, + useWorkspaceState: () => currentWorkspaceState, + useWorkspaceStoreRaw: () => ({ getWorkspaceState: () => currentWorkspaceState }), +})); + +import { useResumeStream } from "./useResumeStream"; + +// workspaceId/resetKey come straight from props so tests can rerender with new identity. +const Harness: React.FC<{ workspaceId?: string; resetKey?: string | null }> = (props) => { + const { resume, error } = useResumeStream(props.workspaceId ?? "ws-1", props.resetKey); + return ( +
+ + {error &&
{error}
} +
+ ); +}; + +describe("useResumeStream", () => { + beforeEach(() => { + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + resumeStreamResult = { success: true, data: { started: true } }; + resumeStream.mockClear(); + setAutoRetryEnabled.mockClear(); + }); + + afterEach(() => { + cleanup(); + mock.restore(); + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + }); + + test("resumes the stream without touching the auto-retry preference", async () => { + const view = render(); + + fireEvent.click(view.getByText("resume")); + + await waitFor(() => { + expect(resumeStream).toHaveBeenCalledTimes(1); + }); + // A user-initiated (Esc) interrupt means "continue once": never enable/disable + // auto-retry, so a transient divider can't cancel a scheduled retry on unmount. + expect(setAutoRetryEnabled).not.toHaveBeenCalled(); + expect(resumeStream.mock.calls[0]?.[0]).toMatchObject({ workspaceId: "ws-1" }); + }); + + test("clears the error when workspaceId changes (no cross-workspace bleed)", async () => { + resumeStreamResult = { + success: false, + error: { type: "runtime_start_failed", message: "Runtime failed to start" }, + }; + + const view = render(); + fireEvent.click(view.getByText("resume")); + + await waitFor(() => { + expect(view.getByTestId("resume-error")).toBeTruthy(); + }); + + // Same always-mounted hook now serves a different workspace: its error must reset. + view.rerender(); + + expect(view.queryByTestId("resume-error")).toBeNull(); + }); + + test("clears the error when the resume target (resetKey) changes in the same workspace", async () => { + resumeStreamResult = { + success: false, + error: { type: "runtime_start_failed", message: "Runtime failed to start" }, + }; + + const view = render(); + fireEvent.click(view.getByText("resume")); + + await waitFor(() => { + expect(view.getByTestId("resume-error")).toBeTruthy(); + }); + + // A later interrupted turn in the same workspace must not inherit the old error. + view.rerender(); + + expect(view.queryByTestId("resume-error")).toBeNull(); + }); +}); diff --git a/src/browser/hooks/useResumeStream.ts b/src/browser/hooks/useResumeStream.ts new file mode 100644 index 0000000000..c2512b1b8e --- /dev/null +++ b/src/browser/hooks/useResumeStream.ts @@ -0,0 +1,97 @@ +import { useRef, useState } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import { useWorkspaceState } from "@/browser/stores/WorkspaceStore"; +import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; +import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; +import { formatSendMessageError } from "@/common/utils/errors/formatSendError"; +import { getErrorMessage } from "@/common/utils/errors"; + +export interface UseResumeStreamResult { + /** Continue the interrupted stream from where it stopped. */ + resume: () => Promise; + /** True while a resume request is in flight; guards against double-trigger. */ + isResuming: boolean; + /** Last resume error, if any. */ + error: string | null; +} + +/** + * One-shot "continue from where it stopped" for the interrupted divider and its + * keybind. A user who pressed Esc asked to stop, so this never flips the + * auto-retry preference (RetryBarrier owns its own auto-retry recovery for + * system/error interrupts). resumeStream does no history shaping; the model just + * continues the partial assistant turn. Auto-retry-enabled users still get + * backend recovery, since the backend consults the persisted preference on failure. + * + * `resetKey` (the resume target message id) augments the identity so transient + * state can't bleed across workspaces or across interrupted turns. + */ +export function useResumeStream( + workspaceId: string, + resetKey?: string | null +): UseResumeStreamResult { + const { api } = useAPI(); + const workspaceState = useWorkspaceState(workspaceId); + const [error, setError] = useState(null); + const [isResuming, setIsResuming] = useState(false); + + // ChatPane owns this hook and stays mounted across workspace/target changes, so + // reset transient state when identity changes (adjust-during-render, no effect) + // and track the live identity so a late-resolving resume can't write onto it. + const identity = `${workspaceId}\u0000${resetKey ?? ""}`; + const latestIdentity = useRef(identity); + latestIdentity.current = identity; + const [trackedIdentity, setTrackedIdentity] = useState(identity); + if (identity !== trackedIdentity) { + setTrackedIdentity(identity); + setError(null); + setIsResuming(false); + } + + const resume = async (): Promise => { + if (!api) { + setError("Not connected to server"); + return; + } + if (isResuming) { + return; + } + + const startedForIdentity = identity; + const applyIfCurrent = (apply: () => void): void => { + if (latestIdentity.current === startedForIdentity) apply(); + }; + + setIsResuming(true); + setError(null); + try { + let options = getSendOptionsFromStorage(workspaceId); + const lastUserMessage = [...workspaceState.messages] + .reverse() + .find( + (message): message is Extract => message.type === "user" + ); + if (lastUserMessage?.compactionRequest) { + options = applyCompactionOverrides(options, lastUserMessage.compactionRequest.parsed); + } + + const result = await api.workspace.resumeStream({ workspaceId, options }); + if (!result.success) { + const formatted = formatSendMessageError(result.error); + applyIfCurrent(() => + setError( + formatted.resolutionHint + ? `${formatted.message} ${formatted.resolutionHint}` + : formatted.message + ) + ); + } + } catch (err) { + applyIfCurrent(() => setError(getErrorMessage(err))); + } finally { + applyIfCurrent(() => setIsResuming(false)); + } + }; + + return { resume, isResuming, error }; +} diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index b8401a9865..2c4dde78e7 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -323,6 +323,9 @@ export const KEYBINDS = { INTERRUPT_STREAM_VIM: { key: "c", ctrl: true, macCtrlBehavior: "control" }, INTERRUPT_STREAM_NORMAL: { key: "Escape" }, + /** Continue an interrupted stream (R = resume; transcript-focused, like Shift+G) */ + RESUME_STREAM: { key: "R", shift: true }, + /** Focus chat input */ FOCUS_INPUT_I: { key: "i" }, diff --git a/src/common/utils/messages/retryEligibility.test.ts b/src/common/utils/messages/retryEligibility.test.ts index edaa0c4343..eeb877cc14 100644 --- a/src/common/utils/messages/retryEligibility.test.ts +++ b/src/common/utils/messages/retryEligibility.test.ts @@ -4,6 +4,7 @@ import { hasInterruptedStream, isEligibleForAutoRetry, isNonRetryableSendError, + isPreTokenInterruptedUserTurn, PENDING_STREAM_START_GRACE_PERIOD_MS, } from "./retryEligibility"; import type { DisplayedMessage } from "@/common/types/message"; @@ -570,3 +571,38 @@ describe("isNonRetryableSendError", () => { }); } }); + +describe("isPreTokenInterruptedUserTurn", () => { + it("is true for a trailing user message aborted by the user (pre-token interrupt)", () => { + // No assistant row yet, so the partial-message divider path can't fire; the + // user must still be able to continue the stopped turn. + expect(isPreTokenInterruptedUserTurn(userMessage(), { reason: "user", at: Date.now() })).toBe( + true + ); + }); + + it("is true for a startup abort (also suppresses RetryBarrier)", () => { + expect( + isPreTokenInterruptedUserTurn(userMessage(), { reason: "startup", at: Date.now() }) + ).toBe(true); + }); + + it("is false for a system abort (RetryBarrier owns recovery)", () => { + expect(isPreTokenInterruptedUserTurn(userMessage(), { reason: "system", at: Date.now() })).toBe( + false + ); + }); + + it("is false with no abort reason (app-restart case; RetryBarrier owns it)", () => { + expect(isPreTokenInterruptedUserTurn(userMessage(), null)).toBe(false); + }); + + it("is false when the tail is an assistant message (partial path handles it)", () => { + expect( + isPreTokenInterruptedUserTurn(assistantMessage({ isPartial: true }), { + reason: "user", + at: Date.now(), + }) + ).toBe(false); + }); +}); diff --git a/src/common/utils/messages/retryEligibility.ts b/src/common/utils/messages/retryEligibility.ts index 8f6d8b6a35..3a07ceb136 100644 --- a/src/common/utils/messages/retryEligibility.ts +++ b/src/common/utils/messages/retryEligibility.ts @@ -96,6 +96,19 @@ function shouldSuppressAutoRetry( return lastAbortReason?.reason === "user" || lastAbortReason?.reason === "startup"; } +/** + * True when a turn was interrupted before its first token: the transcript tail is + * the user message (no assistant/tool row yet) and the abort was user/startup + * (RetryBarrier suppressed). shouldShowInterruptedBarrier never marks a user + * message, so callers use this to still offer "continue" on the interrupted tail. + */ +export function isPreTokenInterruptedUserTurn( + tail: DisplayedMessage | undefined, + lastAbortReason: StreamAbortReasonSnapshot | null | undefined +): boolean { + return tail?.type === "user" && shouldSuppressAutoRetry(lastAbortReason); +} + function isDecorativeTranscriptMessage(message: DisplayedMessage): boolean { return ( message.type === "history-hidden" ||