diff --git a/src/browser/components/ChatPane.tsx b/src/browser/components/ChatPane.tsx
index 086ac47693..4d6ea44c1f 100644
--- a/src/browser/components/ChatPane.tsx
+++ b/src/browser/components/ChatPane.tsx
@@ -7,6 +7,7 @@ import React, {
useDeferredValue,
useMemo,
} from "react";
+import { MessageListProvider } from "./Messages/MessageListContext";
import { cn } from "@/common/lib/utils";
import { MessageRenderer } from "./Messages/MessageRenderer";
import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier";
@@ -170,6 +171,15 @@ export const ChatPane: React.FC = (props) => {
? transformedMessages
: deferredTransformedMessages;
+ const latestMessageId =
+ deferredMessages.length > 0
+ ? (deferredMessages[deferredMessages.length - 1]?.id ?? null)
+ : null;
+ const messageListContextValue = useMemo(
+ () => ({ workspaceId, latestMessageId }),
+ [workspaceId, latestMessageId]
+ );
+
const autoCompactionResult = useMemo(
() => checkAutoCompaction(workspaceUsage, pendingModel, use1M, autoCompactionThreshold / 100),
[workspaceUsage, pendingModel, use1M, autoCompactionThreshold]
@@ -448,6 +458,11 @@ export const ChatPane: React.FC = (props) => {
)
: false;
+ const lastMessage = workspaceState.messages[workspaceState.messages.length - 1];
+ const suppressRetryBarrier =
+ lastMessage?.type === "stream-error" && lastMessage.errorType === "context_exceeded";
+ const showRetryBarrierUI = showRetryBarrier && !suppressRetryBarrier;
+
// Handle keyboard shortcuts (using optional refs that are safe even if not initialized)
useAIViewKeybinds({
workspaceId,
@@ -557,79 +572,81 @@ export const ChatPane: React.FC = (props) => {
) : (
- <>
- {deferredMessages.map((msg, index) => {
- // Compute bash_output grouping at render-time
- const bashOutputGroup = computeBashOutputGroupInfo(deferredMessages, index);
-
- // For bash_output groups, use first message ID as expansion key
- const groupKey = bashOutputGroup
- ? deferredMessages[bashOutputGroup.firstIndex]?.id
- : undefined;
- const isGroupExpanded = groupKey ? expandedBashGroups.has(groupKey) : false;
-
- // Skip rendering middle items in a bash_output group (unless expanded)
- if (bashOutputGroup?.position === "middle" && !isGroupExpanded) {
- return null;
- }
-
- const isAtCutoff =
- editCutoffHistoryId !== undefined &&
- msg.type !== "history-hidden" &&
- msg.type !== "workspace-init" &&
- msg.historyId === editCutoffHistoryId;
-
- return (
-
-
-
+ <>
+ {deferredMessages.map((msg, index) => {
+ // Compute bash_output grouping at render-time
+ const bashOutputGroup = computeBashOutputGroupInfo(deferredMessages, index);
+
+ // For bash_output groups, use first message ID as expansion key
+ const groupKey = bashOutputGroup
+ ? deferredMessages[bashOutputGroup.firstIndex]?.id
+ : undefined;
+ const isGroupExpanded = groupKey ? expandedBashGroups.has(groupKey) : false;
+
+ // Skip rendering middle items in a bash_output group (unless expanded)
+ if (bashOutputGroup?.position === "middle" && !isGroupExpanded) {
+ return null;
+ }
+
+ const isAtCutoff =
+ editCutoffHistoryId !== undefined &&
+ msg.type !== "history-hidden" &&
+ msg.type !== "workspace-init" &&
+ msg.historyId === editCutoffHistoryId;
+
+ return (
+
+
-
- {/* Show collapsed indicator after the first item in a bash_output group */}
- {bashOutputGroup?.position === "first" && groupKey && (
- {
- setExpandedBashGroups((prev) => {
- const next = new Set(prev);
- if (next.has(groupKey)) {
- next.delete(groupKey);
- } else {
- next.add(groupKey);
- }
- return next;
- });
- }}
- />
- )}
- {isAtCutoff && }
- {shouldShowInterruptedBarrier(msg) && }
-
- );
- })}
- {/* Show RetryBarrier after the last message if needed */}
- {showRetryBarrier && }
- >
+ >
+
+
+ {/* Show collapsed indicator after the first item in a bash_output group */}
+ {bashOutputGroup?.position === "first" && groupKey && (
+ {
+ setExpandedBashGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupKey)) {
+ next.delete(groupKey);
+ } else {
+ next.add(groupKey);
+ }
+ return next;
+ });
+ }}
+ />
+ )}
+ {isAtCutoff && }
+ {shouldShowInterruptedBarrier(msg) && }
+
+ );
+ })}
+ {/* Show RetryBarrier after the last message if needed */}
+ {showRetryBarrierUI && }
+ >
+
)}
diff --git a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx
index 335852690b..2813dd5c4b 100644
--- a/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx
+++ b/src/browser/components/Messages/ChatBarrier/RetryBarrier.tsx
@@ -1,19 +1,7 @@
-import React, { useEffect, useMemo, useRef, useState } from "react";
-import { useAPI } from "@/browser/contexts/API";
-import { buildSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
+import React, { useEffect, useMemo, useState } from "react";
import { usePersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import type { RetryState } from "@/browser/hooks/useResumeManager";
import { useWorkspaceState } from "@/browser/stores/WorkspaceStore";
-import {
- getExplicitCompactionSuggestion,
- getHigherContextCompactionSuggestion,
- type CompactionSuggestion,
-} from "@/browser/utils/compaction/suggestion";
-import {
- buildCompactionEditText,
- formatCompactionCommandLine,
-} from "@/browser/utils/compaction/format";
-import { executeCompaction } from "@/browser/utils/chatCommands";
import {
isEligibleForAutoRetry,
isNonRetryableSendError,
@@ -21,126 +9,26 @@ import {
import { calculateBackoffDelay, createManualRetryState } from "@/browser/utils/messages/retryState";
import { KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds";
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
-import {
- getAutoRetryKey,
- getRetryStateKey,
- PREFERRED_COMPACTION_MODEL_KEY,
- VIM_ENABLED_KEY,
-} from "@/common/constants/storage";
+import { getAutoRetryKey, getRetryStateKey, VIM_ENABLED_KEY } from "@/common/constants/storage";
import { cn } from "@/common/lib/utils";
-import type { ImagePart, ProvidersConfigMap } from "@/common/orpc/types";
-import { buildContinueMessage, type DisplayedMessage } from "@/common/types/message";
import { formatSendMessageError } from "@/common/utils/errors/formatSendError";
-import { formatTokens } from "@/common/utils/tokens/tokenMeterUtils";
interface RetryBarrierProps {
workspaceId: string;
className?: string;
}
-function formatContextTokens(tokens: number): string {
- return formatTokens(tokens).replace(/\.0([kM])$/, "$1");
-}
-
-function findTriggerUserMessage(
- messages: DisplayedMessage[]
-): Extract | null {
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (msg.type === "user") {
- return msg;
- }
- }
-
- return null;
-}
const defaultRetryState: RetryState = {
attempt: 0,
retryStartTime: Date.now(),
};
-export const RetryBarrier: React.FC = ({ workspaceId, className }) => {
+export const RetryBarrier: React.FC = (props) => {
// Get workspace state for computing effective autoRetry
- const workspaceState = useWorkspaceState(workspaceId);
-
- const { api } = useAPI();
- const [isRetryingWithCompaction, setIsRetryingWithCompaction] = useState(false);
- const isMountedRef = useRef(true);
- useEffect(() => {
- return () => {
- isMountedRef.current = false;
- };
- }, []);
-
- const [providersConfig, setProvidersConfig] = useState(null);
-
- const lastMessage = workspaceState
- ? workspaceState.messages[workspaceState.messages.length - 1]
- : undefined;
-
- const isContextExceeded =
- lastMessage?.type === "stream-error" && lastMessage.errorType === "context_exceeded";
-
- // Check if we're in a compaction recovery flow: the last user message was a compaction request
- // that failed. This persists the compaction UI even if the retry fails with a different error.
- const triggerUserMessage = useMemo(() => {
- if (!workspaceState) return null;
- return findTriggerUserMessage(workspaceState.messages);
- }, [workspaceState]);
-
- const isCompactionRecoveryFlow =
- lastMessage?.type === "stream-error" && !!triggerUserMessage?.compactionRequest;
-
- // Show compaction UI if either: original context_exceeded OR we're retrying a failed compaction
- const showCompactionUI = isContextExceeded || isCompactionRecoveryFlow;
+ const workspaceState = useWorkspaceState(props.workspaceId);
- // This is a rare error state; we only need a snapshot of provider config to make a
- // best-effort suggestion (no subscriptions / real-time updates required).
- useEffect(() => {
- if (!api) return;
- if (!showCompactionUI) return;
- if (providersConfig) return;
-
- let active = true;
- void (async () => {
- try {
- const cfg = await api.providers.getConfig();
- if (active) {
- setProvidersConfig(cfg);
- }
- } catch {
- // Ignore failures fetching config (we just won't show a suggestion).
- }
- })();
-
- return () => {
- active = false;
- };
- }, [api, showCompactionUI, providersConfig]);
-
- // For compaction recovery, use the model from the original compaction request or fall back to workspace model
- const compactionTargetModel = useMemo(() => {
- if (!showCompactionUI) return null;
- // If retrying a failed compaction, use the model from that request
- if (triggerUserMessage?.compactionRequest?.parsed.model) {
- return triggerUserMessage.compactionRequest.parsed.model;
- }
- // Otherwise use the model from the error or workspace
- if (lastMessage?.type === "stream-error") {
- return lastMessage.model ?? workspaceState?.currentModel ?? null;
- }
- return workspaceState?.currentModel ?? null;
- }, [showCompactionUI, triggerUserMessage, lastMessage, workspaceState?.currentModel]);
-
- // Read preferences from localStorage
-
- const [preferredCompactionModel] = usePersistedState(
- PREFERRED_COMPACTION_MODEL_KEY,
- "", // Default to empty (use workspace model)
- { listener: true }
- );
const [autoRetry, setAutoRetry] = usePersistedState(
- getAutoRetryKey(workspaceId),
+ getAutoRetryKey(props.workspaceId),
true, // Default to true
{ listener: true }
);
@@ -154,7 +42,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa
// Use persisted state for retry tracking (survives workspace switches)
// Read retry state (managed by useResumeManager)
const [retryState] = usePersistedState(
- getRetryStateKey(workspaceId),
+ getRetryStateKey(props.workspaceId),
defaultRetryState,
{ listener: true }
);
@@ -203,160 +91,6 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa
return () => clearInterval(interval);
}, [autoRetry, attempt, retryStartTime]);
- const compactionSuggestion = useMemo(() => {
- // Opportunistic: only attempt suggestions when we can confidently identify the model.
- if (!showCompactionUI || !compactionTargetModel) {
- return null;
- }
-
- // If we're retrying a failed compaction request, prefer a larger-context model to recover.
- if (isCompactionRecoveryFlow) {
- return getHigherContextCompactionSuggestion({
- currentModel: compactionTargetModel,
- providersConfig,
- });
- }
-
- const preferred = preferredCompactionModel.trim();
- if (preferred.length > 0) {
- const explicit = getExplicitCompactionSuggestion({
- modelId: preferred,
- providersConfig,
- });
- if (explicit) {
- return explicit;
- }
- }
-
- return getHigherContextCompactionSuggestion({
- currentModel: compactionTargetModel,
- providersConfig,
- });
- }, [
- compactionTargetModel,
- showCompactionUI,
- isCompactionRecoveryFlow,
- providersConfig,
- preferredCompactionModel,
- ]);
-
- async function handleRetryWithCompaction(): Promise {
- const insertIntoChatInput = (text: string, imageParts?: ImagePart[]): void => {
- window.dispatchEvent(
- createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, {
- text,
- mode: "replace",
- imageParts,
- })
- );
- };
-
- if (!compactionSuggestion) {
- insertIntoChatInput("/compact\n");
- return;
- }
-
- const suggestedCommandLine = formatCompactionCommandLine({
- model: compactionSuggestion.modelArg,
- });
-
- if (!api) {
- insertIntoChatInput(suggestedCommandLine + "\n");
- return;
- }
-
- if (isMountedRef.current) {
- setIsRetryingWithCompaction(true);
- }
- try {
- // Read fresh values at click-time (workspace might have switched models).
- const sendMessageOptions = buildSendMessageOptions(workspaceId);
-
- // Best-effort: fall back to the nearest user message if we can't find the exact one.
- const source = triggerUserMessage;
-
- if (!source) {
- insertIntoChatInput(suggestedCommandLine + "\n");
- return;
- }
-
- if (source.compactionRequest) {
- const maxOutputTokens = source.compactionRequest.parsed.maxOutputTokens;
- const continueMessage = source.compactionRequest.parsed.continueMessage;
-
- const result = await executeCompaction({
- api,
- workspaceId,
- sendMessageOptions,
- model: compactionSuggestion.modelId,
- maxOutputTokens,
- continueMessage,
- });
-
- if (!result.success) {
- console.error("Failed to retry compaction:", result.error);
-
- const rawCommand = formatCompactionCommandLine({
- model: compactionSuggestion.modelArg,
- maxOutputTokens,
- });
-
- const fallbackText = buildCompactionEditText({
- rawCommand,
- parsed: {
- model: compactionSuggestion.modelArg,
- maxOutputTokens,
- continueMessage,
- },
- });
-
- const shouldAppendNewline =
- !continueMessage?.text || continueMessage.text.trim().length === 0;
-
- insertIntoChatInput(
- fallbackText + (shouldAppendNewline ? "\n" : ""),
- continueMessage?.imageParts
- );
- }
-
- return;
- }
-
- const continueMessage = buildContinueMessage({
- text: source.content,
- imageParts: source.imageParts,
- reviews: source.reviews,
- model: sendMessageOptions.model,
- agentId: sendMessageOptions.agentId ?? "exec",
- });
-
- if (!continueMessage) {
- insertIntoChatInput(suggestedCommandLine + "\n");
- return;
- }
-
- const result = await executeCompaction({
- api,
- workspaceId,
- sendMessageOptions,
- model: compactionSuggestion.modelId,
- continueMessage,
- });
-
- if (!result.success) {
- console.error("Failed to start compaction:", result.error);
- insertIntoChatInput(suggestedCommandLine + "\n" + source.content, source.imageParts);
- }
- } catch (error) {
- console.error("Failed to retry with compaction", error);
- insertIntoChatInput(suggestedCommandLine + "\n");
- } finally {
- if (isMountedRef.current) {
- setIsRetryingWithCompaction(false);
- }
- }
- }
-
// Manual retry handler (user-initiated, immediate)
// Emits event to useResumeManager instead of calling resumeStream directly
// This keeps all retry logic centralized in one place
@@ -365,13 +99,13 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa
// Create manual retry state: immediate retry BUT preserves attempt counter
// This prevents infinite retry loops without backoff if the retry fails
- updatePersistedState(getRetryStateKey(workspaceId), createManualRetryState(attempt));
+ updatePersistedState(getRetryStateKey(props.workspaceId), createManualRetryState(attempt));
// 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, {
- workspaceId,
+ workspaceId: props.workspaceId,
isManual: true,
})
);
@@ -393,38 +127,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa
: formatted.message;
};
- const details = showCompactionUI ? (
-
- Context window exceeded.{" "}
- {compactionSuggestion ? (
- compactionSuggestion.kind === "preferred" ? (
- <>
- We'll compact with your configured compaction model{" "}
-
- {compactionSuggestion.displayName}
-
- {compactionSuggestion.maxInputTokens !== null ? (
- <> ({formatContextTokens(compactionSuggestion.maxInputTokens)} context)>
- ) : null}{" "}
- to unblock you. Your workspace model stays the same.
- >
- ) : (
- <>
- We'll compact with{" "}
-
- {compactionSuggestion.displayName}
-
- {compactionSuggestion.maxInputTokens !== null ? (
- <> ({formatContextTokens(compactionSuggestion.maxInputTokens)} context)>
- ) : null}{" "}
- to unblock you with a higher-context model. Your workspace model stays the same.
- >
- )
- ) : (
- <>Compact this chat to unblock you. Your workspace model stays the same.>
- )}
-
- ) : lastError ? (
+ const details = lastError ? (
Error: {getErrorMessage(lastError)}
@@ -432,7 +135,7 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa
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",
- className
+ props.className
);
let statusIcon = "⚠️";
@@ -462,28 +165,12 @@ export const RetryBarrier: React.FC = ({ workspaceId, classNa
);
} else {
- const onClick = showCompactionUI ? () => void handleRetryWithCompaction() : handleManualRetry;
-
- let label = "Retry";
- if (showCompactionUI) {
- if (isRetryingWithCompaction) {
- label = "Starting...";
- } else if (!compactionSuggestion || !triggerUserMessage) {
- label = "Insert /compact";
- } else if (triggerUserMessage.compactionRequest) {
- label = "Retry compaction";
- } else {
- label = "Compact & retry";
- }
- }
-
actionButton = (
);
}
diff --git a/src/browser/components/Messages/MessageListContext.tsx b/src/browser/components/Messages/MessageListContext.tsx
new file mode 100644
index 0000000000..fad3aa3bb7
--- /dev/null
+++ b/src/browser/components/Messages/MessageListContext.tsx
@@ -0,0 +1,31 @@
+import React, { createContext, useContext } from "react";
+
+interface MessageListContextValue {
+ workspaceId: string;
+ latestMessageId: string | null;
+}
+
+const MessageListContext = createContext(null);
+
+interface MessageListProviderProps {
+ value: MessageListContextValue;
+ children: React.ReactNode;
+}
+
+export const MessageListProvider: React.FC = (props) => {
+ return (
+ {props.children}
+ );
+};
+
+export function useOptionalMessageListContext(): MessageListContextValue | null {
+ return useContext(MessageListContext);
+}
+
+export function useMessageListContext(): MessageListContextValue {
+ const context = useContext(MessageListContext);
+ if (!context) {
+ throw new Error("useMessageListContext must be used within MessageListProvider");
+ }
+ return context;
+}
diff --git a/src/browser/components/Messages/StreamErrorMessage.tsx b/src/browser/components/Messages/StreamErrorMessage.tsx
index 1709a13eae..8109c17f67 100644
--- a/src/browser/components/Messages/StreamErrorMessage.tsx
+++ b/src/browser/components/Messages/StreamErrorMessage.tsx
@@ -2,17 +2,33 @@ import React from "react";
import { Bug } from "lucide-react";
import { Button } from "@/browser/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/ui/tooltip";
+import { useCompactAndRetry } from "@/browser/hooks/useCompactAndRetry";
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
import { cn } from "@/common/lib/utils";
import type { DisplayedMessage } from "@/common/types/message";
+import { formatTokens } from "@/common/utils/tokens/tokenMeterUtils";
+import { useOptionalMessageListContext } from "./MessageListContext";
interface StreamErrorMessageProps {
message: DisplayedMessage & { type: "stream-error" };
className?: string;
}
-// Note: RetryBarrier handles retry actions. This component only displays the error.
-export const StreamErrorMessage: React.FC = ({ message, className }) => {
+interface StreamErrorMessageBaseProps extends StreamErrorMessageProps {
+ compactRetryAction?: React.ReactNode;
+ compactionDetails?: React.ReactNode;
+}
+
+function formatContextTokens(tokens: number): string {
+ return formatTokens(tokens).replace(/\.0([kM])$/, "$1");
+}
+
+const StreamErrorMessageBase: React.FC = (props) => {
+ const message = props.message;
+ const className = props.className;
+ const compactRetryAction = props.compactRetryAction;
+ const compactionDetails = props.compactionDetails;
+
const debugAction = (
@@ -79,6 +95,107 @@ export const StreamErrorMessage: React.FC = ({ message,
{message.error}
+ {compactionDetails}
+ {compactRetryAction ? (
+ {compactRetryAction}
+ ) : null}
+
+ );
+};
+
+interface StreamErrorMessageWithRetryProps extends StreamErrorMessageProps {
+ workspaceId: string;
+}
+
+const StreamErrorMessageWithRetry: React.FC = (props) => {
+ const compactAndRetry = useCompactAndRetry({ workspaceId: props.workspaceId });
+ const showCompactRetry = compactAndRetry.showCompactionUI;
+
+ let compactRetryLabel = "Compact & retry";
+ if (showCompactRetry) {
+ if (compactAndRetry.isRetryingWithCompaction) {
+ compactRetryLabel = "Starting...";
+ } else if (!compactAndRetry.compactionSuggestion || !compactAndRetry.hasTriggerUserMessage) {
+ compactRetryLabel = "Insert /compact";
+ } else if (compactAndRetry.hasCompactionRequest) {
+ compactRetryLabel = "Retry compaction";
+ }
+ }
+
+ const compactRetryAction = showCompactRetry ? (
+
+ ) : null;
+
+ const compactionSuggestion = compactAndRetry.compactionSuggestion;
+ const compactionDetails = showCompactRetry ? (
+
+ Context window exceeded.{" "}
+ {compactionSuggestion ? (
+ compactionSuggestion.kind === "preferred" ? (
+ <>
+ We'll compact with your configured compaction model{" "}
+
+ {compactionSuggestion.displayName}
+
+ {compactionSuggestion.maxInputTokens !== null ? (
+ <> ({formatContextTokens(compactionSuggestion.maxInputTokens)} context)>
+ ) : null}{" "}
+ to unblock you. Your workspace model stays the same.
+ >
+ ) : (
+ <>
+ We'll compact with{" "}
+
+ {compactionSuggestion.displayName}
+
+ {compactionSuggestion.maxInputTokens !== null ? (
+ <> ({formatContextTokens(compactionSuggestion.maxInputTokens)} context)>
+ ) : null}{" "}
+ to unblock you with a higher-context model. Your workspace model stays the same.
+ >
+ )
+ ) : (
+ <>Compact this chat to unblock you. Your workspace model stays the same.>
+ )}
+ ) : null;
+
+ return (
+
+ );
+};
+
+// RetryBarrier handles auto-retry; compaction retry UI lives here for stream errors.
+export const StreamErrorMessage: React.FC = (props) => {
+ const messageListContext = useOptionalMessageListContext();
+ const latestMessageId = messageListContext?.latestMessageId ?? null;
+ const workspaceId = messageListContext?.workspaceId ?? null;
+ const isLatestMessage = latestMessageId === props.message.id;
+
+ if (!workspaceId || !isLatestMessage) {
+ return ;
+ }
+
+ return (
+
);
};
diff --git a/src/browser/hooks/useCompactAndRetry.ts b/src/browser/hooks/useCompactAndRetry.ts
new file mode 100644
index 0000000000..154f4be76a
--- /dev/null
+++ b/src/browser/hooks/useCompactAndRetry.ts
@@ -0,0 +1,270 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useAPI } from "@/browser/contexts/API";
+import { buildSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
+import { usePersistedState } from "@/browser/hooks/usePersistedState";
+import { useWorkspaceState } from "@/browser/stores/WorkspaceStore";
+import {
+ buildCompactionEditText,
+ formatCompactionCommandLine,
+ getCompactionContinueText,
+} from "@/browser/utils/compaction/format";
+import {
+ getExplicitCompactionSuggestion,
+ getHigherContextCompactionSuggestion,
+ type CompactionSuggestion,
+} from "@/browser/utils/compaction/suggestion";
+import { executeCompaction } from "@/browser/utils/chatCommands";
+import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
+import { PREFERRED_COMPACTION_MODEL_KEY } from "@/common/constants/storage";
+import type { ImagePart, ProvidersConfigMap } from "@/common/orpc/types";
+import { buildContinueMessage, type DisplayedMessage } from "@/common/types/message";
+
+interface CompactAndRetryState {
+ showCompactionUI: boolean;
+ compactionSuggestion: CompactionSuggestion | null;
+ isRetryingWithCompaction: boolean;
+ hasTriggerUserMessage: boolean;
+ hasCompactionRequest: boolean;
+ retryWithCompaction: () => Promise;
+}
+
+function findTriggerUserMessage(
+ messages: DisplayedMessage[]
+): Extract | null {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i];
+ if (msg.type === "user") {
+ return msg;
+ }
+ }
+
+ return null;
+}
+
+export function useCompactAndRetry(props: { workspaceId: string }): CompactAndRetryState {
+ const workspaceState = useWorkspaceState(props.workspaceId);
+ const { api } = useAPI();
+ const [providersConfig, setProvidersConfig] = useState(null);
+ const [isRetryingWithCompaction, setIsRetryingWithCompaction] = useState(false);
+ const isMountedRef = useRef(true);
+
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ const lastMessage = workspaceState
+ ? workspaceState.messages[workspaceState.messages.length - 1]
+ : undefined;
+
+ const triggerUserMessage = useMemo(() => {
+ if (!workspaceState) return null;
+ return findTriggerUserMessage(workspaceState.messages);
+ }, [workspaceState]);
+
+ const isCompactionRecoveryFlow =
+ lastMessage?.type === "stream-error" && !!triggerUserMessage?.compactionRequest;
+
+ const isContextExceeded =
+ lastMessage?.type === "stream-error" && lastMessage.errorType === "context_exceeded";
+
+ const showCompactionUI = isContextExceeded || isCompactionRecoveryFlow;
+
+ const [preferredCompactionModel] = usePersistedState(PREFERRED_COMPACTION_MODEL_KEY, "", {
+ listener: true,
+ });
+
+ useEffect(() => {
+ if (!api) return;
+ if (!showCompactionUI) return;
+ if (providersConfig) return;
+
+ let active = true;
+ const fetchProvidersConfig = async () => {
+ try {
+ const cfg = await api.providers.getConfig();
+ if (active) {
+ setProvidersConfig(cfg);
+ }
+ } catch {
+ // Ignore failures fetching config (we just won't show a suggestion).
+ }
+ };
+
+ fetchProvidersConfig().catch(() => undefined);
+
+ return () => {
+ active = false;
+ };
+ }, [api, showCompactionUI, providersConfig]);
+
+ const compactionTargetModel = useMemo(() => {
+ if (!showCompactionUI) return null;
+ if (triggerUserMessage?.compactionRequest?.parsed.model) {
+ return triggerUserMessage.compactionRequest.parsed.model;
+ }
+ if (lastMessage?.type === "stream-error") {
+ return lastMessage.model ?? workspaceState?.currentModel ?? null;
+ }
+ return workspaceState?.currentModel ?? null;
+ }, [showCompactionUI, triggerUserMessage, lastMessage, workspaceState?.currentModel]);
+
+ const compactionSuggestion = useMemo(() => {
+ if (!showCompactionUI || !compactionTargetModel) {
+ return null;
+ }
+
+ if (isCompactionRecoveryFlow) {
+ return getHigherContextCompactionSuggestion({
+ currentModel: compactionTargetModel,
+ providersConfig,
+ });
+ }
+
+ const preferred = preferredCompactionModel.trim();
+ if (preferred.length > 0) {
+ const explicit = getExplicitCompactionSuggestion({
+ modelId: preferred,
+ providersConfig,
+ });
+ if (explicit) {
+ return explicit;
+ }
+ }
+
+ return getHigherContextCompactionSuggestion({
+ currentModel: compactionTargetModel,
+ providersConfig,
+ });
+ }, [
+ compactionTargetModel,
+ showCompactionUI,
+ isCompactionRecoveryFlow,
+ providersConfig,
+ preferredCompactionModel,
+ ]);
+
+ const retryWithCompaction = useCallback(async (): Promise => {
+ const insertIntoChatInput = (text: string, imageParts?: ImagePart[]): void => {
+ window.dispatchEvent(
+ createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, {
+ text,
+ mode: "replace",
+ imageParts,
+ })
+ );
+ };
+
+ if (!compactionSuggestion) {
+ insertIntoChatInput("/compact\n");
+ return;
+ }
+
+ const suggestedCommandLine = formatCompactionCommandLine({
+ model: compactionSuggestion.modelArg,
+ });
+
+ if (!api) {
+ insertIntoChatInput(suggestedCommandLine + "\n");
+ return;
+ }
+
+ if (isMountedRef.current) {
+ setIsRetryingWithCompaction(true);
+ }
+ try {
+ const sendMessageOptions = buildSendMessageOptions(props.workspaceId);
+ const source = triggerUserMessage;
+
+ if (!source) {
+ insertIntoChatInput(suggestedCommandLine + "\n");
+ return;
+ }
+
+ if (source.compactionRequest) {
+ const maxOutputTokens = source.compactionRequest.parsed.maxOutputTokens;
+ const continueMessage = source.compactionRequest.parsed.continueMessage;
+ const result = await executeCompaction({
+ api,
+ workspaceId: props.workspaceId,
+ sendMessageOptions,
+ model: compactionSuggestion.modelId,
+ maxOutputTokens,
+ continueMessage,
+ editMessageId: source.id,
+ });
+
+ if (!result.success) {
+ console.error("Failed to retry compaction:", result.error);
+
+ const rawCommand = formatCompactionCommandLine({
+ model: compactionSuggestion.modelArg,
+ maxOutputTokens,
+ });
+
+ const fallbackText = buildCompactionEditText({
+ rawCommand,
+ parsed: {
+ model: compactionSuggestion.modelArg,
+ maxOutputTokens,
+ continueMessage,
+ },
+ });
+
+ const shouldAppendNewline = !getCompactionContinueText(continueMessage);
+
+ insertIntoChatInput(
+ fallbackText + (shouldAppendNewline ? "\n" : ""),
+ continueMessage?.imageParts
+ );
+ }
+
+ return;
+ }
+
+ const continueMessage = buildContinueMessage({
+ text: source.content,
+ imageParts: source.imageParts,
+ reviews: source.reviews,
+ model: sendMessageOptions.model,
+ agentId: sendMessageOptions.agentId ?? "exec",
+ });
+
+ if (!continueMessage) {
+ insertIntoChatInput(suggestedCommandLine + "\n");
+ return;
+ }
+
+ const result = await executeCompaction({
+ api,
+ workspaceId: props.workspaceId,
+ sendMessageOptions,
+ model: compactionSuggestion.modelId,
+ continueMessage,
+ editMessageId: source.id,
+ });
+
+ if (!result.success) {
+ console.error("Failed to start compaction:", result.error);
+ insertIntoChatInput(suggestedCommandLine + "\n" + source.content, source.imageParts);
+ }
+ } catch (error) {
+ console.error("Failed to retry with compaction", error);
+ insertIntoChatInput(suggestedCommandLine + "\n");
+ } finally {
+ if (isMountedRef.current) {
+ setIsRetryingWithCompaction(false);
+ }
+ }
+ }, [api, compactionSuggestion, props.workspaceId, triggerUserMessage]);
+
+ return {
+ showCompactionUI,
+ compactionSuggestion,
+ isRetryingWithCompaction,
+ hasTriggerUserMessage: !!triggerUserMessage,
+ hasCompactionRequest: !!triggerUserMessage?.compactionRequest,
+ retryWithCompaction,
+ };
+}
diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts
index 9173777861..f59a99ee09 100644
--- a/src/browser/utils/chatCommands.ts
+++ b/src/browser/utils/chatCommands.ts
@@ -14,6 +14,7 @@ import {
type CompactionRequestData,
type ContinueMessage,
buildContinueMessage,
+ isDefaultContinueMessage,
} from "@/common/types/message";
import type { ReviewNoteData } from "@/common/types/review";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
@@ -647,8 +648,7 @@ export function prepareCompactionMessage(options: CompactionOptions): {
// misread it as a competing instruction. We still keep it in metadata so the backend resumes.
// Only treat it as the default resume when there's no other queued content (images/reviews).
const cm = options.continueMessage;
- const isDefaultResume =
- cm?.text?.trim() === "Continue" && !cm?.imageParts?.length && !cm?.reviews?.length;
+ const isDefaultResume = isDefaultContinueMessage(cm);
if (cm && !isDefaultResume) {
messageText += `\n\nThe user wants to continue with: ${cm.text}`;
diff --git a/src/browser/utils/compaction/format.ts b/src/browser/utils/compaction/format.ts
index a33d9d53df..f9d400f8c1 100644
--- a/src/browser/utils/compaction/format.ts
+++ b/src/browser/utils/compaction/format.ts
@@ -1,4 +1,5 @@
import type { CompactionRequestData } from "@/common/types/message";
+import { isDefaultContinueMessage } from "@/common/types/message";
/**
* Format compaction command *line* for display.
@@ -20,6 +21,22 @@ export function formatCompactionCommandLine(options: {
return cmd;
}
+/**
+ * Return the visible continue text for a compaction request.
+ * Hides the default resume sentinel ("Continue") and empty text.
+ */
+export function getCompactionContinueText(
+ continueMessage?: CompactionRequestData["continueMessage"]
+): string | null {
+ if (!continueMessage) return null;
+ if (isDefaultContinueMessage(continueMessage)) return null;
+ const continueText = continueMessage.text;
+ if (typeof continueText !== "string" || continueText.trim().length === 0) {
+ return null;
+ }
+ return continueText;
+}
+
/**
* Build the text shown in the editor when editing a /compact request.
*
@@ -30,9 +47,24 @@ export function buildCompactionEditText(request: {
rawCommand: string;
parsed: CompactionRequestData;
}): string {
- const continueText = request.parsed.continueMessage?.text;
- if (typeof continueText === "string" && continueText.trim().length > 0) {
+ const continueText = getCompactionContinueText(request.parsed.continueMessage);
+ if (continueText) {
return `${request.rawCommand}\n${continueText}`;
}
return request.rawCommand;
}
+
+/**
+ * Build the text shown in user message bubbles for a /compact request.
+ * Uses a hard line break so the command and payload render on separate lines.
+ */
+export function buildCompactionDisplayText(request: {
+ rawCommand: string;
+ parsed: CompactionRequestData;
+}): string {
+ const continueText = getCompactionContinueText(request.parsed.continueMessage);
+ if (continueText) {
+ return `${request.rawCommand} \n${continueText}`;
+ }
+ return request.rawCommand;
+}
diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts
index 32fa2a136c..137bbb5cde 100644
--- a/src/browser/utils/messages/StreamingMessageAggregator.ts
+++ b/src/browser/utils/messages/StreamingMessageAggregator.ts
@@ -7,6 +7,7 @@ import type {
MuxFrontendMetadata,
} from "@/common/types/message";
import { createMuxMessage } from "@/common/types/message";
+import { buildCompactionDisplayText } from "@/browser/utils/compaction/format";
import type {
StreamStartEvent,
StreamDeltaEvent,
@@ -1739,10 +1740,6 @@ export class StreamingMessageAggregator {
}
: undefined;
- const userDisplayText =
- compactionRequest?.rawCommand ??
- (muxMeta?.type === "agent-skill" ? muxMeta.rawCommand : content);
-
// Extract reviews from muxMetadata for rich UI display (orthogonal to message type)
const reviews = muxMeta?.reviews;
@@ -1750,7 +1747,7 @@ export class StreamingMessageAggregator {
type: "user",
id: message.id,
historyId: message.id,
- content: userDisplayText,
+ content: compactionRequest ? buildCompactionDisplayText(compactionRequest) : content,
imageParts: imageParts.length > 0 ? imageParts : undefined,
historySequence,
isSynthetic: message.metadata?.synthetic === true ? true : undefined,
diff --git a/src/common/types/message.ts b/src/common/types/message.ts
index da8caee14d..6e573b9a67 100644
--- a/src/common/types/message.ts
+++ b/src/common/types/message.ts
@@ -98,6 +98,18 @@ export type PersistedContinueMessage =
mode?: "exec" | "plan";
};
+/**
+ * True when the continue message is the default resume sentinel ("Continue")
+ * with no attachments.
+ */
+export function isDefaultContinueMessage(message?: Partial): boolean {
+ if (!message) return false;
+ const text = typeof message.text === "string" ? message.text.trim() : "";
+ const hasImages = (message.imageParts?.length ?? 0) > 0;
+ const hasReviews = (message.reviews?.length ?? 0) > 0;
+ return text === "Continue" && !hasImages && !hasReviews;
+}
+
/**
* Rebuild a ContinueMessage from persisted data.
* Use this when reading from storage/history where the data may have been
diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts
index 5f224ac508..08094b627b 100644
--- a/src/node/services/agentSession.ts
+++ b/src/node/services/agentSession.ts
@@ -656,6 +656,7 @@ export class AgentSession {
"exec";
// Build options for the queued message (strip compaction-specific fields)
// agentId determines tool policy via resolveToolPolicyForAgent in aiService
+
const sanitizedOptions: Omit<
SendMessageOptions,
"muxMetadata" | "mode" | "editMessageId" | "imageParts" | "maxOutputTokens"
@@ -680,8 +681,14 @@ export class AgentSession {
sanitizedOptions.muxMetadata = metadata;
}
- this.messageQueue.add(finalText, sanitizedOptions);
- this.emitQueuedMessageChanged();
+ const dedupeKey = JSON.stringify({
+ text: finalText.trim(),
+ images: (continueImageParts ?? []).map((image) => `${image.mediaType}:${image.url}`),
+ });
+
+ if (this.messageQueue.addOnce(finalText, sanitizedOptions, dedupeKey)) {
+ this.emitQueuedMessageChanged();
+ }
}
if (this.disposed) {
@@ -925,12 +932,17 @@ export class AgentSession {
error: string;
errorType?: string;
}): Promise {
+ const hadCompactionRequest = this.activeCompactionRequest !== undefined;
if (await this.maybeRetryCompactionOnContextExceeded(data)) {
return;
}
this.activeCompactionRequest = undefined;
+ if (hadCompactionRequest && !this.disposed) {
+ this.clearQueue();
+ }
+
const streamError: StreamErrorMessage = {
type: "stream-error",
messageId: data.messageId,
@@ -984,7 +996,11 @@ export class AgentSession {
forward("reasoning-end", (payload) => this.emitChatEvent(payload));
forward("usage-delta", (payload) => this.emitChatEvent(payload));
forward("stream-abort", (payload) => {
+ const hadCompactionRequest = this.activeCompactionRequest !== undefined;
this.activeCompactionRequest = undefined;
+ if (hadCompactionRequest && !this.disposed) {
+ this.clearQueue();
+ }
this.emitChatEvent(payload);
});
forward("runtime-status", (payload) => this.emitChatEvent(payload));
@@ -992,6 +1008,7 @@ export class AgentSession {
forward("stream-end", async (payload) => {
this.activeCompactionRequest = undefined;
const handled = await this.compactionHandler.handleCompletion(payload as StreamEndEvent);
+
if (!handled) {
this.emitChatEvent(payload);
} else {
@@ -999,6 +1016,7 @@ export class AgentSession {
// This allows the frontend to get updated postCompaction state
this.onCompactionComplete?.();
}
+
// Stream end: auto-send queued messages
this.sendQueuedMessages();
});
diff --git a/src/node/services/compactionHandler.ts b/src/node/services/compactionHandler.ts
index 57c276e114..51021836e3 100644
--- a/src/node/services/compactionHandler.ts
+++ b/src/node/services/compactionHandler.ts
@@ -68,6 +68,7 @@ export class CompactionHandler {
private readonly telemetryService?: TelemetryService;
private readonly emitter: EventEmitter;
private readonly processedCompactionRequestIds: Set = new Set();
+
private readonly onCompactionComplete?: () => void;
/** Flag indicating post-compaction attachments should be generated on next turn */
@@ -125,7 +126,8 @@ export class CompactionHandler {
const messages = historyResult.data;
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
- const isCompaction = lastUserMsg?.metadata?.muxMetadata?.type === "compaction-request";
+ const muxMeta = lastUserMsg?.metadata?.muxMetadata;
+ const isCompaction = muxMeta?.type === "compaction-request";
if (!isCompaction || !lastUserMsg) {
return false;
@@ -175,10 +177,8 @@ export class CompactionHandler {
}
// Check if this was an idle-compaction (auto-triggered due to inactivity)
- const muxMeta = lastUserMsg.metadata?.muxMetadata;
const isIdleCompaction =
muxMeta?.type === "compaction-request" && muxMeta.source === "idle-compaction";
-
// Mark as processed before performing compaction
this.processedCompactionRequestIds.add(lastUserMsg.id);
diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts
index 6487ddbc72..257dbc1dbb 100644
--- a/src/node/services/messageQueue.test.ts
+++ b/src/node/services/messageQueue.test.ts
@@ -159,6 +159,27 @@ describe("MessageQueue", () => {
});
});
+ describe("addOnce", () => {
+ it("should dedupe repeated entries by key", () => {
+ const image = { url: "data:image/png;base64,abc", mediaType: "image/png" };
+ const addedFirst = queue.addOnce(
+ "Follow up",
+ { model: "gpt-4", imageParts: [image] },
+ "follow-up"
+ );
+ const addedSecond = queue.addOnce(
+ "Follow up",
+ { model: "gpt-4", imageParts: [image] },
+ "follow-up"
+ );
+
+ expect(addedFirst).toBe(true);
+ expect(addedSecond).toBe(false);
+ expect(queue.getMessages()).toEqual(["Follow up"]);
+ expect(queue.getImageParts()).toEqual([image]);
+ });
+ });
+
describe("multi-message batching", () => {
it("should batch multiple follow-up messages", () => {
queue.add("First message");
diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts
index 22ff7aeb87..f1e82d1484 100644
--- a/src/node/services/messageQueue.ts
+++ b/src/node/services/messageQueue.ts
@@ -66,6 +66,7 @@ export class MessageQueue {
private firstMuxMetadata?: unknown;
private latestOptions?: SendMessageOptions;
private accumulatedImages: ImagePart[] = [];
+ private dedupeKeys: Set = new Set();
/**
* Check if the queue currently contains a compaction request.
@@ -82,12 +83,39 @@ export class MessageQueue {
* @throws Error if trying to add a compaction request when queue already has messages
*/
add(message: string, options?: SendMessageOptions & { imageParts?: ImagePart[] }): void {
+ this.addInternal(message, options);
+ }
+
+ /**
+ * Add a message to the queue once, keyed by dedupeKey.
+ * Returns true if the message was queued.
+ */
+ addOnce(
+ message: string,
+ options?: SendMessageOptions & { imageParts?: ImagePart[] },
+ dedupeKey?: string
+ ): boolean {
+ if (dedupeKey !== undefined && this.dedupeKeys.has(dedupeKey)) {
+ return false;
+ }
+
+ const didAdd = this.addInternal(message, options);
+ if (didAdd && dedupeKey !== undefined) {
+ this.dedupeKeys.add(dedupeKey);
+ }
+ return didAdd;
+ }
+
+ private addInternal(
+ message: string,
+ options?: SendMessageOptions & { imageParts?: ImagePart[] }
+ ): boolean {
const trimmedMessage = message.trim();
const hasImages = options?.imageParts && options.imageParts.length > 0;
// Reject if both text and images are empty
if (trimmedMessage.length === 0 && !hasImages) {
- return;
+ return false;
}
const incomingIsCompaction = isCompactionMetadata(options?.muxMetadata);
@@ -139,6 +167,8 @@ export class MessageQueue {
this.accumulatedImages.push(...imageParts);
}
}
+
+ return true;
}
/**
@@ -218,6 +248,7 @@ export class MessageQueue {
this.firstMuxMetadata = undefined;
this.latestOptions = undefined;
this.accumulatedImages = [];
+ this.dedupeKeys.clear();
}
/**
diff --git a/tests/ui/contextExceededCompactionSuggestion.integration.test.ts b/tests/ui/contextExceededCompactionSuggestion.integration.test.ts
index f65c026220..a45fc0bd44 100644
--- a/tests/ui/contextExceededCompactionSuggestion.integration.test.ts
+++ b/tests/ui/contextExceededCompactionSuggestion.integration.test.ts
@@ -81,8 +81,8 @@ describeIntegration("Context exceeded compaction suggestion (UI)", () => {
// And we should render an action button for one-click compaction + retry.
await waitFor(
() => {
- const button = view.queryByRole("button", { name: "Compact & retry" });
- if (!button) {
+ const buttons = view.queryAllByRole("button", { name: "Compact & retry" });
+ if (buttons.length === 0) {
throw new Error("Expected Compact & retry button");
}
},
@@ -105,7 +105,7 @@ describeIntegration("Context exceeded compaction suggestion (UI)", () => {
// Clicking the CTA should actually send a compaction request message.
// We assert on the rendered /compact command (from muxMetadata.rawCommand).
- const button = view.getByRole("button", { name: "Compact & retry" });
+ const [button] = view.getAllByRole("button", { name: "Compact & retry" });
if (view.container.textContent?.includes(expectedCompactionCommand)) {
throw new Error("Compaction command should not be present before clicking");
}
@@ -176,8 +176,8 @@ describeIntegration("Context exceeded compaction suggestion (UI)", () => {
// And we should render an action button for one-click compaction + retry.
await waitFor(
() => {
- const button = view.queryByRole("button", { name: "Compact & retry" });
- if (!button) {
+ const buttons = view.queryAllByRole("button", { name: "Compact & retry" });
+ if (buttons.length === 0) {
throw new Error("Expected Compact & retry button");
}
},
@@ -201,7 +201,7 @@ describeIntegration("Context exceeded compaction suggestion (UI)", () => {
// Clicking the CTA should actually send a compaction request message.
// We assert on the rendered /compact command (from muxMetadata.rawCommand).
- const button = view.getByRole("button", { name: "Compact & retry" });
+ const [button] = view.getAllByRole("button", { name: "Compact & retry" });
if (view.container.textContent?.includes(expectedCompactionCommand)) {
throw new Error("Compaction command should not be present before clicking");
}