diff --git a/mobile/src/messages/normalizeChatEvent.test.ts b/mobile/src/messages/normalizeChatEvent.test.ts index 3616cf1cc0..f8e61f696b 100644 --- a/mobile/src/messages/normalizeChatEvent.test.ts +++ b/mobile/src/messages/normalizeChatEvent.test.ts @@ -105,6 +105,22 @@ describe("createChatEventExpander", () => { }); }); + it("ignores desktop-only compaction and retry status events", () => { + const expander = createChatEventExpander(); + + const events = expander.expand([ + { type: "idle-compaction-needed" } as WorkspaceChatEvent, + { type: "idle-compaction-started" } as WorkspaceChatEvent, + { type: "auto-compaction-triggered" } as WorkspaceChatEvent, + { type: "auto-compaction-completed" } as WorkspaceChatEvent, + { type: "auto-retry-scheduled" } as WorkspaceChatEvent, + { type: "auto-retry-starting" } as WorkspaceChatEvent, + { type: "auto-retry-abandoned" } as WorkspaceChatEvent, + ]); + + expect(events).toEqual([]); + }); + it("emits displayable entries for mux messages replayed from history", () => { const expander = createChatEventExpander(); diff --git a/mobile/src/messages/normalizeChatEvent.ts b/mobile/src/messages/normalizeChatEvent.ts index 8fe969e06e..0402ce2fa9 100644 --- a/mobile/src/messages/normalizeChatEvent.ts +++ b/mobile/src/messages/normalizeChatEvent.ts @@ -416,8 +416,15 @@ export function createChatEventExpander(): ChatEventExpander { // Usage delta: mobile app doesn't display usage, silently ignore "usage-delta": () => [], - // Idle compaction signal: desktop auto-triggers; mobile currently ignores. + // Compaction/retry status signals: desktop-only affordances for now. + // Mobile intentionally drops these to avoid noisy "unsupported event" rows. "idle-compaction-needed": () => [], + "idle-compaction-started": () => [], + "auto-compaction-triggered": () => [], + "auto-compaction-completed": () => [], + "auto-retry-scheduled": () => [], + "auto-retry-starting": () => [], + "auto-retry-abandoned": () => [], // Pass-through events: return unchanged "caught-up": () => [payload as WorkspaceChatEvent], diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 221281f1a7..1dfefea31b 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -21,7 +21,6 @@ import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { handleLayoutSlotHotkeys } from "./utils/ui/layoutSlotHotkeys"; import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering"; import { getVisibleWorkspaceIds } from "./utils/ui/workspaceDomNav"; -import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; @@ -244,9 +243,6 @@ function AppInner() { } }, [refreshWorkspaceMetadata, setSelectedWorkspace]); - // Auto-resume interrupted streams on app startup and when failures occur - useResumeManager(); - // Update window title based on selected workspace // URL syncing is now handled by RouterContext useEffect(() => { diff --git a/src/browser/components/AppLoader.tsx b/src/browser/components/AppLoader.tsx index 8ec524bbc8..c9150ae197 100644 --- a/src/browser/components/AppLoader.tsx +++ b/src/browser/components/AppLoader.tsx @@ -12,7 +12,6 @@ import { ProjectProvider, useProjectContext } from "../contexts/ProjectContext"; import { PolicyProvider, usePolicy } from "@/browser/contexts/PolicyContext"; import { PolicyBlockedScreen } from "@/browser/components/PolicyBlockedScreen"; import { APIProvider, useAPI, type APIClient } from "@/browser/contexts/API"; -import { useIdleCompactionHandler } from "@/browser/hooks/useIdleCompactionHandler"; import { WorkspaceProvider, useWorkspaceContext } from "../contexts/WorkspaceContext"; import { RouterProvider } from "../contexts/RouterContext"; import { TelemetryEnabledProvider } from "../contexts/TelemetryEnabledContext"; @@ -64,13 +63,6 @@ function AppLoaderInner() { const apiState = useAPI(); const api = apiState.api; - // Idle compaction should be registered at the app level so it can serialize across - // all workspaces (and won't reset when switching workspaces). - // - // Avoid triggering background compactions when mux is blocked by policy. - const idleCompactionApi = policyState.status.state === "blocked" ? null : api; - useIdleCompactionHandler({ api: idleCompactionApi }); - // Get store instances const workspaceStoreInstance = useWorkspaceStoreRaw(); const gitStatusStore = useGitStatusStoreRaw(); diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 45523b9ecc..f38a41b395 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -51,13 +51,11 @@ import { getWorkspaceLastReadKey, } from "@/common/constants/storage"; import { - executeCompaction, prepareCompactionMessage, processSlashCommand, type SlashCommandContext, } from "@/browser/utils/chatCommands"; import { Button } from "../ui/button"; -import { shouldTriggerAutoCompaction } from "@/browser/utils/compaction/shouldTriggerAutoCompaction"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { findAtMentionAtCursor } from "@/common/utils/atMentions"; import { @@ -98,7 +96,7 @@ import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; import { resolveThinkingInput } from "@/common/utils/thinking/policy"; import { - type MuxFrontendMetadata, + type MuxMessageMetadata, type ReviewNoteDataForDisplay, prepareUserMessageForSend, } from "@/common/types/message"; @@ -181,8 +179,6 @@ const ChatInputInner: React.FC = (props) => { const editingMessage = variant === "workspace" ? props.editingMessage : undefined; const isStreamStarting = variant === "workspace" ? (props.isStreamStarting ?? false) : false; const isCompacting = variant === "workspace" ? (props.isCompacting ?? false) : false; - const hasQueuedCompaction = - variant === "workspace" ? (props.hasQueuedCompaction ?? false) : false; const [isMobileTouch, setIsMobileTouch] = useState( () => typeof window !== "undefined" && @@ -1868,89 +1864,6 @@ const ChatInputInner: React.FC = (props) => { const preSendDraft = getDraft(); const preSendReviews = draftReviews; - // Auto-compaction check (workspace variant only) - // Check if we should auto-compact before sending this message - // Result is computed in parent (AIView) and passed down to avoid duplicate calculation - if ( - variant === "workspace" && - shouldTriggerAutoCompaction( - props.autoCompactionCheck, - isCompacting || isStreamStarting, - !!editingMessage, - hasQueuedCompaction - ) - ) { - // Prepare file parts for the continue message - const fileParts = chatAttachmentsToFileParts(attachments); - - // Prepare reviews data for the continue message - const reviewsData = reviewData; - - // Capture review IDs for marking as checked on success - const sentReviewIds = reviewIdsForCheck; - - // Clear input immediately for responsive UX - setInput(""); - setDraftReviews(null); - - const compactionSendMessageOptions: SendMessageOptions = { - ...sendMessageOptions, - }; - - setAttachments([]); - setHideReviewsDuringSend(true); - - try { - const result = await executeCompaction({ - api, - workspaceId: props.workspaceId, - followUpContent: { - text: messageTextForSend, - fileParts, - reviews: reviewsData, - muxMetadata: skillMuxMetadata, - }, - sendMessageOptions: compactionSendMessageOptions, - }); - - if (!result.success) { - // Restore on error - setDraft(preSendDraft); - setDraftReviews(preSendReviews); - pushToast({ - type: "error", - title: "Auto-Compaction Failed", - message: result.error ?? "Failed to start auto-compaction", - }); - } else { - // Mark reviews as checked on success - if (sentReviewIds.length > 0) { - props.onCheckReviews?.(sentReviewIds); - } - pushToast({ - type: "success", - message: "Context threshold reached - auto-compacting...", - }); - props.onMessageSent?.(); - } - } catch (error) { - // Restore on unexpected error - setDraft(preSendDraft); - setDraftReviews(preSendReviews); - pushToast({ - type: "error", - title: "Auto-Compaction Failed", - message: - error instanceof Error ? error.message : "Unexpected error during auto-compaction", - }); - } finally { - setSendingCount((c) => c - 1); - setHideReviewsDuringSend(false); - } - - return; // Skip normal send - } - try { // Prepare file parts if any const fileParts = chatAttachmentsToFileParts(attachments, { validate: true }); @@ -1965,7 +1878,7 @@ const ChatInputInner: React.FC = (props) => { // When editing a /compact command, regenerate the actual summarization request let actualMessageText = messageTextForSend; - let muxMetadata: MuxFrontendMetadata | undefined = skillMuxMetadata; + let muxMetadata: MuxMessageMetadata | undefined = skillMuxMetadata; let compactionOptions: Partial = {}; if (editingMessage && actualMessageText.startsWith("/")) { diff --git a/src/browser/components/ChatInput/types.ts b/src/browser/components/ChatInput/types.ts index 9aa4d2d162..29cda0ce05 100644 --- a/src/browser/components/ChatInput/types.ts +++ b/src/browser/components/ChatInput/types.ts @@ -1,6 +1,5 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { TelemetryRuntimeType } from "@/common/telemetry/payload"; -import type { AutoCompactionCheckResult } from "@/browser/utils/compaction/autoCompactionCheck"; import type { Review } from "@/common/types/review"; import type { EditingMessageState, PendingUserMessage } from "@/browser/utils/chatEditing"; @@ -37,9 +36,6 @@ export interface ChatInputWorkspaceVariant { /** Optional explanation displayed when input is disabled */ disabledReason?: string; onReady?: (api: ChatInputAPI) => void; - autoCompactionCheck?: AutoCompactionCheckResult; // Computed in parent (AIView) to avoid duplicate calculation - /** True if there's already a compaction request queued (prevents double-compaction) */ - hasQueuedCompaction?: boolean; /** Reviews currently attached to chat (from useReviews hook) */ attachedReviews?: Review[]; /** Detach a review from chat input (sets status to pending) */ diff --git a/src/browser/components/ChatInput/utils.ts b/src/browser/components/ChatInput/utils.ts index 96bc867dfe..5f2e82b988 100644 --- a/src/browser/components/ChatInput/utils.ts +++ b/src/browser/components/ChatInput/utils.ts @@ -4,7 +4,7 @@ import type { APIClient } from "@/browser/contexts/API"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { SendMessageError } from "@/common/types/errors"; import type { ParsedRuntime } from "@/common/types/runtime"; -import { buildAgentSkillMetadata, type MuxFrontendMetadata } from "@/common/types/message"; +import { buildAgentSkillMetadata, type MuxMessageMetadata } from "@/common/types/message"; import type { FilePart } from "@/common/orpc/types"; import type { ChatAttachment } from "../ChatAttachments"; import type { Review } from "@/common/types/review"; @@ -50,7 +50,7 @@ function isUnknownSlashCommand(value: ParsedCommand): value is UnknownSlashComma export function buildSkillInvocationMetadata( rawCommand: string, descriptor: AgentSkillDescriptor -): MuxFrontendMetadata { +): MuxMessageMetadata { return buildAgentSkillMetadata({ rawCommand, commandPrefix: `/${descriptor.name}`, diff --git a/src/browser/components/ChatPane.tsx b/src/browser/components/ChatPane.tsx index b63bccea79..e8da7a0b1b 100644 --- a/src/browser/components/ChatPane.tsx +++ b/src/browser/components/ChatPane.tsx @@ -34,11 +34,10 @@ import { } from "@/browser/utils/messages/messageUtils"; import { computeTaskReportLinking } from "@/browser/utils/messages/taskReportLinking"; import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator"; -import { enableAutoRetryPreference } from "@/browser/utils/messages/autoRetryPreference"; import { getInterruptionContext, getLastNonDecorativeMessage, -} from "@/browser/utils/messages/retryEligibility"; +} from "@/common/utils/messages/retryEligibility"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useAutoScroll } from "@/browser/hooks/useAutoScroll"; import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor"; @@ -59,16 +58,14 @@ import { CompactionWarning } from "./CompactionWarning"; import { ContextSwitchWarning as ContextSwitchWarningBanner } from "./ContextSwitchWarning"; import { ConcurrentLocalWarning } from "./ConcurrentLocalWarning"; import { BackgroundProcessesBanner } from "./BackgroundProcessesBanner"; -import { checkAutoCompaction } from "@/browser/utils/compaction/autoCompactionCheck"; +import { checkAutoCompaction } from "@/common/utils/compaction/autoCompactionCheck"; import { cancelCompaction } from "@/browser/utils/compaction/handler"; import type { ContextSwitchWarning } from "@/browser/utils/compaction/contextSwitchCheck"; -import { executeCompaction } from "@/browser/utils/chatCommands"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings"; import { useContextSwitchWarning } from "@/browser/hooks/useContextSwitchWarning"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; -import { useForceCompaction } from "@/browser/hooks/useForceCompaction"; import type { TerminalSessionCreateOptions } from "@/browser/utils/terminal"; import { useAPI } from "@/browser/contexts/API"; import { useReviews } from "@/browser/hooks/useReviews"; @@ -194,6 +191,19 @@ export const ChatPane: React.FC = (props) => { pendingModel ); + useEffect(() => { + if (!api) { + return; + } + + // Keep backend session threshold in sync with the persisted per-model slider value. + const normalizedThreshold = Math.max(0.1, Math.min(1, autoCompactionThreshold / 100)); + void api.workspace.setAutoCompactionThreshold({ + workspaceId, + threshold: normalizedThreshold, + }); + }, [api, workspaceId, autoCompactionThreshold]); + const [editingState, setEditingState] = useState(() => ({ workspaceId, message: undefined as EditingMessageState | undefined, @@ -304,33 +314,6 @@ export const ChatPane: React.FC = (props) => { const shouldShowCompactionWarning = !isCompacting && autoCompactionResult.shouldShowWarning && !contextSwitchWarning; - // Handle force compaction callback - memoized to avoid effect re-runs. - // We pass a default continueMessage of "Continue" as a resume sentinel so the backend can - // auto-send it after compaction. The compaction prompt builder special-cases this sentinel - // to avoid injecting it into the summarization request. - const handleForceCompaction = useCallback(() => { - if (!api) return; - - // Force compaction queues a message while a stream is active. - // Match user-send semantics: background any running foreground bash so we don't block. - autoBackgroundOnSend(); - - void executeCompaction({ - api, - workspaceId, - sendMessageOptions: pendingSendOptions, - followUpContent: { text: "Continue" }, - }); - }, [api, workspaceId, pendingSendOptions, autoBackgroundOnSend]); - - // Force compaction when live usage shows we're about to hit context limit - useForceCompaction({ - shouldForceCompact: autoCompactionResult.shouldForceCompact, - canInterrupt, - isCompacting, - onTrigger: handleForceCompaction, - }); - // Vim mode state - needed for keybind selection (Ctrl+C in vim, Esc otherwise) const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true }); @@ -460,11 +443,6 @@ export const ChatPane: React.FC = (props) => { [addReview] ); - // Handler for manual compaction from CompactionWarning click - const handleCompactClick = useCallback(() => { - chatInputAPI.current?.prependText("/compact\n"); - }, []); - // Handlers for editing messages const handleEditUserMessage = useCallback( (message: EditingMessageState) => { @@ -556,11 +534,7 @@ export const ChatPane: React.FC = (props) => { // Enable auto-scroll when user sends a message setAutoScroll(true); - - // Reset autoRetry when user sends a message - // User action = clear intent: "I'm actively using this workspace" - enableAutoRetryPreference(workspaceId); - }, [setAutoScroll, autoBackgroundOnSend, workspaceId]); + }, [setAutoScroll, autoBackgroundOnSend]); const handleClearHistory = useCallback( async (percentage = 1.0) => { @@ -601,9 +575,8 @@ export const ChatPane: React.FC = (props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspaceId, workspaceState?.loading]); - // Compute showRetryBarrier once for both keybinds and UI - // Track if last message was interrupted or errored (for RetryBarrier) - // Uses same logic as useResumeManager for DRY + // Compute showRetryBarrier once for both keybinds and UI. + // Track if last message was interrupted or errored (for RetryBarrier). const interruption = workspaceState ? getInterruptionContext( workspaceState.messages, @@ -613,14 +586,18 @@ export const ChatPane: React.FC = (props) => { ) : null; + const hasInterruptedStream = interruption?.hasInterruptedStream ?? false; const showRetryBarrier = workspaceState - ? !workspaceState.canInterrupt && (interruption?.hasInterruptedStream ?? false) + ? !workspaceState.canInterrupt && hasInterruptedStream : false; const lastActionableMessage = getLastNonDecorativeMessage(workspaceState.messages); const suppressRetryBarrier = lastActionableMessage?.type === "stream-error" && lastActionableMessage.errorType === "context_exceeded"; + // Keep RetryBarrier mounted (but visually hidden) while a resumed stream is in flight + // so its temporary auto-retry rollback effect can observe terminal stream outcomes. + const shouldMountRetryBarrier = hasInterruptedStream && !suppressRetryBarrier; const showRetryBarrierUI = showRetryBarrier && !suppressRetryBarrier; const handleLoadOlderHistory = useCallback(() => { @@ -863,7 +840,12 @@ export const ChatPane: React.FC = (props) => { ); })} {/* Show RetryBarrier after the last message if needed */} - {showRetryBarrierUI && } + {shouldMountRetryBarrier && ( + + )} )} @@ -943,14 +925,12 @@ export const ChatPane: React.FC = (props) => { onContextSwitchCompact={handleContextSwitchCompact} onContextSwitchDismiss={handleContextSwitchDismiss} onModelChange={handleModelChange} - onCompactClick={handleCompactClick} onMessageSent={handleMessageSent} onTruncateHistory={handleClearHistory} editingMessage={editingMessage} onCancelEdit={handleCancelEdit} onEditLastUserMessage={handleEditLastUserMessageClick} onChatInputReady={handleChatInputReady} - hasQueuedCompaction={Boolean(workspaceState.queuedMessage?.hasCompactionRequest)} reviews={reviews} onCheckReviews={handleCheckReviews} /> @@ -975,14 +955,12 @@ interface ChatInputPaneProps { onContextSwitchCompact: () => void; onContextSwitchDismiss: () => void; onModelChange?: (model: string) => void; - onCompactClick: () => void; onMessageSent: () => void; onTruncateHistory: (percentage?: number) => Promise; editingMessage: EditingMessageState | undefined; onCancelEdit: () => void; onEditLastUserMessage: () => void; onChatInputReady: (api: ChatInputAPI) => void; - hasQueuedCompaction: boolean; reviews: ReviewsState; onCheckReviews: (ids: string[]) => void; } @@ -997,7 +975,6 @@ const ChatInputPane: React.FC = (props) => { usagePercentage={props.autoCompactionResult.usagePercentage} thresholdPercentage={props.autoCompactionResult.thresholdPercentage} isStreaming={props.canInterrupt} - onCompactClick={props.onCompactClick} /> )} {props.contextSwitchWarning && ( @@ -1035,8 +1012,6 @@ const ChatInputPane: React.FC = (props) => { onEditLastUserMessage={props.onEditLastUserMessage} canInterrupt={props.canInterrupt} onReady={props.onChatInputReady} - autoCompactionCheck={props.autoCompactionResult} - hasQueuedCompaction={props.hasQueuedCompaction} attachedReviews={reviews.attachedReviews} onDetachReview={reviews.detachReview} onDetachAllReviews={reviews.detachAllAttached} diff --git a/src/browser/components/CompactionWarning.tsx b/src/browser/components/CompactionWarning.tsx index e882d08e58..5889c832b0 100644 --- a/src/browser/components/CompactionWarning.tsx +++ b/src/browser/components/CompactionWarning.tsx @@ -9,18 +9,14 @@ import { FORCE_COMPACTION_BUFFER_PERCENT } from "@/common/constants/ui"; * - At/above threshold (not streaming): Bold "Next message will Auto-Compact" * - At/above threshold (streaming): "Force-compacting in N%" (where N = force threshold - current usage) * - * All states are clickable to insert /compact command. - * * @param usagePercentage - Current token usage as percentage (0-100), reflects live usage when streaming * @param thresholdPercentage - Auto-compaction trigger threshold (0-100, default 70) * @param isStreaming - Whether currently streaming a response - * @param onCompactClick - Callback when user clicks to trigger manual compaction */ export const CompactionWarning: React.FC<{ usagePercentage: number; thresholdPercentage: number; isStreaming: boolean; - onCompactClick?: () => void; }> = (props) => { // At threshold or above, next message will trigger compaction const willCompactNext = props.usagePercentage >= props.thresholdPercentage; @@ -47,17 +43,12 @@ export const CompactionWarning: React.FC<{ } return ( -
- +
+ {text}
); }; diff --git a/src/browser/components/Messages/ChatBarrier/RetryBarrier.test.tsx b/src/browser/components/Messages/ChatBarrier/RetryBarrier.test.tsx new file mode 100644 index 0000000000..cdba1aad97 --- /dev/null +++ b/src/browser/components/Messages/ChatBarrier/RetryBarrier.test.tsx @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { GlobalWindow } from "happy-dom"; + +interface MockWorkspaceState { + autoRetryStatus: + | { + type: "auto-retry-scheduled"; + attempt: number; + delayMs: number; + scheduledAt: number; + } + | { + type: "auto-retry-starting"; + attempt: number; + } + | { + type: "auto-retry-abandoned"; + reason: string; + } + | null; + isStreamStarting: boolean; + canInterrupt: boolean; + messages: Array>; +} + +function createWorkspaceState(overrides: Partial = {}): MockWorkspaceState { + return { + autoRetryStatus: null, + isStreamStarting: false, + canInterrupt: false, + messages: [ + { + type: "stream-error", + messageId: "assistant-1", + error: "Runtime failed to start", + errorType: "runtime_start_failed", + }, + ], + ...overrides, + }; +} + +let currentWorkspaceState = createWorkspaceState(); + +type ResumeStreamResult = + | { success: true; data: { started: boolean } } + | { + success: false; + error: { + type: "runtime_start_failed"; + message: string; + }; + }; + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +} + +let resumeStreamResult: ResumeStreamResult = { success: true, data: { started: true } }; +let previousAutoRetryEnabled = false; +const resumeStream = mock((_input: unknown) => Promise.resolve(resumeStreamResult)); +const setAutoRetryEnabled = mock((input: unknown) => { + if ( + typeof input === "object" && + input !== null && + "enabled" in input && + (input as { enabled?: unknown }).enabled === false + ) { + previousAutoRetryEnabled = false; + } + + return Promise.resolve({ + success: true as const, + data: { + previousEnabled: previousAutoRetryEnabled, + enabled: + typeof input === "object" && input !== null && "enabled" in input + ? ((input as { enabled?: boolean }).enabled ?? true) + : true, + }, + }); +}); + +void mock.module("@/browser/contexts/API", () => ({ + useAPI: () => ({ + api: { + workspace: { + resumeStream, + setAutoRetryEnabled, + }, + }, + status: "connected" as const, + error: null, + authenticate: () => undefined, + retry: () => undefined, + }), +})); + +void mock.module("@/browser/stores/WorkspaceStore", () => ({ + useWorkspaceState: () => currentWorkspaceState, + useWorkspaceStoreRaw: () => ({ + getWorkspaceState: (_workspaceId: string) => currentWorkspaceState, + }), +})); + +void mock.module("@/browser/hooks/usePersistedState", () => ({ + usePersistedState: () => [false, () => undefined] as const, +})); + +import { RetryBarrier } from "./RetryBarrier"; + +describe("RetryBarrier", () => { + beforeEach(() => { + globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; + globalThis.document = globalThis.window.document; + + currentWorkspaceState = createWorkspaceState(); + resumeStreamResult = { success: true, data: { started: true } }; + previousAutoRetryEnabled = false; + resumeStream.mockClear(); + setAutoRetryEnabled.mockClear(); + }); + + afterEach(() => { + cleanup(); + globalThis.window = undefined as unknown as Window & typeof globalThis; + globalThis.document = undefined as unknown as Document; + }); + + test("shows error details when manual resume fails before stream events", async () => { + resumeStreamResult = { + success: false, + error: { + type: "runtime_start_failed", + message: "Runtime failed to start", + }, + }; + + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(view.getByText("Retry failed:")).toBeTruthy(); + }); + expect(view.getByText(/Runtime failed to start/)).toBeTruthy(); + + expect(setAutoRetryEnabled).toHaveBeenNthCalledWith(1, { + workspaceId: "ws-1", + enabled: true, + persist: false, + }); + expect(setAutoRetryEnabled).toHaveBeenNthCalledWith(2, { + workspaceId: "ws-1", + enabled: false, + persist: false, + }); + expect(resumeStream).toHaveBeenCalledTimes(1); + }); + + test("restores disabled auto-retry preference after resumed stream reaches terminal state", async () => { + previousAutoRetryEnabled = false; + const resumeDeferred = createDeferred(); + resumeStream.mockImplementationOnce((_input: unknown) => resumeDeferred.promise); + + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(setAutoRetryEnabled).toHaveBeenCalledTimes(1); + }); + + currentWorkspaceState = createWorkspaceState({ + autoRetryStatus: { type: "auto-retry-starting", attempt: 1 }, + isStreamStarting: true, + canInterrupt: true, + }); + view.rerender(); + + resumeDeferred.resolve({ success: true, data: { started: true } }); + await waitFor(() => { + expect(resumeStream).toHaveBeenCalledTimes(1); + expect(setAutoRetryEnabled.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + currentWorkspaceState = createWorkspaceState({ + autoRetryStatus: null, + isStreamStarting: false, + canInterrupt: false, + }); + view.rerender(); + + await waitFor(() => { + expect(setAutoRetryEnabled).toHaveBeenCalledTimes(2); + }); + + expect(setAutoRetryEnabled).toHaveBeenNthCalledWith(1, { + workspaceId: "ws-1", + enabled: true, + persist: false, + }); + expect(setAutoRetryEnabled).toHaveBeenNthCalledWith(2, { + workspaceId: "ws-1", + enabled: false, + persist: false, + }); + expect(view.queryByText("Retry failed:")).toBeNull(); + }); + + test("restores preference when terminal state arrives without in-flight snapshots", async () => { + resumeStreamResult = { success: true, data: { started: true } }; + previousAutoRetryEnabled = false; + + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(resumeStream).toHaveBeenCalledTimes(1); + expect(setAutoRetryEnabled.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + currentWorkspaceState = createWorkspaceState({ + autoRetryStatus: null, + isStreamStarting: false, + canInterrupt: false, + messages: [ + { + type: "stream-error", + messageId: "assistant-1", + error: "Runtime failed to start", + errorType: "runtime_start_failed", + }, + { + type: "stream-error", + messageId: "assistant-2", + error: "Runtime failed to start", + errorType: "runtime_start_failed", + }, + ], + }); + view.rerender(); + + await waitFor(() => { + expect(setAutoRetryEnabled).toHaveBeenCalledTimes(2); + }); + + expect(setAutoRetryEnabled).toHaveBeenNthCalledWith(1, { + workspaceId: "ws-1", + enabled: true, + persist: false, + }); + expect(setAutoRetryEnabled).toHaveBeenNthCalledWith(2, { + workspaceId: "ws-1", + enabled: false, + persist: false, + }); + }); + + test("restores disabled auto-retry preference if barrier unmounts before terminal state", async () => { + resumeStreamResult = { success: true, data: { started: true } }; + previousAutoRetryEnabled = false; + + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(resumeStream).toHaveBeenCalledTimes(1); + expect(setAutoRetryEnabled.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + view.unmount(); + + await waitFor(() => { + expect(setAutoRetryEnabled).toHaveBeenCalledTimes(2); + }); + + expect(setAutoRetryEnabled).toHaveBeenNthCalledWith(1, { + workspaceId: "ws-1", + enabled: true, + persist: false, + }); + expect(setAutoRetryEnabled).toHaveBeenNthCalledWith(2, { + workspaceId: "ws-1", + enabled: false, + persist: false, + }); + }); + + test("rolls back temporary retry enable when resume reports not started", async () => { + resumeStreamResult = { success: true, data: { started: false } }; + previousAutoRetryEnabled = false; + + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(setAutoRetryEnabled).toHaveBeenCalledTimes(2); + }); + + expect(setAutoRetryEnabled).toHaveBeenNthCalledWith(1, { + workspaceId: "ws-1", + enabled: true, + persist: false, + }); + expect(setAutoRetryEnabled).toHaveBeenNthCalledWith(2, { + workspaceId: "ws-1", + enabled: false, + persist: false, + }); + }); + + test("keeps auto-retry enabled when manual retry fails and preference was already on", async () => { + previousAutoRetryEnabled = true; + resumeStreamResult = { + success: false, + error: { + type: "runtime_start_failed", + message: "Runtime failed to start", + }, + }; + + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(view.getByText("Retry failed:")).toBeTruthy(); + }); + + expect(setAutoRetryEnabled).toHaveBeenCalledTimes(1); + expect(setAutoRetryEnabled).toHaveBeenCalledWith({ + workspaceId: "ws-1", + enabled: true, + persist: false, + }); + expect(resumeStream).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx index a320e549c3..5da7b154d4 100644 --- a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx +++ b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx @@ -1,187 +1,290 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { AlertTriangle, RefreshCw } from "lucide-react"; -import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; -import type { RetryState } from "@/browser/hooks/useResumeManager"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceState } from "@/browser/stores/WorkspaceStore"; -import { - disableAutoRetryPreference, - enableAutoRetryPreference, - useAutoRetryPreference, -} from "@/browser/utils/messages/autoRetryPreference"; -import { - getInterruptionContext, - getLastNonDecorativeMessage, - isNonRetryableSendError, -} from "@/browser/utils/messages/retryEligibility"; -import { calculateBackoffDelay, createManualRetryState } from "@/browser/utils/messages/retryState"; +import { getLastNonDecorativeMessage } from "@/common/utils/messages/retryEligibility"; import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; -import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; -import { getRetryStateKey, VIM_ENABLED_KEY } from "@/common/constants/storage"; +import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { cn } from "@/common/lib/utils"; +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"; interface RetryBarrierProps { workspaceId: string; className?: string; } -const defaultRetryState: RetryState = { - attempt: 0, - retryStartTime: Date.now(), -}; - export const RetryBarrier: React.FC = (props) => { - // Get workspace state for computing effective autoRetry + const { api } = useAPI(); const workspaceState = useWorkspaceState(props.workspaceId); + const [countdown, setCountdown] = useState(0); + const [manualRetryError, setManualRetryError] = useState(null); + const [isManualRetrying, setIsManualRetrying] = useState(false); - const [autoRetry] = useAutoRetryPreference(props.workspaceId); - - // Read vim mode for displaying correct stop keybind const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true }); const stopKeybind = formatKeybind( vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL ); - // Use persisted state for retry tracking (survives workspace switches) - // Read retry state (managed by useResumeManager) - const [retryState] = usePersistedState( - getRetryStateKey(props.workspaceId), - defaultRetryState, - { listener: true } + const autoRetryStatus = workspaceState.autoRetryStatus; + const isAutoRetryScheduled = autoRetryStatus?.type === "auto-retry-scheduled"; + const isAutoRetryActive = + autoRetryStatus?.type === "auto-retry-scheduled" || + autoRetryStatus?.type === "auto-retry-starting"; + + const manualRetryRollbackWorkspaceIdRef = useRef(null); + const manualRetryRollbackPendingRef = useRef(false); + const manualRetryRollbackArmedRef = useRef(false); + const manualRetryRollbackBaselineMessageCountRef = useRef(null); + const apiRef = useRef(api); + + useEffect(() => { + apiRef.current = api; + }, [api]); + + const rollbackManualRetryAutoRetryIfNeeded = useCallback( + async (options?: { suppressErrors?: boolean }): Promise => { + if (!manualRetryRollbackPendingRef.current) { + return; + } + + const rollbackWorkspaceId = manualRetryRollbackWorkspaceIdRef.current; + manualRetryRollbackPendingRef.current = false; + manualRetryRollbackArmedRef.current = false; + manualRetryRollbackBaselineMessageCountRef.current = null; + manualRetryRollbackWorkspaceIdRef.current = null; + + const activeApi = apiRef.current; + if (!activeApi || !rollbackWorkspaceId) { + return; + } + + const rollbackResult = await activeApi.workspace.setAutoRetryEnabled?.({ + workspaceId: rollbackWorkspaceId, + enabled: false, + persist: false, + }); + if (rollbackResult && !rollbackResult.success && !options?.suppressErrors) { + setManualRetryError(rollbackResult.error); + } + }, + [] ); - const { attempt, retryStartTime, lastError } = retryState || defaultRetryState; + useEffect(() => { + if (!manualRetryRollbackPendingRef.current) { + return; + } - // Compute effective autoRetry state: user preference AND error is retryable - // This ensures UI shows "Retry" button (not "Retrying...") for non-retryable errors - const effectiveAutoRetry = useMemo(() => { - if (!autoRetry || !workspaceState) { - return false; + const rollbackWorkspaceId = manualRetryRollbackWorkspaceIdRef.current; + if (!rollbackWorkspaceId || rollbackWorkspaceId === props.workspaceId) { + return; } - // Check if current state is eligible for auto-retry - const { isEligibleForAutoRetry: messagesEligible } = getInterruptionContext( - workspaceState.messages, - workspaceState.pendingStreamStartTime, - workspaceState.runtimeStatus, - workspaceState.lastAbortReason - ); + void rollbackManualRetryAutoRetryIfNeeded(); + }, [props.workspaceId, rollbackManualRetryAutoRetryIfNeeded]); + + useEffect(() => { + return () => { + if (!manualRetryRollbackPendingRef.current) { + return; + } + + void rollbackManualRetryAutoRetryIfNeeded({ suppressErrors: true }); + }; + }, [rollbackManualRetryAutoRetryIfNeeded]); - // Also check RetryState for SendMessageErrors (from resumeStream failures) - // Note: isNonRetryableSendError already respects window.__MUX_FORCE_ALL_RETRYABLE - if (lastError && isNonRetryableSendError(lastError)) { - return false; // Non-retryable SendMessageError + useEffect(() => { + if (!manualRetryRollbackPendingRef.current) { + return; } - return messagesEligible; - }, [autoRetry, workspaceState, lastError]); + const autoRetryActive = + autoRetryStatus?.type === "auto-retry-scheduled" || + autoRetryStatus?.type === "auto-retry-starting"; + const streamInFlight = workspaceState.isStreamStarting || workspaceState.canInterrupt; - // Local state for UI - const [countdown, setCountdown] = useState(0); + // Mirror ask_user rollback semantics: keep temporary enablement while the resumed + // stream/retry attempt is in flight, then restore preference after terminal outcome. + if (autoRetryActive || streamInFlight) { + manualRetryRollbackArmedRef.current = true; + return; + } - // Update countdown display (pure display logic, no side effects) - // useResumeManager handles the actual retry logic - useEffect(() => { - if (!autoRetry) return; + const baselineMessageCount = manualRetryRollbackBaselineMessageCountRef.current; + const hasObservedPostRetryMessage = + baselineMessageCount !== null && workspaceState.messages.length > baselineMessageCount; + if (!manualRetryRollbackArmedRef.current && !hasObservedPostRetryMessage) { + return; + } + + void rollbackManualRetryAutoRetryIfNeeded(); + }, [ + autoRetryStatus, + workspaceState.isStreamStarting, + workspaceState.canInterrupt, + workspaceState.messages.length, + rollbackManualRetryAutoRetryIfNeeded, + ]); - const interval = setInterval(() => { - const delay = calculateBackoffDelay(attempt); - const nextRetryTime = retryStartTime + delay; - const timeUntilRetry = Math.max(0, nextRetryTime - Date.now()); + useEffect(() => { + if (!isAutoRetryScheduled) { + setCountdown(0); + return; + } + const updateCountdown = () => { + const retryAt = autoRetryStatus.scheduledAt + autoRetryStatus.delayMs; + const timeUntilRetry = Math.max(0, retryAt - Date.now()); setCountdown(Math.ceil(timeUntilRetry / 1000)); - }, 100); + }; + updateCountdown(); + const interval = setInterval(updateCountdown, 100); return () => clearInterval(interval); - }, [autoRetry, attempt, retryStartTime]); - - // Manual retry handler (user-initiated, immediate) - // Emits event to useResumeManager instead of calling resumeStream directly - // This keeps all retry logic centralized in one place - const handleManualRetry = () => { - const resetBackoff = !autoRetry; - enableAutoRetryPreference(props.workspaceId); // Re-enable auto-retry for next failure - - // User rationale: after explicitly stopping auto-retry, hitting Retry should - // start a fresh backoff window instead of inheriting a long prior delay. - updatePersistedState( - getRetryStateKey(props.workspaceId), - createManualRetryState(attempt, { resetBackoff }) - ); + }, [autoRetryStatus, isAutoRetryScheduled]); + + useEffect(() => { + if (isAutoRetryActive) { + setManualRetryError(null); + } + }, [isAutoRetryActive]); + const handleManualRetry = async () => { + if (!api) { + setManualRetryError("Not connected to server"); + return; + } + + if (isManualRetrying) { + return; + } + + setIsManualRetrying(true); + setManualRetryError(null); - // Emit event to useResumeManager - it will handle the actual resume - // Pass isManual flag to bypass eligibility checks (user explicitly wants to retry) - window.dispatchEvent( - createCustomEvent(CUSTOM_EVENTS.RESUME_CHECK_REQUESTED, { + try { + let options = getSendOptionsFromStorage(props.workspaceId); + const lastUserMessage = [...workspaceState.messages] + .reverse() + .find( + (message): message is Extract => message.type === "user" + ); + + if (lastUserMessage?.compactionRequest) { + options = applyCompactionOverrides(options, lastUserMessage.compactionRequest.parsed); + } + + const enableResult = await api.workspace.setAutoRetryEnabled?.({ workspaceId: props.workspaceId, - isManual: true, - }) - ); + enabled: true, + persist: false, + }); + if (enableResult && !enableResult.success) { + setManualRetryError(enableResult.error); + return; + } + + if (enableResult?.success && enableResult.data.previousEnabled === false) { + // Manual retry temporarily enables auto-retry for this resumed attempt. + // Restore only when stream/retry outcome is terminal. + manualRetryRollbackWorkspaceIdRef.current = props.workspaceId; + manualRetryRollbackPendingRef.current = true; + manualRetryRollbackArmedRef.current = false; + manualRetryRollbackBaselineMessageCountRef.current = workspaceState.messages.length; + } + + const resumeResult = await api.workspace.resumeStream({ + workspaceId: props.workspaceId, + options, + }); + + if (!resumeResult.success) { + const formatted = formatSendMessageError(resumeResult.error); + const details = formatted.resolutionHint + ? `${formatted.message} ${formatted.resolutionHint}` + : formatted.message; + setManualRetryError(details); + + // Keep preference consistent when resume fails before retry/stream events. + await rollbackManualRetryAutoRetryIfNeeded(); + return; + } + + if ( + manualRetryRollbackPendingRef.current && + !manualRetryRollbackArmedRef.current && + resumeResult.data.started === false + ) { + await rollbackManualRetryAutoRetryIfNeeded(); + } + } catch (error) { + setManualRetryError(getErrorMessage(error)); + await rollbackManualRetryAutoRetryIfNeeded(); + } finally { + setIsManualRetrying(false); + } }; - // Stop auto-retry handler const handleStopAutoRetry = () => { setCountdown(0); - disableAutoRetryPreference(props.workspaceId); - }; - - // Format error message for display (centralized logic) - const getErrorMessage = (error: typeof lastError): string => { - if (!error) return ""; - const formatted = formatSendMessageError(error); - // Combine message with a resolution hint if available - return formatted.resolutionHint - ? `${formatted.message} ${formatted.resolutionHint}` - : formatted.message; + setManualRetryError(null); + void api?.workspace.setAutoRetryEnabled?.({ workspaceId: props.workspaceId, enabled: false }); }; - const details = lastError ? ( -
- Error: {getErrorMessage(lastError)} -
- ) : null; - const barrierClassName = cn( "my-5 px-5 py-4 bg-gradient-to-br from-[rgba(255,165,0,0.1)] to-[rgba(255,140,0,0.1)] border-l-4 border-warning rounded flex flex-col gap-3", props.className ); - const lastMessage = workspaceState - ? getLastNonDecorativeMessage(workspaceState.messages) - : undefined; + const lastMessage = getLastNonDecorativeMessage(workspaceState.messages); const lastStreamError = lastMessage?.type === "stream-error" ? lastMessage : null; - const interruptionReason = lastStreamError?.errorType === "rate_limit" ? "Rate limited" : null; let statusIcon: React.ReactNode = (