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"); }