From 20299007b4787c9a047daf661ff881e84d5901d3 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 21 Nov 2025 12:56:35 +1100 Subject: [PATCH 01/20] feat: add auto-compaction with progressive warnings --- src/browser/components/AIView.tsx | 25 +++- src/browser/components/ChatInput/index.tsx | 123 +++++++++++++----- src/browser/components/ChatInput/types.ts | 2 + src/browser/components/CompactionWarning.tsx | 36 +++++ src/browser/hooks/useResumeManager.ts | 5 +- src/browser/stores/WorkspaceStore.ts | 33 +++-- src/browser/utils/chatCommands.ts | 23 +++- .../utils/compaction/autoCompactionCheck.ts | 87 +++++++++++++ .../messages/StreamingMessageAggregator.ts | 5 +- src/common/types/message.ts | 8 +- src/node/services/agentSession.ts | 3 +- 11 files changed, 294 insertions(+), 56 deletions(-) create mode 100644 src/browser/components/CompactionWarning.tsx create mode 100644 src/browser/utils/compaction/autoCompactionCheck.ts diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 219ad9b3a..314d69047 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -23,7 +23,11 @@ import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useAutoScroll } from "@/browser/hooks/useAutoScroll"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { useThinking } from "@/browser/contexts/ThinkingContext"; -import { useWorkspaceState, useWorkspaceAggregator } from "@/browser/stores/WorkspaceStore"; +import { + useWorkspaceState, + useWorkspaceAggregator, + useWorkspaceUsage, +} from "@/browser/stores/WorkspaceStore"; import { WorkspaceHeader } from "./WorkspaceHeader"; import { getModelName } from "@/common/utils/ai/models"; import type { DisplayedMessage } from "@/common/types/message"; @@ -31,6 +35,9 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds"; import { evictModelFromLRU } from "@/browser/hooks/useModelLRU"; import { QueuedMessage } from "./Messages/QueuedMessage"; +import { CompactionWarning } from "./CompactionWarning"; +import { shouldAutoCompact } from "@/browser/utils/compaction/autoCompactionCheck"; +import { use1MContext } from "@/browser/hooks/use1MContext"; interface AIViewProps { workspaceId: string; @@ -74,6 +81,8 @@ const AIViewInner: React.FC = ({ const workspaceState = useWorkspaceState(workspaceId); const aggregator = useWorkspaceAggregator(workspaceId); + const workspaceUsage = useWorkspaceUsage(workspaceId); + const [use1M] = use1MContext(); const handledModelErrorsRef = useRef>(new Set()); useEffect(() => { @@ -318,6 +327,13 @@ const AIViewInner: React.FC = ({ // Get active stream message ID for token counting const activeStreamMessageId = aggregator.getActiveStreamMessageId(); + const autoCompactionCheck = currentModel + ? shouldAutoCompact(workspaceUsage, currentModel, use1M) + : { shouldShowWarning: false, usagePercentage: 0, thresholdPercentage: 70 }; + + // Show warning when: shouldShowWarning flag is true AND not currently compacting + const shouldShowCompactionWarning = !isCompacting && autoCompactionCheck.shouldShowWarning; + // Note: We intentionally do NOT reset autoRetry when streams start. // If user pressed the interrupt key, autoRetry stays false until they manually retry. // This makes state transitions explicit and predictable. @@ -503,6 +519,12 @@ const AIViewInner: React.FC = ({ )} + {shouldShowCompactionWarning && ( + + )} = ({ onEditLastUserMessage={() => void handleEditLastUserMessage()} canInterrupt={canInterrupt} onReady={handleChatInputReady} + autoCompactionCheck={autoCompactionCheck} /> diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index a82205f15..507520c8a 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -30,6 +30,7 @@ import { handleCompactCommand, forkWorkspace, prepareCompactionMessage, + executeCompaction, type CommandHandlerContext, } from "@/browser/utils/chatCommands"; import { CUSTOM_EVENTS } from "@/common/constants/events"; @@ -472,6 +473,32 @@ export const ChatInput: React.FC = (props) => { // Workspace variant: full command handling + message send if (variant !== "workspace") return; // Type guard + // Prepare image parts if any + const imageParts = imageAttachments.map((img, index) => { + // Validate before sending to help with debugging + if (!img.url || typeof img.url !== "string") { + console.error( + `Image attachment [${index}] has invalid url:`, + typeof img.url, + img.url?.slice(0, 50) + ); + } + if (!img.url?.startsWith("data:")) { + console.error(`Image attachment [${index}] url is not a data URL:`, img.url?.slice(0, 100)); + } + if (!img.mediaType || typeof img.mediaType !== "string") { + console.error( + `Image attachment [${index}] has invalid mediaType:`, + typeof img.mediaType, + img.mediaType + ); + } + return { + url: img.url, + mediaType: img.mediaType, + }; + }); + try { // Parse command const parsed = parseCommand(messageText); @@ -571,8 +598,10 @@ export const ChatInput: React.FC = (props) => { const context: CommandHandlerContext = { workspaceId: props.workspaceId, sendMessageOptions, + imageParts, editMessageId: editingMessage?.id, setInput, + setImageAttachments, setIsSending, setToast, onCancelEdit: props.onCancelEdit, @@ -636,7 +665,9 @@ export const ChatInput: React.FC = (props) => { const context: CommandHandlerContext = { workspaceId: props.workspaceId, sendMessageOptions, + imageParts: undefined, // /new doesn't use images setInput, + setImageAttachments, setIsSending, setToast, }; @@ -656,42 +687,70 @@ export const ChatInput: React.FC = (props) => { } } - // Regular message - send directly via API - setIsSending(true); - // Save current state for restoration on error const previousImageAttachments = [...imageAttachments]; - try { - // Prepare image parts if any - const imageParts = imageAttachments.map((img, index) => { - // Validate before sending to help with debugging - if (!img.url || typeof img.url !== "string") { - console.error( - `Image attachment [${index}] has invalid url:`, - typeof img.url, - img.url?.slice(0, 50) - ); - } - if (!img.url?.startsWith("data:")) { - console.error( - `Image attachment [${index}] url is not a data URL:`, - img.url?.slice(0, 100) - ); - } - if (!img.mediaType || typeof img.mediaType !== "string") { - console.error( - `Image attachment [${index}] has invalid mediaType:`, - typeof img.mediaType, - img.mediaType - ); + // 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 + const shouldAutoCompact = + props.autoCompactionCheck && + props.autoCompactionCheck.usagePercentage >= props.autoCompactionCheck.thresholdPercentage; + if (variant === "workspace" && !editingMessage && shouldAutoCompact) { + // Clear input immediately for responsive UX + setInput(""); + setImageAttachments([]); + setIsSending(true); + + try { + const result = await executeCompaction({ + workspaceId: props.workspaceId, + continueMessage: { + text: messageText, + imageParts, + }, + sendMessageOptions, + }); + + if (!result.success) { + // Restore on error + setInput(messageText); + setImageAttachments(previousImageAttachments); + setToast({ + id: Date.now().toString(), + type: "error", + title: "Auto-Compaction Failed", + message: result.error ?? "Failed to start auto-compaction", + }); + } else { + setToast({ + id: Date.now().toString(), + type: "success", + message: `Context threshold reached - auto-compacting...`, + }); } - return { - url: img.url, - mediaType: img.mediaType, - }; - }); + } catch (error) { + // Restore on unexpected error + setInput(messageText); + setImageAttachments(previousImageAttachments); + setToast({ + id: Date.now().toString(), + type: "error", + title: "Auto-Compaction Failed", + message: + error instanceof Error ? error.message : "Unexpected error during auto-compaction", + }); + } finally { + setIsSending(false); + } + return; // Skip normal send + } + + // Regular message - send directly via API + setIsSending(true); + + try { // When editing a /compact command, regenerate the actual summarization request let actualMessageText = messageText; let muxMetadata: MuxFrontendMetadata | undefined; @@ -707,7 +766,7 @@ export const ChatInput: React.FC = (props) => { } = prepareCompactionMessage({ workspaceId: props.workspaceId, maxOutputTokens: parsed.maxOutputTokens, - continueMessage: parsed.continueMessage, + continueMessage: { text: parsed.continueMessage ?? "", imageParts }, model: parsed.model, sendMessageOptions, }); diff --git a/src/browser/components/ChatInput/types.ts b/src/browser/components/ChatInput/types.ts index 25f7979c9..324c6e12d 100644 --- a/src/browser/components/ChatInput/types.ts +++ b/src/browser/components/ChatInput/types.ts @@ -1,5 +1,6 @@ import type { ImagePart } from "@/common/types/ipc"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { AutoCompactionCheckResult } from "@/browser/utils/compaction/autoCompactionCheck"; export interface ChatInputAPI { focus: () => void; @@ -23,6 +24,7 @@ export interface ChatInputWorkspaceVariant { canInterrupt?: boolean; disabled?: boolean; onReady?: (api: ChatInputAPI) => void; + autoCompactionCheck?: AutoCompactionCheckResult; // Computed in parent (AIView) to avoid duplicate calculation } // Creation variant: simplified for first message / workspace creation diff --git a/src/browser/components/CompactionWarning.tsx b/src/browser/components/CompactionWarning.tsx new file mode 100644 index 000000000..7688f1bad --- /dev/null +++ b/src/browser/components/CompactionWarning.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +/** + * Warning banner shown when context usage is approaching the compaction threshold. + * + * Displays progressive warnings: + * - Below threshold: "Context left until Auto-Compact: X% remaining" (where X = threshold - current) + * - At/above threshold: "Approaching context limit. Next message will trigger auto-compaction." + * + * Displayed above ChatInput when: + * - Token usage >= (threshold - 10%) of model's context window + * - Not currently compacting (user can still send messages) + * + * @param usagePercentage - Current token usage as percentage (0-100) + * @param thresholdPercentage - Auto-compaction trigger threshold (0-100, default 70) + */ +export const CompactionWarning: React.FC<{ + usagePercentage: number; + thresholdPercentage: number; +}> = (props) => { + // At threshold or above, next message will trigger compaction + const willCompactNext = props.usagePercentage >= props.thresholdPercentage; + + // Calculate remaining percentage until threshold + const remaining = props.thresholdPercentage - props.usagePercentage; + + const message = willCompactNext + ? "⚠️ Context limit reached. Next message will trigger auto-compaction." + : `Context left until Auto-Compact: ${Math.round(remaining)}%`; + + return ( +
+ {message} +
+ ); +}; diff --git a/src/browser/hooks/useResumeManager.ts b/src/browser/hooks/useResumeManager.ts index 507ab7523..afe5a0fcb 100644 --- a/src/browser/hooks/useResumeManager.ts +++ b/src/browser/hooks/useResumeManager.ts @@ -171,7 +171,10 @@ export function useResumeManager() { if (lastUserMsg?.compactionRequest) { // Apply compaction overrides using shared function (same as ChatInput) // This ensures custom model/tokens are preserved across resume - options = applyCompactionOverrides(options, lastUserMsg.compactionRequest.parsed); + options = applyCompactionOverrides(options, { + maxOutputTokens: lastUserMsg.compactionRequest.parsed.maxOutputTokens, + continueMessage: { text: lastUserMsg.compactionRequest.parsed.continueMessage ?? "" }, + }); } } diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 136d8f8eb..d15f4a09a 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -424,27 +424,34 @@ export class WorkspaceStore { * Extract usage from messages (no tokenization). * Each usage entry calculated with its own model for accurate costs. * - * REQUIRES: Workspace must have been added via addWorkspace() first. + * Returns empty state if workspace doesn't exist (e.g., creation mode). */ getWorkspaceUsage(workspaceId: string): WorkspaceUsageState { return this.usageStore.get(workspaceId, () => { - const aggregator = this.assertGet(workspaceId); + const aggregator = this.aggregators.get(workspaceId); + if (!aggregator) { + return { usageHistory: [], totalTokens: 0 }; + } const messages = aggregator.getAllMessages(); const model = aggregator.getCurrentModel(); const usageHistory = collectUsageHistory(messages, model); - // Calculate total from usage history (now includes historical) - const totalTokens = usageHistory.reduce( - (sum, u) => - sum + - u.input.tokens + - u.cached.tokens + - u.cacheCreate.tokens + - u.output.tokens + - u.reasoning.tokens, - 0 - ); + const messages = aggregator.getAllMessages(); + const model = aggregator.getCurrentModel(); + const usageHistory = cumUsageHistory(messages, model); + + // Use last entry's total (each entry is cumulative, not a delta) + // Each usageHistory entry contains the FULL prompt tokens for that turn, + // so we only need the most recent value, not a sum + const lastEntry = usageHistory[usageHistory.length - 1]; + const totalTokens = lastEntry + ? lastEntry.input.tokens + + lastEntry.cached.tokens + + lastEntry.cacheCreate.tokens + + lastEntry.output.tokens + + lastEntry.reasoning.tokens + : 0; return { usageHistory, totalTokens }; }); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 39f63800b..e388f7894 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -6,8 +6,12 @@ * to ensure consistent behavior and avoid duplication. */ -import type { SendMessageOptions } from "@/common/types/ipc"; -import type { MuxFrontendMetadata, CompactionRequestData } from "@/common/types/message"; +import type { SendMessageOptions, ImagePart } from "@/common/types/ipc"; +import type { + MuxFrontendMetadata, + CompactionRequestData, + ContinueMessage, +} from "@/common/types/message"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { RuntimeConfig } from "@/common/types/runtime"; import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/common/types/runtime"; @@ -17,6 +21,7 @@ import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; import { getRuntimeKey } from "@/common/constants/storage"; +import type { ImageAttachment } from "../components/ImageAttachments"; // ============================================================================ // Workspace Creation @@ -177,7 +182,7 @@ export { forkWorkspace } from "./workspaceFork"; export interface CompactionOptions { workspaceId: string; maxOutputTokens?: number; - continueMessage?: string; + continueMessage?: ContinueMessage; model?: string; sendMessageOptions: SendMessageOptions; editMessageId?: string; @@ -203,7 +208,7 @@ export function prepareCompactionMessage(options: CompactionOptions): { let messageText = `Summarize this conversation into a compact form for a new Assistant to continue helping the user. Use approximately ${targetWords} words.`; if (options.continueMessage) { - messageText += `\n\nThe user wants to continue with: ${options.continueMessage}`; + messageText += `\n\nThe user wants to continue with: ${options.continueMessage.text}`; } // Handle model preference (sticky globally) @@ -267,7 +272,7 @@ function formatCompactionCommand(options: CompactionOptions): string { cmd += ` -m ${options.model}`; } if (options.continueMessage) { - cmd += `\n${options.continueMessage}`; + cmd += `\n${options.continueMessage.text}`; } return cmd; } @@ -279,8 +284,10 @@ function formatCompactionCommand(options: CompactionOptions): string { export interface CommandHandlerContext { workspaceId: string; sendMessageOptions: SendMessageOptions; + imageParts?: ImagePart[]; editMessageId?: string; setInput: (value: string) => void; + setImageAttachments: (images: ImageAttachment[]) => void; setIsSending: (value: boolean) => void; setToast: (toast: Toast) => void; onCancelEdit?: () => void; @@ -394,19 +401,23 @@ export async function handleCompactCommand( sendMessageOptions, editMessageId, setInput, + setImageAttachments, setIsSending, setToast, onCancelEdit, } = context; setInput(""); + setImageAttachments([]); setIsSending(true); try { const result = await executeCompaction({ workspaceId, maxOutputTokens: parsed.maxOutputTokens, - continueMessage: parsed.continueMessage, + continueMessage: parsed.continueMessage + ? { text: parsed.continueMessage, imageParts: context.imageParts } + : undefined, model: parsed.model, sendMessageOptions, editMessageId, diff --git a/src/browser/utils/compaction/autoCompactionCheck.ts b/src/browser/utils/compaction/autoCompactionCheck.ts new file mode 100644 index 000000000..4369eadc4 --- /dev/null +++ b/src/browser/utils/compaction/autoCompactionCheck.ts @@ -0,0 +1,87 @@ +/** + * Auto-compaction threshold checking + * + * Determines whether auto-compaction should trigger based on current token usage + * as a percentage of the model's context window. + * + * Auto-compaction triggers when: + * - Usage data is available (has at least one API response) + * - Model has known max_input_tokens + * - Usage exceeds threshold (default 70%) + * + * Safe defaults: + * - Returns false if no usage data (first message) + * - Returns false if model stats unavailable (unknown model) + * - Never triggers in edit mode (caller's responsibility to check) + */ + +import type { WorkspaceUsageState } from "@/browser/stores/WorkspaceStore"; +import { getModelStats } from "@/common/utils/tokens/modelStats"; +import { supports1MContext } from "@/common/utils/ai/models"; + +export interface AutoCompactionCheckResult { + shouldShowWarning: boolean; + usagePercentage: number; + thresholdPercentage: number; +} + +// Auto-compaction threshold (0.7 = 70%) +// TODO: Make this configurable via settings +const AUTO_COMPACTION_THRESHOLD = 0.7; + +// Show warning this many percentage points before threshold +const WARNING_ADVANCE_PERCENT = 10; + +/** + * Check if auto-compaction should trigger based on token usage + * + * @param usage - Current workspace usage state (from useWorkspaceUsage) + * @param model - Current model string + * @param use1M - Whether 1M context is enabled + * @param threshold - Usage percentage threshold (0.0-1.0, default 0.7 = 70%) + * @param warningAdvancePercent - Show warning this many percentage points before threshold (default 10) + * @returns Check result with warning flag and usage percentage + */ +export function shouldAutoCompact( + usage: WorkspaceUsageState | undefined, + model: string, + use1M: boolean, + threshold: number = AUTO_COMPACTION_THRESHOLD, + warningAdvancePercent: number = WARNING_ADVANCE_PERCENT +): AutoCompactionCheckResult { + const thresholdPercentage = threshold * 100; + + // No usage data yet - safe default (don't trigger on first message) + if (!usage || usage.usageHistory.length === 0) { + return { + shouldShowWarning: false, + usagePercentage: 0, + thresholdPercentage, + }; + } + + // Determine max tokens for this model + const modelStats = getModelStats(model); + const maxTokens = use1M && supports1MContext(model) ? 1_000_000 : modelStats?.max_input_tokens; + + // No max tokens known - safe default (can't calculate percentage) + if (!maxTokens) { + return { + shouldShowWarning: false, + usagePercentage: 0, + thresholdPercentage, + }; + } + + // Calculate usage percentage from cumulative conversation total + const usagePercentage = (usage.totalTokens / maxTokens) * 100; + + // Show warning if within advance window (e.g., 60% for 70% threshold with 10% advance) + const shouldShowWarning = usagePercentage >= thresholdPercentage - warningAdvancePercent; + + return { + shouldShowWarning, + usagePercentage, + thresholdPercentage, + }; +} diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index e0d1193e1..269155da1 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -762,7 +762,10 @@ export class StreamingMessageAggregator { muxMeta?.type === "compaction-request" ? { rawCommand: muxMeta.rawCommand, - parsed: muxMeta.parsed, + parsed: { + maxOutputTokens: muxMeta.parsed.maxOutputTokens, + continueMessage: muxMeta.parsed.continueMessage?.text, // Extract text for display + }, } : undefined; diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 0d88b52d4..6e79594ea 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -5,11 +5,17 @@ import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; import type { ImagePart } from "./ipc"; +// Message to continue with after compaction +export interface ContinueMessage { + text: string; + imageParts?: ImagePart[]; +} + // Parsed compaction request data (shared type for consistency) export interface CompactionRequestData { model?: string; // Custom model override for compaction maxOutputTokens?: number; - continueMessage?: string; + continueMessage?: ContinueMessage; } // Frontend-specific metadata stored in muxMetadata field diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index adbe96ee3..4b61503ac 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -326,9 +326,10 @@ export class AgentSession { // If this is a compaction request with a continue message, queue it for auto-send after compaction const muxMeta = options?.muxMetadata; if (muxMeta?.type === "compaction-request" && muxMeta.parsed.continueMessage && options) { + const { text, imageParts } = muxMeta.parsed.continueMessage; // Strip out edit-specific and compaction-specific fields so the queued message is a fresh user message const { muxMetadata, mode, editMessageId, ...continueOptions } = options; - this.messageQueue.add(muxMeta.parsed.continueMessage, continueOptions); + this.messageQueue.add(text, { ...continueOptions, imageParts }); this.emitQueuedMessageChanged(); } From 3a272c70ec7e7d5a3f2534693789ba9ede86eaa7 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 21 Nov 2025 12:59:54 +1100 Subject: [PATCH 02/20] fix merge conflict --- src/browser/stores/WorkspaceStore.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index d15f4a09a..82c1f1f58 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -437,10 +437,6 @@ export class WorkspaceStore { const model = aggregator.getCurrentModel(); const usageHistory = collectUsageHistory(messages, model); - const messages = aggregator.getAllMessages(); - const model = aggregator.getCurrentModel(); - const usageHistory = cumUsageHistory(messages, model); - // Use last entry's total (each entry is cumulative, not a delta) // Each usageHistory entry contains the FULL prompt tokens for that turn, // so we only need the most recent value, not a sum From 7e67829ac50902eb6628f9f0222b9a59c1ea2d69 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 21 Nov 2025 13:11:56 +1100 Subject: [PATCH 03/20] use model options for 1m context --- src/browser/components/AIView.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 314d69047..b8a5d3ab3 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -37,7 +37,7 @@ import { evictModelFromLRU } from "@/browser/hooks/useModelLRU"; import { QueuedMessage } from "./Messages/QueuedMessage"; import { CompactionWarning } from "./CompactionWarning"; import { shouldAutoCompact } from "@/browser/utils/compaction/autoCompactionCheck"; -import { use1MContext } from "@/browser/hooks/use1MContext"; +import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; interface AIViewProps { workspaceId: string; @@ -82,7 +82,8 @@ const AIViewInner: React.FC = ({ const workspaceState = useWorkspaceState(workspaceId); const aggregator = useWorkspaceAggregator(workspaceId); const workspaceUsage = useWorkspaceUsage(workspaceId); - const [use1M] = use1MContext(); + const { options } = useProviderOptions(); + const use1M = options.anthropic?.use1MContext ?? false; const handledModelErrorsRef = useRef>(new Set()); useEffect(() => { From 683d9dfc0a0886ea52a59784946afe9035c12f41 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 21 Nov 2025 13:58:36 +1100 Subject: [PATCH 04/20] pass model and images when resuming --- src/browser/hooks/useResumeManager.ts | 6 +++++- src/browser/utils/messages/StreamingMessageAggregator.ts | 6 ++++-- src/common/types/message.ts | 5 +---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/browser/hooks/useResumeManager.ts b/src/browser/hooks/useResumeManager.ts index afe5a0fcb..5cbc2a502 100644 --- a/src/browser/hooks/useResumeManager.ts +++ b/src/browser/hooks/useResumeManager.ts @@ -172,8 +172,12 @@ export function useResumeManager() { // Apply compaction overrides using shared function (same as ChatInput) // This ensures custom model/tokens are preserved across resume options = applyCompactionOverrides(options, { + model: lastUserMsg.compactionRequest.parsed.model, maxOutputTokens: lastUserMsg.compactionRequest.parsed.maxOutputTokens, - continueMessage: { text: lastUserMsg.compactionRequest.parsed.continueMessage ?? "" }, + continueMessage: { + text: lastUserMsg.compactionRequest.parsed.continueMessage?.text ?? "", + imageParts: lastUserMsg.compactionRequest.parsed.continueMessage?.imageParts, + }, }); } } diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index 269155da1..10bccd05d 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -3,6 +3,7 @@ import type { MuxMetadata, MuxImagePart, DisplayedMessage, + CompactionRequestData, } from "@/common/types/message"; import { createMuxMessage } from "@/common/types/message"; import type { @@ -763,9 +764,10 @@ export class StreamingMessageAggregator { ? { rawCommand: muxMeta.rawCommand, parsed: { + model: muxMeta.parsed.model, maxOutputTokens: muxMeta.parsed.maxOutputTokens, - continueMessage: muxMeta.parsed.continueMessage?.text, // Extract text for display - }, + continueMessage: muxMeta.parsed.continueMessage, + } satisfies CompactionRequestData, } : undefined; diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 6e79594ea..bb47236bf 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -106,10 +106,7 @@ export type DisplayedMessage = compactionRequest?: { // Present if this is a /compact command rawCommand: string; - parsed: { - maxOutputTokens?: number; - continueMessage?: string; - }; + parsed: CompactionRequestData; }; } | { From 686e43977f91c8697cdd2616e87a60c9b89e4a45 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 21 Nov 2025 20:06:55 +1100 Subject: [PATCH 05/20] fix usage calc --- src/browser/stores/WorkspaceStore.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 82c1f1f58..4576fb3fd 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -437,17 +437,17 @@ export class WorkspaceStore { const model = aggregator.getCurrentModel(); const usageHistory = collectUsageHistory(messages, model); - // Use last entry's total (each entry is cumulative, not a delta) - // Each usageHistory entry contains the FULL prompt tokens for that turn, - // so we only need the most recent value, not a sum - const lastEntry = usageHistory[usageHistory.length - 1]; - const totalTokens = lastEntry - ? lastEntry.input.tokens + - lastEntry.cached.tokens + - lastEntry.cacheCreate.tokens + - lastEntry.output.tokens + - lastEntry.reasoning.tokens - : 0; + // Calculate total from usage history (now includes historical) + const totalTokens = usageHistory.reduce( + (sum, u) => + sum + + u.input.tokens + + u.cached.tokens + + u.cacheCreate.tokens + + u.output.tokens + + u.reasoning.tokens, + 0 + ); return { usageHistory, totalTokens }; }); From 8b68d1601c9b4e83245639178c29f7587e41434b Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 21 Nov 2025 20:41:16 +1100 Subject: [PATCH 06/20] fix usage calc --- src/browser/utils/chatCommands.ts | 3 --- .../utils/compaction/autoCompactionCheck.ts | 24 +++++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index e388f7894..2f81bc5a4 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -271,9 +271,6 @@ function formatCompactionCommand(options: CompactionOptions): string { if (options.model) { cmd += ` -m ${options.model}`; } - if (options.continueMessage) { - cmd += `\n${options.continueMessage.text}`; - } return cmd; } diff --git a/src/browser/utils/compaction/autoCompactionCheck.ts b/src/browser/utils/compaction/autoCompactionCheck.ts index 4369eadc4..c532395c7 100644 --- a/src/browser/utils/compaction/autoCompactionCheck.ts +++ b/src/browser/utils/compaction/autoCompactionCheck.ts @@ -35,6 +35,10 @@ const WARNING_ADVANCE_PERCENT = 10; /** * Check if auto-compaction should trigger based on token usage * + * Uses the last usage entry (most recent API call) to calculate current context size. + * This matches the UI token meter display and excludes historical usage from compaction, + * preventing infinite compaction loops after the first compaction completes. + * * @param usage - Current workspace usage state (from useWorkspaceUsage) * @param model - Current model string * @param use1M - Whether 1M context is enabled @@ -73,8 +77,24 @@ export function shouldAutoCompact( }; } - // Calculate usage percentage from cumulative conversation total - const usagePercentage = (usage.totalTokens / maxTokens) * 100; + // Use last usage entry to calculate current context size (matches UI display) + const lastUsage = usage.usageHistory[usage.usageHistory.length - 1]; + if (!lastUsage) { + return { + shouldShowWarning: false, + usagePercentage: 0, + thresholdPercentage, + }; + } + + const currentContextTokens = + lastUsage.input.tokens + + lastUsage.cached.tokens + + lastUsage.cacheCreate.tokens + + lastUsage.output.tokens + + lastUsage.reasoning.tokens; + + const usagePercentage = (currentContextTokens / maxTokens) * 100; // Show warning if within advance window (e.g., 60% for 70% threshold with 10% advance) const shouldShowWarning = usagePercentage >= thresholdPercentage - warningAdvancePercent; From 444c3baae2227de80875f114204178d2951a51df Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 24 Nov 2025 12:49:09 +1100 Subject: [PATCH 07/20] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20make=20countdo?= =?UTF-8?q?wn=20warning=20smaller=20and=20less=20intrusive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Countdown warning (60-69%): Small grey text, right-aligned - Urgent warning (70%+): Keep prominent blue box styling - Makes countdown unobtrusive while keeping urgent warning visible _Generated with `mux`_ --- src/browser/components/CompactionWarning.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/browser/components/CompactionWarning.tsx b/src/browser/components/CompactionWarning.tsx index 7688f1bad..a48711200 100644 --- a/src/browser/components/CompactionWarning.tsx +++ b/src/browser/components/CompactionWarning.tsx @@ -21,16 +21,20 @@ export const CompactionWarning: React.FC<{ // At threshold or above, next message will trigger compaction const willCompactNext = props.usagePercentage >= props.thresholdPercentage; - // Calculate remaining percentage until threshold - const remaining = props.thresholdPercentage - props.usagePercentage; - - const message = willCompactNext - ? "⚠️ Context limit reached. Next message will trigger auto-compaction." - : `Context left until Auto-Compact: ${Math.round(remaining)}%`; + // Urgent warning at/above threshold - prominent blue box + if (willCompactNext) { + return ( +
+ ⚠️ Context limit reached. Next message will trigger auto-compaction. +
+ ); + } + // Countdown warning below threshold - subtle grey text, right-aligned + const remaining = props.thresholdPercentage - props.usagePercentage; return ( -
- {message} +
+ Context left until Auto-Compact: {Math.round(remaining)}%
); }; From 2f2d3eb124735eba591c805317a88c22737e06b3 Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 24 Nov 2025 13:07:54 +1100 Subject: [PATCH 08/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20prevent=20double-co?= =?UTF-8?q?mpaction=20when=20sending=20during=20active=20compaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds !isCompacting check to shouldAutoCompact calculation to prevent queueing a second compaction request when user sends a message while the first compaction is still running. Without this check, messages sent during compaction would trigger another compaction, resulting in back-to-back compactions and delayed user messages. _Generated with `mux`_ --- src/browser/components/ChatInput/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 507520c8a..a92ba98b9 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -695,7 +695,9 @@ export const ChatInput: React.FC = (props) => { // Result is computed in parent (AIView) and passed down to avoid duplicate calculation const shouldAutoCompact = props.autoCompactionCheck && - props.autoCompactionCheck.usagePercentage >= props.autoCompactionCheck.thresholdPercentage; + props.autoCompactionCheck.usagePercentage >= + props.autoCompactionCheck.thresholdPercentage && + !isCompacting; // Skip if already compacting to prevent double-compaction queue if (variant === "workspace" && !editingMessage && shouldAutoCompact) { // Clear input immediately for responsive UX setInput(""); From b184dcf84cf16fccbcf8e96c26fe1031c65b4bab Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 24 Nov 2025 13:44:31 +1100 Subject: [PATCH 09/20] =?UTF-8?q?=F0=9F=A4=96=20test:=20add=20comprehensiv?= =?UTF-8?q?e=20unit=20tests=20for=20shouldAutoCompact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 23 unit tests covering: - Basic functionality (safe defaults, threshold detection) - Usage calculation (last entry vs cumulative, historical usage handling) - 1M context mode (model support, fallback behavior) - Edge cases (zero tokens, custom thresholds, boundary conditions) - Percentage calculation accuracy All tests verify the infinite loop fix - that historical usage from compaction is correctly excluded from threshold calculations. Tests run: 23 pass, 0 fail, 62 expect() calls _Generated with `mux`_ --- .../compaction/autoCompactionCheck.test.ts | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 src/browser/utils/compaction/autoCompactionCheck.test.ts diff --git a/src/browser/utils/compaction/autoCompactionCheck.test.ts b/src/browser/utils/compaction/autoCompactionCheck.test.ts new file mode 100644 index 000000000..8e1d26f3d --- /dev/null +++ b/src/browser/utils/compaction/autoCompactionCheck.test.ts @@ -0,0 +1,300 @@ +import { describe, test, expect } from "bun:test"; +import { shouldAutoCompact } from "./autoCompactionCheck"; +import type { WorkspaceUsageState } from "@/browser/stores/WorkspaceStore"; +import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; +import { KNOWN_MODELS } from "@/common/constants/knownModels"; + +// Helper to create a mock usage entry +const createUsageEntry = ( + tokens: number, + model: string = KNOWN_MODELS.SONNET.id +): ChatUsageDisplay => { + // Distribute tokens across different types (realistic pattern) + const inputTokens = Math.floor(tokens * 0.6); // 60% input + const outputTokens = Math.floor(tokens * 0.3); // 30% output + const cachedTokens = Math.floor(tokens * 0.1); // 10% cached + + return { + input: { tokens: inputTokens }, + cached: { tokens: cachedTokens }, + cacheCreate: { tokens: 0 }, + output: { tokens: outputTokens }, + reasoning: { tokens: 0 }, + model, + }; +}; + +// Helper to create mock WorkspaceUsageState +const createMockUsage = ( + lastEntryTokens: number, + historicalTokens?: number, + model: string = KNOWN_MODELS.SONNET.id +): WorkspaceUsageState => { + const usageHistory: ChatUsageDisplay[] = []; + + if (historicalTokens !== undefined) { + // Add historical usage (from compaction) + usageHistory.push(createUsageEntry(historicalTokens, "historical-model")); + } + + // Add recent usage + usageHistory.push(createUsageEntry(lastEntryTokens, model)); + + return { usageHistory, totalTokens: 0 }; +}; + +describe("shouldAutoCompact", () => { + const SONNET_MAX_TOKENS = 200_000; + const SONNET_70_PERCENT = SONNET_MAX_TOKENS * 0.7; // 140,000 + const SONNET_60_PERCENT = SONNET_MAX_TOKENS * 0.6; // 120,000 + + describe("Basic Functionality", () => { + test("returns false when no usage data (first message)", () => { + const result = shouldAutoCompact(undefined, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(false); + expect(result.usagePercentage).toBe(0); + expect(result.thresholdPercentage).toBe(70); + }); + + test("returns false when usage history is empty", () => { + const usage: WorkspaceUsageState = { usageHistory: [], totalTokens: 0 }; + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(false); + expect(result.usagePercentage).toBe(0); + expect(result.thresholdPercentage).toBe(70); + }); + + test("returns false when model has no max_input_tokens (unknown model)", () => { + const usage = createMockUsage(50_000); + const result = shouldAutoCompact(usage, "unknown-model", false); + + expect(result.shouldShowWarning).toBe(false); + expect(result.usagePercentage).toBe(0); + expect(result.thresholdPercentage).toBe(70); + }); + + test("returns false when usage is low (10%)", () => { + const usage = createMockUsage(20_000); // 10% of 200k + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(false); + expect(result.usagePercentage).toBe(10); + expect(result.thresholdPercentage).toBe(70); + }); + + test("returns true at warning threshold (60% with default 10% advance)", () => { + const usage = createMockUsage(SONNET_60_PERCENT); + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(true); + expect(result.usagePercentage).toBe(60); + expect(result.thresholdPercentage).toBe(70); + }); + + test("returns true at compaction threshold (70%)", () => { + const usage = createMockUsage(SONNET_70_PERCENT); + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(true); + expect(result.usagePercentage).toBe(70); + expect(result.thresholdPercentage).toBe(70); + }); + + test("returns true above threshold (80%)", () => { + const usage = createMockUsage(160_000); // 80% of 200k + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(true); + expect(result.usagePercentage).toBe(80); + expect(result.thresholdPercentage).toBe(70); + }); + }); + + describe("Usage Calculation (Critical for infinite loop fix)", () => { + test("uses last usage entry tokens, not cumulative sum", () => { + const usage = createMockUsage(10_000); // Only 5% of context + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + // Should be 5%, not counting historical + expect(result.usagePercentage).toBe(5); + expect(result.shouldShowWarning).toBe(false); + }); + + test("handles historical usage correctly - ignores it in calculation", () => { + // Scenario: After compaction, historical = 70K, recent = 5K + // Should calculate based on 5K (2.5%), not 75K (37.5%) + const usage = createMockUsage(5_000, 70_000); + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.usagePercentage).toBe(2.5); + expect(result.shouldShowWarning).toBe(false); + }); + + test("includes all token types in calculation", () => { + // Create usage with all token types specified + const usage: WorkspaceUsageState = { + usageHistory: [ + { + input: { tokens: 10_000 }, + cached: { tokens: 5_000 }, + cacheCreate: { tokens: 2_000 }, + output: { tokens: 3_000 }, + reasoning: { tokens: 1_000 }, + model: KNOWN_MODELS.SONNET.id, + }, + ], + totalTokens: 0, + }; + + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + // Total: 10k + 5k + 2k + 3k + 1k = 21k tokens = 10.5% + expect(result.usagePercentage).toBe(10.5); + }); + }); + + describe("1M Context Mode", () => { + test("uses 1M tokens when use1M=true and model supports it (Sonnet 4)", () => { + const usage = createMockUsage(600_000); // 60% of 1M + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, true); + + expect(result.usagePercentage).toBe(60); + expect(result.shouldShowWarning).toBe(true); + }); + + test("uses 1M tokens for Sonnet with use1M=true (model is claude-sonnet-4-5)", () => { + const usage = createMockUsage(700_000); // 70% of 1M + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, true); + + expect(result.usagePercentage).toBe(70); + expect(result.shouldShowWarning).toBe(true); + }); + + test("uses standard max_input_tokens when use1M=false", () => { + const usage = createMockUsage(140_000); // 70% of 200k + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.usagePercentage).toBe(70); + expect(result.shouldShowWarning).toBe(true); + }); + + test("ignores use1M for models that don't support it (GPT)", () => { + const usage = createMockUsage(100_000, undefined, KNOWN_MODELS.GPT_MINI.id); + // GPT Mini has 272k context, so 100k = 36.76% + const result = shouldAutoCompact(usage, KNOWN_MODELS.GPT_MINI.id, true); + + // Should use standard 272k, not 1M (use1M ignored for GPT) + expect(result.usagePercentage).toBeCloseTo(36.76, 1); + expect(result.shouldShowWarning).toBe(false); + }); + }); + + describe("Edge Cases", () => { + test("empty usageHistory array returns safe defaults", () => { + const usage: WorkspaceUsageState = { usageHistory: [], totalTokens: 0 }; + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(false); + expect(result.usagePercentage).toBe(0); + expect(result.thresholdPercentage).toBe(70); + }); + + test("single entry in usageHistory works correctly", () => { + const usage = createMockUsage(140_000); + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(true); + expect(result.usagePercentage).toBe(70); + }); + + test("custom threshold parameter (80%)", () => { + const usage = createMockUsage(140_000); // 70% of context + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false, 0.8); // 80% threshold + + // At 70%, should NOT show warning for 80% threshold (needs 70% advance = 10%) + expect(result.shouldShowWarning).toBe(true); // 70% >= (80% - 10% = 70%) + expect(result.usagePercentage).toBe(70); + expect(result.thresholdPercentage).toBe(80); + }); + + test("custom warning advance (5% instead of 10%)", () => { + const usage = createMockUsage(130_000); // 65% of context + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false, 0.7, 5); + + // At 65%, should show warning with 5% advance (70% - 5% = 65%) + expect(result.shouldShowWarning).toBe(true); + expect(result.usagePercentage).toBe(65); + expect(result.thresholdPercentage).toBe(70); + }); + + test("handles zero tokens gracefully", () => { + const usage: WorkspaceUsageState = { + usageHistory: [ + { + input: { tokens: 0 }, + cached: { tokens: 0 }, + cacheCreate: { tokens: 0 }, + output: { tokens: 0 }, + reasoning: { tokens: 0 }, + model: KNOWN_MODELS.SONNET.id, + }, + ], + totalTokens: 0, + }; + + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(false); + expect(result.usagePercentage).toBe(0); + }); + + test("handles usage at exactly 100% of context", () => { + const usage = createMockUsage(SONNET_MAX_TOKENS); + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(true); + expect(result.usagePercentage).toBe(100); + expect(result.thresholdPercentage).toBe(70); + }); + + test("handles usage beyond 100% of context", () => { + const usage = createMockUsage(SONNET_MAX_TOKENS + 50_000); + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.shouldShowWarning).toBe(true); + expect(result.usagePercentage).toBe(125); + expect(result.thresholdPercentage).toBe(70); + }); + }); + + describe("Percentage Calculation Accuracy", () => { + test("calculates percentage correctly for various token counts", () => { + // Test specific percentages + const testCases = [ + { tokens: 20_000, expectedPercent: 10 }, + { tokens: 40_000, expectedPercent: 20 }, + { tokens: 100_000, expectedPercent: 50 }, + { tokens: 120_000, expectedPercent: 60 }, + { tokens: 140_000, expectedPercent: 70 }, + { tokens: 160_000, expectedPercent: 80 }, + { tokens: 180_000, expectedPercent: 90 }, + ]; + + for (const { tokens, expectedPercent } of testCases) { + const usage = createMockUsage(tokens); + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + expect(result.usagePercentage).toBe(expectedPercent); + } + }); + + test("handles fractional percentages correctly", () => { + const usage = createMockUsage(123_456); // 61.728% + const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + + expect(result.usagePercentage).toBeCloseTo(61.728, 2); + expect(result.shouldShowWarning).toBe(true); // Above 60% + }); + }); +}); From b3e06dad16b6c682c3eb1e2420d0b8bbf2a6837c Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 20 Nov 2025 16:40:47 +1100 Subject: [PATCH 10/20] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20auto-compact?= =?UTF-8?q?ion=20configuration=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add configurable auto-compaction threshold (50-90%) per workspace - Extract threshold constants to ui.ts (DRY) - Create reusable useClampedNumberInput hook for numeric inputs - Add settings to right sidebar with checkbox and percentage input - Wire settings through to shouldAutoCompact check - Use existing HelpIndicator pattern for tooltips --- src/browser/components/AIView.tsx | 21 +++++- .../RightSidebar/AutoCompactionSettings.tsx | 65 +++++++++++++++++++ .../components/RightSidebar/CostsTab.tsx | 3 + .../hooks/useAutoCompactionSettings.ts | 40 ++++++++++++ src/browser/hooks/useClampedNumberInput.ts | 56 ++++++++++++++++ .../utils/compaction/autoCompactionCheck.ts | 27 ++++++-- src/common/constants/storage.ts | 18 +++++ src/common/constants/ui.ts | 17 +++++ 8 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 src/browser/components/RightSidebar/AutoCompactionSettings.tsx create mode 100644 src/browser/hooks/useAutoCompactionSettings.ts create mode 100644 src/browser/hooks/useClampedNumberInput.ts diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index b8a5d3ab3..0ab844ae7 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -37,7 +37,12 @@ import { evictModelFromLRU } from "@/browser/hooks/useModelLRU"; import { QueuedMessage } from "./Messages/QueuedMessage"; import { CompactionWarning } from "./CompactionWarning"; import { shouldAutoCompact } from "@/browser/utils/compaction/autoCompactionCheck"; +<<<<<<< HEAD import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; +======= +import { use1MContext } from "@/browser/hooks/use1MContext"; +import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings"; +>>>>>>> 52d11bd1 (🤖 feat: add auto-compaction configuration UI) interface AIViewProps { workspaceId: string; @@ -82,8 +87,14 @@ const AIViewInner: React.FC = ({ const workspaceState = useWorkspaceState(workspaceId); const aggregator = useWorkspaceAggregator(workspaceId); const workspaceUsage = useWorkspaceUsage(workspaceId); +<<<<<<< HEAD const { options } = useProviderOptions(); const use1M = options.anthropic?.use1MContext ?? false; +======= + const [use1M] = use1MContext(); + const { enabled: autoCompactionEnabled, threshold: autoCompactionThreshold } = + useAutoCompactionSettings(workspaceId); +>>>>>>> 52d11bd1 (🤖 feat: add auto-compaction configuration UI) const handledModelErrorsRef = useRef>(new Set()); useEffect(() => { @@ -328,9 +339,13 @@ const AIViewInner: React.FC = ({ // Get active stream message ID for token counting const activeStreamMessageId = aggregator.getActiveStreamMessageId(); - const autoCompactionCheck = currentModel - ? shouldAutoCompact(workspaceUsage, currentModel, use1M) - : { shouldShowWarning: false, usagePercentage: 0, thresholdPercentage: 70 }; + const autoCompactionCheck = shouldAutoCompact( + workspaceUsage, + currentModel, + use1M, + autoCompactionEnabled, + autoCompactionThreshold / 100 + ); // Show warning when: shouldShowWarning flag is true AND not currently compacting const shouldShowCompactionWarning = !isCompacting && autoCompactionCheck.shouldShowWarning; diff --git a/src/browser/components/RightSidebar/AutoCompactionSettings.tsx b/src/browser/components/RightSidebar/AutoCompactionSettings.tsx new file mode 100644 index 000000000..518c85ce4 --- /dev/null +++ b/src/browser/components/RightSidebar/AutoCompactionSettings.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings"; +import { useClampedNumberInput } from "@/browser/hooks/useClampedNumberInput"; +import { + AUTO_COMPACTION_THRESHOLD_MIN, + AUTO_COMPACTION_THRESHOLD_MAX, +} from "@/common/constants/ui"; +import { TooltipWrapper, Tooltip, HelpIndicator } from "../Tooltip"; + +interface AutoCompactionSettingsProps { + workspaceId: string; +} + +export const AutoCompactionSettings: React.FC = ({ workspaceId }) => { + const { enabled, setEnabled, threshold, setThreshold } = useAutoCompactionSettings(workspaceId); + const { localValue, handleChange, handleBlur } = useClampedNumberInput( + threshold, + setThreshold, + AUTO_COMPACTION_THRESHOLD_MIN, + AUTO_COMPACTION_THRESHOLD_MAX + ); + + return ( +
+
+ {/* Left side: checkbox + label + tooltip */} +
+ + + ? + + Automatically compact conversation history when context usage reaches the threshold + + +
+ + {/* Right side: input + % symbol */} +
+ + % +
+
+
+ ); +}; diff --git a/src/browser/components/RightSidebar/CostsTab.tsx b/src/browser/components/RightSidebar/CostsTab.tsx index aeae58152..f5053c8d5 100644 --- a/src/browser/components/RightSidebar/CostsTab.tsx +++ b/src/browser/components/RightSidebar/CostsTab.tsx @@ -8,6 +8,7 @@ import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; import { supports1MContext } from "@/common/utils/ai/models"; import { TOKEN_COMPONENT_COLORS } from "@/common/utils/tokens/tokenMeterUtils"; import { ConsumerBreakdown } from "./ConsumerBreakdown"; +import { AutoCompactionSettings } from "./AutoCompactionSettings"; // Format token display - show k for thousands with 1 decimal const formatTokens = (tokens: number) => @@ -231,6 +232,8 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => {
)} + {hasUsageData && } + {hasUsageData && (
diff --git a/src/browser/hooks/useAutoCompactionSettings.ts b/src/browser/hooks/useAutoCompactionSettings.ts new file mode 100644 index 000000000..3a5b436ed --- /dev/null +++ b/src/browser/hooks/useAutoCompactionSettings.ts @@ -0,0 +1,40 @@ +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + getAutoCompactionEnabledKey, + getAutoCompactionThresholdKey, +} from "@/common/constants/storage"; +import { DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT } from "@/common/constants/ui"; + +export interface AutoCompactionSettings { + /** Whether auto-compaction is enabled for this workspace */ + enabled: boolean; + /** Update enabled state */ + setEnabled: (value: boolean) => void; + /** Current threshold percentage (50-90) */ + threshold: number; + /** Update threshold percentage (will be clamped to 50-90 range by UI) */ + setThreshold: (value: number) => void; +} + +/** + * Custom hook for auto-compaction settings per workspace. + * Persists both enabled state and threshold percentage to localStorage. + * + * @param workspaceId - Workspace identifier + * @returns Settings object with getters and setters + */ +export function useAutoCompactionSettings(workspaceId: string): AutoCompactionSettings { + const [enabled, setEnabled] = usePersistedState( + getAutoCompactionEnabledKey(workspaceId), + true, + { listener: true } + ); + + const [threshold, setThreshold] = usePersistedState( + getAutoCompactionThresholdKey(workspaceId), + DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT, + { listener: true } + ); + + return { enabled, setEnabled, threshold, setThreshold }; +} diff --git a/src/browser/hooks/useClampedNumberInput.ts b/src/browser/hooks/useClampedNumberInput.ts new file mode 100644 index 000000000..998ff0e97 --- /dev/null +++ b/src/browser/hooks/useClampedNumberInput.ts @@ -0,0 +1,56 @@ +import React from "react"; + +/** + * Hook for number input with local state, validation, and clamping on blur. + * Prevents typing interruption while ensuring valid persisted values. + * + * @param persistedValue - Current value from persistence layer + * @param setPersisted - Function to update persisted value + * @param min - Minimum allowed value + * @param max - Maximum allowed value + * @returns Object with localValue, handleChange, and handleBlur + */ +export function useClampedNumberInput( + persistedValue: number, + setPersisted: (value: number) => void, + min: number, + max: number +) { + const [localValue, setLocalValue] = React.useState(persistedValue.toString()); + + // Sync local state when persisted value changes (e.g., from other tabs) + React.useEffect(() => { + setLocalValue(persistedValue.toString()); + }, [persistedValue]); + + const handleChange = (e: React.ChangeEvent) => { + const input = e.target.value; + // Allow empty or valid partial numbers (1-3 digits for typical use) + if (input === "" || /^\d{1,3}$/.test(input)) { + setLocalValue(input); + } + }; + + const handleBlur = () => { + const num = parseInt(localValue); + + if (localValue === "" || isNaN(num)) { + // Invalid input - revert to persisted value + setLocalValue(persistedValue.toString()); + } else if (num < min) { + // Below minimum - clamp to min + setPersisted(min); + setLocalValue(min.toString()); + } else if (num > max) { + // Above maximum - clamp to max + setPersisted(max); + setLocalValue(max.toString()); + } else { + // Valid - persist the value + setPersisted(num); + setLocalValue(num.toString()); + } + }; + + return { localValue, handleChange, handleBlur }; +} diff --git a/src/browser/utils/compaction/autoCompactionCheck.ts b/src/browser/utils/compaction/autoCompactionCheck.ts index c532395c7..a2765875b 100644 --- a/src/browser/utils/compaction/autoCompactionCheck.ts +++ b/src/browser/utils/compaction/autoCompactionCheck.ts @@ -18,17 +18,15 @@ import type { WorkspaceUsageState } from "@/browser/stores/WorkspaceStore"; import { getModelStats } from "@/common/utils/tokens/modelStats"; import { supports1MContext } from "@/common/utils/ai/models"; +import { DEFAULT_AUTO_COMPACTION_THRESHOLD } from "@/common/constants/ui"; export interface AutoCompactionCheckResult { shouldShowWarning: boolean; usagePercentage: number; thresholdPercentage: number; + enabled: boolean; } -// Auto-compaction threshold (0.7 = 70%) -// TODO: Make this configurable via settings -const AUTO_COMPACTION_THRESHOLD = 0.7; - // Show warning this many percentage points before threshold const WARNING_ADVANCE_PERCENT = 10; @@ -40,27 +38,40 @@ const WARNING_ADVANCE_PERCENT = 10; * preventing infinite compaction loops after the first compaction completes. * * @param usage - Current workspace usage state (from useWorkspaceUsage) - * @param model - Current model string + * @param model - Current model string (optional - returns safe default if not provided) * @param use1M - Whether 1M context is enabled + * @param enabled - Whether auto-compaction is enabled for this workspace * @param threshold - Usage percentage threshold (0.0-1.0, default 0.7 = 70%) * @param warningAdvancePercent - Show warning this many percentage points before threshold (default 10) * @returns Check result with warning flag and usage percentage */ export function shouldAutoCompact( usage: WorkspaceUsageState | undefined, - model: string, + model: string | null | undefined, use1M: boolean, - threshold: number = AUTO_COMPACTION_THRESHOLD, + enabled = true, + threshold: number = DEFAULT_AUTO_COMPACTION_THRESHOLD, warningAdvancePercent: number = WARNING_ADVANCE_PERCENT ): AutoCompactionCheckResult { const thresholdPercentage = threshold * 100; + // Short-circuit if auto-compaction is disabled + if (!enabled || !model) { + return { + shouldShowWarning: false, + usagePercentage: 0, + thresholdPercentage, + enabled: false, + }; + } + // No usage data yet - safe default (don't trigger on first message) if (!usage || usage.usageHistory.length === 0) { return { shouldShowWarning: false, usagePercentage: 0, thresholdPercentage, + enabled: true, }; } @@ -74,6 +85,7 @@ export function shouldAutoCompact( shouldShowWarning: false, usagePercentage: 0, thresholdPercentage, + enabled: true, }; } @@ -103,5 +115,6 @@ export function shouldAutoCompact( shouldShowWarning, usagePercentage, thresholdPercentage, + enabled: true, }; } diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index bfaa80a16..e1108c0a3 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -153,6 +153,22 @@ export function getReviewSearchStateKey(workspaceId: string): string { return `reviewSearchState:${workspaceId}`; } +/** + * Get the localStorage key for auto-compaction enabled preference per workspace + * Format: "autoCompaction:enabled:{workspaceId}" + */ +export function getAutoCompactionEnabledKey(workspaceId: string): string { + return `autoCompaction:enabled:${workspaceId}`; +} + +/** + * Get the localStorage key for auto-compaction threshold percentage per workspace + * Format: "autoCompaction:threshold:{workspaceId}" + */ +export function getAutoCompactionThresholdKey(workspaceId: string): string { + return `autoCompaction:threshold:${workspaceId}`; +} + /** * List of workspace-scoped key functions that should be copied on fork and deleted on removal */ @@ -166,6 +182,8 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getReviewExpandStateKey, getFileTreeExpandStateKey, getReviewSearchStateKey, + getAutoCompactionEnabledKey, + getAutoCompactionThresholdKey, ]; /** diff --git a/src/common/constants/ui.ts b/src/common/constants/ui.ts index d038b8fef..f4b7437a5 100644 --- a/src/common/constants/ui.ts +++ b/src/common/constants/ui.ts @@ -10,6 +10,23 @@ */ export const COMPACTED_EMOJI = "📦"; +/** + * Auto-compaction threshold bounds (percentage) + * Too low risks frequent interruptions; too high risks hitting context limits + */ +export const AUTO_COMPACTION_THRESHOLD_MIN = 50; +export const AUTO_COMPACTION_THRESHOLD_MAX = 90; + +/** + * Default auto-compaction threshold percentage (50-90 range) + * Applied when creating new workspaces + */ +export const DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT = 70; + +/** + * Default threshold as decimal for calculations (0.7 = 70%) + */ +export const DEFAULT_AUTO_COMPACTION_THRESHOLD = DEFAULT_AUTO_COMPACTION_THRESHOLD_PERCENT / 100; /** * Duration (ms) to show "copied" feedback after copying to clipboard */ From 02e99b4881de4d08225c927fb844e440472d8f32 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 21 Nov 2025 13:30:14 +1100 Subject: [PATCH 11/20] fix merge conflict --- src/browser/components/AIView.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 0ab844ae7..8a11ac6db 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -37,12 +37,8 @@ import { evictModelFromLRU } from "@/browser/hooks/useModelLRU"; import { QueuedMessage } from "./Messages/QueuedMessage"; import { CompactionWarning } from "./CompactionWarning"; import { shouldAutoCompact } from "@/browser/utils/compaction/autoCompactionCheck"; -<<<<<<< HEAD import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; -======= -import { use1MContext } from "@/browser/hooks/use1MContext"; -import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings"; ->>>>>>> 52d11bd1 (🤖 feat: add auto-compaction configuration UI) +import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings"; interface AIViewProps { workspaceId: string; @@ -87,14 +83,10 @@ const AIViewInner: React.FC = ({ const workspaceState = useWorkspaceState(workspaceId); const aggregator = useWorkspaceAggregator(workspaceId); const workspaceUsage = useWorkspaceUsage(workspaceId); -<<<<<<< HEAD const { options } = useProviderOptions(); const use1M = options.anthropic?.use1MContext ?? false; -======= - const [use1M] = use1MContext(); const { enabled: autoCompactionEnabled, threshold: autoCompactionThreshold } = useAutoCompactionSettings(workspaceId); ->>>>>>> 52d11bd1 (🤖 feat: add auto-compaction configuration UI) const handledModelErrorsRef = useRef>(new Set()); useEffect(() => { From dfabd45db78db4d84f9d261f164c9a729dc5b3c6 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 21 Nov 2025 21:03:42 +1100 Subject: [PATCH 12/20] cleanup --- src/browser/components/AIView.tsx | 12 +++---- .../utils/compaction/autoCompactionCheck.ts | 34 ++++--------------- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 8a11ac6db..3a6be0e43 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -36,7 +36,7 @@ import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds"; import { evictModelFromLRU } from "@/browser/hooks/useModelLRU"; import { QueuedMessage } from "./Messages/QueuedMessage"; import { CompactionWarning } from "./CompactionWarning"; -import { shouldAutoCompact } from "@/browser/utils/compaction/autoCompactionCheck"; +import { checkAutoCompaction } from "@/browser/utils/compaction/autoCompactionCheck"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings"; @@ -331,7 +331,7 @@ const AIViewInner: React.FC = ({ // Get active stream message ID for token counting const activeStreamMessageId = aggregator.getActiveStreamMessageId(); - const autoCompactionCheck = shouldAutoCompact( + const autoCompactionResult = checkAutoCompaction( workspaceUsage, currentModel, use1M, @@ -340,7 +340,7 @@ const AIViewInner: React.FC = ({ ); // Show warning when: shouldShowWarning flag is true AND not currently compacting - const shouldShowCompactionWarning = !isCompacting && autoCompactionCheck.shouldShowWarning; + const shouldShowCompactionWarning = !isCompacting && autoCompactionResult.shouldShowWarning; // Note: We intentionally do NOT reset autoRetry when streams start. // If user pressed the interrupt key, autoRetry stays false until they manually retry. @@ -529,8 +529,8 @@ const AIViewInner: React.FC = ({
{shouldShowCompactionWarning && ( )} = ({ onEditLastUserMessage={() => void handleEditLastUserMessage()} canInterrupt={canInterrupt} onReady={handleChatInputReady} - autoCompactionCheck={autoCompactionCheck} + autoCompactionCheck={autoCompactionResult} />
diff --git a/src/browser/utils/compaction/autoCompactionCheck.ts b/src/browser/utils/compaction/autoCompactionCheck.ts index a2765875b..f25a64a28 100644 --- a/src/browser/utils/compaction/autoCompactionCheck.ts +++ b/src/browser/utils/compaction/autoCompactionCheck.ts @@ -24,7 +24,6 @@ export interface AutoCompactionCheckResult { shouldShowWarning: boolean; usagePercentage: number; thresholdPercentage: number; - enabled: boolean; } // Show warning this many percentage points before threshold @@ -45,53 +44,33 @@ const WARNING_ADVANCE_PERCENT = 10; * @param warningAdvancePercent - Show warning this many percentage points before threshold (default 10) * @returns Check result with warning flag and usage percentage */ -export function shouldAutoCompact( +export function checkAutoCompaction( usage: WorkspaceUsageState | undefined, - model: string | null | undefined, + model: string | null, use1M: boolean, - enabled = true, + enabled: boolean, threshold: number = DEFAULT_AUTO_COMPACTION_THRESHOLD, warningAdvancePercent: number = WARNING_ADVANCE_PERCENT ): AutoCompactionCheckResult { const thresholdPercentage = threshold * 100; // Short-circuit if auto-compaction is disabled - if (!enabled || !model) { + // Or if no usage data yet + if (!enabled || !model || !usage || usage.usageHistory.length === 0) { return { shouldShowWarning: false, usagePercentage: 0, thresholdPercentage, - enabled: false, - }; - } - - // No usage data yet - safe default (don't trigger on first message) - if (!usage || usage.usageHistory.length === 0) { - return { - shouldShowWarning: false, - usagePercentage: 0, - thresholdPercentage, - enabled: true, }; } // Determine max tokens for this model const modelStats = getModelStats(model); const maxTokens = use1M && supports1MContext(model) ? 1_000_000 : modelStats?.max_input_tokens; + const lastUsage = usage.usageHistory[usage.usageHistory.length - 1]; // No max tokens known - safe default (can't calculate percentage) if (!maxTokens) { - return { - shouldShowWarning: false, - usagePercentage: 0, - thresholdPercentage, - enabled: true, - }; - } - - // Use last usage entry to calculate current context size (matches UI display) - const lastUsage = usage.usageHistory[usage.usageHistory.length - 1]; - if (!lastUsage) { return { shouldShowWarning: false, usagePercentage: 0, @@ -115,6 +94,5 @@ export function shouldAutoCompact( shouldShowWarning, usagePercentage, thresholdPercentage, - enabled: true, }; } From 71eb2b26a5d6930d0a450213b28b011beeab6e62 Mon Sep 17 00:00:00 2001 From: ethan Date: Mon, 24 Nov 2025 13:52:36 +1100 Subject: [PATCH 13/20] =?UTF-8?q?=F0=9F=A4=96=20fix:=20update=20tests=20fo?= =?UTF-8?q?r=20checkAutoCompaction=20API=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename shouldAutoCompact to checkAutoCompaction in tests - Add enabled parameter to all test function calls - Remove enabled field assertions (no longer in return type) - Fix function parameter ordering for custom threshold tests - All 23 tests passing --- .../compaction/autoCompactionCheck.test.ts | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/browser/utils/compaction/autoCompactionCheck.test.ts b/src/browser/utils/compaction/autoCompactionCheck.test.ts index 8e1d26f3d..2b656cc1f 100644 --- a/src/browser/utils/compaction/autoCompactionCheck.test.ts +++ b/src/browser/utils/compaction/autoCompactionCheck.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { shouldAutoCompact } from "./autoCompactionCheck"; +import { checkAutoCompaction } from "./autoCompactionCheck"; import type { WorkspaceUsageState } from "@/browser/stores/WorkspaceStore"; import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; @@ -43,14 +43,14 @@ const createMockUsage = ( return { usageHistory, totalTokens: 0 }; }; -describe("shouldAutoCompact", () => { +describe("checkAutoCompaction", () => { const SONNET_MAX_TOKENS = 200_000; const SONNET_70_PERCENT = SONNET_MAX_TOKENS * 0.7; // 140,000 const SONNET_60_PERCENT = SONNET_MAX_TOKENS * 0.6; // 120,000 describe("Basic Functionality", () => { test("returns false when no usage data (first message)", () => { - const result = shouldAutoCompact(undefined, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(undefined, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(0); @@ -59,7 +59,7 @@ describe("shouldAutoCompact", () => { test("returns false when usage history is empty", () => { const usage: WorkspaceUsageState = { usageHistory: [], totalTokens: 0 }; - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(0); @@ -68,7 +68,7 @@ describe("shouldAutoCompact", () => { test("returns false when model has no max_input_tokens (unknown model)", () => { const usage = createMockUsage(50_000); - const result = shouldAutoCompact(usage, "unknown-model", false); + const result = checkAutoCompaction(usage, "unknown-model", false, true); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(0); @@ -77,7 +77,7 @@ describe("shouldAutoCompact", () => { test("returns false when usage is low (10%)", () => { const usage = createMockUsage(20_000); // 10% of 200k - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(10); @@ -86,7 +86,7 @@ describe("shouldAutoCompact", () => { test("returns true at warning threshold (60% with default 10% advance)", () => { const usage = createMockUsage(SONNET_60_PERCENT); - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(60); @@ -95,7 +95,7 @@ describe("shouldAutoCompact", () => { test("returns true at compaction threshold (70%)", () => { const usage = createMockUsage(SONNET_70_PERCENT); - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(70); @@ -104,7 +104,7 @@ describe("shouldAutoCompact", () => { test("returns true above threshold (80%)", () => { const usage = createMockUsage(160_000); // 80% of 200k - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(80); @@ -115,7 +115,7 @@ describe("shouldAutoCompact", () => { describe("Usage Calculation (Critical for infinite loop fix)", () => { test("uses last usage entry tokens, not cumulative sum", () => { const usage = createMockUsage(10_000); // Only 5% of context - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); // Should be 5%, not counting historical expect(result.usagePercentage).toBe(5); @@ -126,7 +126,7 @@ describe("shouldAutoCompact", () => { // Scenario: After compaction, historical = 70K, recent = 5K // Should calculate based on 5K (2.5%), not 75K (37.5%) const usage = createMockUsage(5_000, 70_000); - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.usagePercentage).toBe(2.5); expect(result.shouldShowWarning).toBe(false); @@ -148,7 +148,7 @@ describe("shouldAutoCompact", () => { totalTokens: 0, }; - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); // Total: 10k + 5k + 2k + 3k + 1k = 21k tokens = 10.5% expect(result.usagePercentage).toBe(10.5); @@ -158,7 +158,7 @@ describe("shouldAutoCompact", () => { describe("1M Context Mode", () => { test("uses 1M tokens when use1M=true and model supports it (Sonnet 4)", () => { const usage = createMockUsage(600_000); // 60% of 1M - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, true, true); expect(result.usagePercentage).toBe(60); expect(result.shouldShowWarning).toBe(true); @@ -166,7 +166,7 @@ describe("shouldAutoCompact", () => { test("uses 1M tokens for Sonnet with use1M=true (model is claude-sonnet-4-5)", () => { const usage = createMockUsage(700_000); // 70% of 1M - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, true, true); expect(result.usagePercentage).toBe(70); expect(result.shouldShowWarning).toBe(true); @@ -174,7 +174,7 @@ describe("shouldAutoCompact", () => { test("uses standard max_input_tokens when use1M=false", () => { const usage = createMockUsage(140_000); // 70% of 200k - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.usagePercentage).toBe(70); expect(result.shouldShowWarning).toBe(true); @@ -183,7 +183,7 @@ describe("shouldAutoCompact", () => { test("ignores use1M for models that don't support it (GPT)", () => { const usage = createMockUsage(100_000, undefined, KNOWN_MODELS.GPT_MINI.id); // GPT Mini has 272k context, so 100k = 36.76% - const result = shouldAutoCompact(usage, KNOWN_MODELS.GPT_MINI.id, true); + const result = checkAutoCompaction(usage, KNOWN_MODELS.GPT_MINI.id, true, true); // Should use standard 272k, not 1M (use1M ignored for GPT) expect(result.usagePercentage).toBeCloseTo(36.76, 1); @@ -194,7 +194,7 @@ describe("shouldAutoCompact", () => { describe("Edge Cases", () => { test("empty usageHistory array returns safe defaults", () => { const usage: WorkspaceUsageState = { usageHistory: [], totalTokens: 0 }; - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(0); @@ -203,7 +203,7 @@ describe("shouldAutoCompact", () => { test("single entry in usageHistory works correctly", () => { const usage = createMockUsage(140_000); - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(70); @@ -211,7 +211,7 @@ describe("shouldAutoCompact", () => { test("custom threshold parameter (80%)", () => { const usage = createMockUsage(140_000); // 70% of context - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false, 0.8); // 80% threshold + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true, 0.8); // 80% threshold // At 70%, should NOT show warning for 80% threshold (needs 70% advance = 10%) expect(result.shouldShowWarning).toBe(true); // 70% >= (80% - 10% = 70%) @@ -221,7 +221,7 @@ describe("shouldAutoCompact", () => { test("custom warning advance (5% instead of 10%)", () => { const usage = createMockUsage(130_000); // 65% of context - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false, 0.7, 5); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true, 0.7, 5); // At 65%, should show warning with 5% advance (70% - 5% = 65%) expect(result.shouldShowWarning).toBe(true); @@ -244,7 +244,7 @@ describe("shouldAutoCompact", () => { totalTokens: 0, }; - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(false); expect(result.usagePercentage).toBe(0); @@ -252,7 +252,7 @@ describe("shouldAutoCompact", () => { test("handles usage at exactly 100% of context", () => { const usage = createMockUsage(SONNET_MAX_TOKENS); - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(100); @@ -261,7 +261,7 @@ describe("shouldAutoCompact", () => { test("handles usage beyond 100% of context", () => { const usage = createMockUsage(SONNET_MAX_TOKENS + 50_000); - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.shouldShowWarning).toBe(true); expect(result.usagePercentage).toBe(125); @@ -284,14 +284,14 @@ describe("shouldAutoCompact", () => { for (const { tokens, expectedPercent } of testCases) { const usage = createMockUsage(tokens); - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.usagePercentage).toBe(expectedPercent); } }); test("handles fractional percentages correctly", () => { const usage = createMockUsage(123_456); // 61.728% - const result = shouldAutoCompact(usage, KNOWN_MODELS.SONNET.id, false); + const result = checkAutoCompaction(usage, KNOWN_MODELS.SONNET.id, false, true); expect(result.usagePercentage).toBeCloseTo(61.728, 2); expect(result.shouldShowWarning).toBe(true); // Above 60% From e41a38b0ed037d6732e9d34148694f585190d1df Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 25 Nov 2025 12:55:53 +1100 Subject: [PATCH 14/20] refactor merge commit changes --- mobile/src/utils/slashCommandHelpers.test.ts | 7 ++- mobile/src/utils/slashCommandHelpers.ts | 7 ++- src/browser/components/ChatInput/index.tsx | 61 +++++++++++-------- src/browser/hooks/useResumeManager.ts | 1 + src/browser/utils/chatCommands.test.ts | 57 ++++++++++++++++- src/browser/utils/chatCommands.ts | 25 ++++++-- src/common/types/message.ts | 2 +- src/node/services/agentSession.ts | 20 +++--- .../compactionContinueOptions.test.ts | 37 ----------- .../services/compactionContinueOptions.ts | 27 -------- 10 files changed, 130 insertions(+), 114 deletions(-) delete mode 100644 src/node/services/compactionContinueOptions.test.ts delete mode 100644 src/node/services/compactionContinueOptions.ts diff --git a/mobile/src/utils/slashCommandHelpers.test.ts b/mobile/src/utils/slashCommandHelpers.test.ts index 02a456682..402b08511 100644 --- a/mobile/src/utils/slashCommandHelpers.test.ts +++ b/mobile/src/utils/slashCommandHelpers.test.ts @@ -55,8 +55,11 @@ describe("buildMobileCompactionPayload", () => { expect(payload.metadata.parsed).toEqual({ model: "anthropic:claude-opus-4-1", maxOutputTokens: 800, - continueMessage: parsed.continueMessage, - resumeModel: baseOptions.model, + continueMessage: { + text: parsed.continueMessage, + imageParts: [], + model: baseOptions.model, + }, }); expect(payload.sendOptions.model).toBe("anthropic:claude-opus-4-1"); expect(payload.sendOptions.mode).toBe("compact"); diff --git a/mobile/src/utils/slashCommandHelpers.ts b/mobile/src/utils/slashCommandHelpers.ts index e930480d9..8f70bb4b6 100644 --- a/mobile/src/utils/slashCommandHelpers.ts +++ b/mobile/src/utils/slashCommandHelpers.ts @@ -56,8 +56,11 @@ export function buildMobileCompactionPayload( parsed: { model: parsed.model, maxOutputTokens: parsed.maxOutputTokens, - continueMessage: parsed.continueMessage, - resumeModel: baseOptions.model, + continueMessage: { + text: parsed.continueMessage ?? "", + imageParts: [], + model: baseOptions.model, + }, }, }; diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 85553a50d..5be7ccdf4 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -479,12 +479,39 @@ export const ChatInput: React.FC = (props) => { const isSlashCommand = normalizedCommandInput.startsWith("/"); const parsed = isSlashCommand ? parseCommand(normalizedCommandInput) : null; + // Prepare image parts early so slash commands can access them + const imageParts = imageAttachments.map((img, index) => { + // Validate before sending to help with debugging + if (!img.url || typeof img.url !== "string") { + console.error( + `Image attachment [${index}] has invalid url:`, + typeof img.url, + img.url?.slice(0, 50) + ); + } + if (!img.url?.startsWith("data:")) { + console.error(`Image attachment [${index}] url is not a data URL:`, img.url?.slice(0, 100)); + } + if (!img.mediaType || typeof img.mediaType !== "string") { + console.error( + `Image attachment [${index}] has invalid mediaType:`, + typeof img.mediaType, + img.mediaType + ); + } + return { + url: img.url, + mediaType: img.mediaType, + }; + }); + if (parsed) { const context: SlashCommandContext = { variant, workspaceId: variant === "workspace" ? props.workspaceId : undefined, sendMessageOptions, setInput, + setImageAttachments, setIsSending, setToast, setVimEnabled, @@ -494,6 +521,7 @@ export const ChatInput: React.FC = (props) => { onTruncateHistory: variant === "workspace" ? props.onTruncateHistory : undefined, onCancelEdit: variant === "workspace" ? props.onCancelEdit : undefined, editMessageId: editingMessage?.id, + imageParts: imageParts.length > 0 ? imageParts : undefined, resetInputHeight: () => { if (inputRef.current) { inputRef.current.style.height = "36px"; @@ -534,32 +562,6 @@ export const ChatInput: React.FC = (props) => { // Workspace variant: regular message send - // Prepare image parts if any - const imageParts = imageAttachments.map((img, index) => { - // Validate before sending to help with debugging - if (!img.url || typeof img.url !== "string") { - console.error( - `Image attachment [${index}] has invalid url:`, - typeof img.url, - img.url?.slice(0, 50) - ); - } - if (!img.url?.startsWith("data:")) { - console.error(`Image attachment [${index}] url is not a data URL:`, img.url?.slice(0, 100)); - } - if (!img.mediaType || typeof img.mediaType !== "string") { - console.error( - `Image attachment [${index}] has invalid mediaType:`, - typeof img.mediaType, - img.mediaType - ); - } - return { - url: img.url, - mediaType: img.mediaType, - }; - }); - try { // Regular message - send directly via API setIsSending(true); @@ -587,6 +589,7 @@ export const ChatInput: React.FC = (props) => { continueMessage: { text: messageText, imageParts, + model: sendMessageOptions.model, }, sendMessageOptions, }); @@ -645,7 +648,11 @@ export const ChatInput: React.FC = (props) => { } = prepareCompactionMessage({ workspaceId: props.workspaceId, maxOutputTokens: parsedEditingCommand.maxOutputTokens, - continueMessage: { text: parsedEditingCommand.continueMessage ?? "", imageParts }, + continueMessage: { + text: parsedEditingCommand.continueMessage ?? "", + imageParts, + model: sendMessageOptions.model, + }, model: parsedEditingCommand.model, sendMessageOptions, }); diff --git a/src/browser/hooks/useResumeManager.ts b/src/browser/hooks/useResumeManager.ts index 5cbc2a502..1b893936e 100644 --- a/src/browser/hooks/useResumeManager.ts +++ b/src/browser/hooks/useResumeManager.ts @@ -177,6 +177,7 @@ export function useResumeManager() { continueMessage: { text: lastUserMsg.compactionRequest.parsed.continueMessage?.text ?? "", imageParts: lastUserMsg.compactionRequest.parsed.continueMessage?.imageParts, + model: lastUserMsg.compactionRequest.parsed.continueMessage?.model ?? options.model, }, }); } diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index ae3696198..d3d20093f 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -103,12 +103,12 @@ describe("prepareCompactionMessage", () => { mode: "exec", }); - test("embeds resumeModel from base send options", () => { + test("embeds continue message model from base send options", () => { const sendMessageOptions = createBaseOptions(); const { metadata } = prepareCompactionMessage({ workspaceId: "ws-1", maxOutputTokens: 4096, - continueMessage: "Keep building", + continueMessage: { text: "Keep building" }, model: "anthropic:claude-3-5-haiku", sendMessageOptions, }); @@ -118,7 +118,7 @@ describe("prepareCompactionMessage", () => { throw new Error("Expected compaction metadata"); } - expect(metadata.parsed.resumeModel).toBe(sendMessageOptions.model); + expect(metadata.parsed.continueMessage?.model).toBe(sendMessageOptions.model); }); test("generates correct prompt text with strict summary instructions", () => { @@ -132,4 +132,55 @@ describe("prepareCompactionMessage", () => { expect(messageText).toContain("Focus entirely on the summary"); expect(messageText).toContain("Do not suggest next steps or future actions"); }); + + test("does not create continueMessage when no text or images provided", () => { + const sendMessageOptions = createBaseOptions(); + const { metadata } = prepareCompactionMessage({ + workspaceId: "ws-1", + maxOutputTokens: 4096, + sendMessageOptions, + }); + + expect(metadata.type).toBe("compaction-request"); + if (metadata.type !== "compaction-request") { + throw new Error("Expected compaction metadata"); + } + + expect(metadata.parsed.continueMessage).toBeUndefined(); + }); + + test("creates continueMessage when text is provided", () => { + const sendMessageOptions = createBaseOptions(); + const { metadata } = prepareCompactionMessage({ + workspaceId: "ws-1", + continueMessage: { text: "Continue with this" }, + sendMessageOptions, + }); + + if (metadata.type !== "compaction-request") { + throw new Error("Expected compaction metadata"); + } + + expect(metadata.parsed.continueMessage).toBeDefined(); + expect(metadata.parsed.continueMessage?.text).toBe("Continue with this"); + }); + + test("creates continueMessage when images are provided without text", () => { + const sendMessageOptions = createBaseOptions(); + const { metadata } = prepareCompactionMessage({ + workspaceId: "ws-1", + continueMessage: { + text: "", + imageParts: [{ url: "data:image/png;base64,abc", mediaType: "image/png" }], + }, + sendMessageOptions, + }); + + if (metadata.type !== "compaction-request") { + throw new Error("Expected compaction metadata"); + } + + expect(metadata.parsed.continueMessage).toBeDefined(); + expect(metadata.parsed.continueMessage?.imageParts).toHaveLength(1); + }); }); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index bf96fd1e7..273453ae6 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -542,11 +542,21 @@ export function prepareCompactionMessage(options: CompactionOptions): { const effectiveModel = resolveCompactionModel(options.model); // Create compaction metadata (will be stored in user message) + // Only include continueMessage if there's text or images to queue after compaction + const hasText = options.continueMessage?.text; + const hasImages = + options.continueMessage?.imageParts && options.continueMessage.imageParts.length > 0; const compactData: CompactionRequestData = { model: effectiveModel, maxOutputTokens: options.maxOutputTokens, - continueMessage: options.continueMessage, - resumeModel: options.sendMessageOptions.model, + continueMessage: + hasText || hasImages + ? { + text: options.continueMessage?.text ?? "", + imageParts: options.continueMessage?.imageParts, + model: options.continueMessage?.model ?? options.sendMessageOptions.model, + } + : undefined, }; const metadata: MuxFrontendMetadata = { @@ -740,9 +750,14 @@ export async function handleCompactCommand( const result = await executeCompaction({ workspaceId, maxOutputTokens: parsed.maxOutputTokens, - continueMessage: parsed.continueMessage - ? { text: parsed.continueMessage, imageParts: context.imageParts } - : undefined, + continueMessage: + parsed.continueMessage || (context.imageParts && context.imageParts.length > 0) + ? { + text: parsed.continueMessage ?? "", + imageParts: context.imageParts, + model: sendMessageOptions.model, + } + : undefined, model: parsed.model, sendMessageOptions, editMessageId, diff --git a/src/common/types/message.ts b/src/common/types/message.ts index cf0de4875..cfb11bea7 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -9,7 +9,7 @@ import type { ImagePart } from "./ipc"; export interface ContinueMessage { text: string; imageParts?: ImagePart[]; - model: string; + model?: string; } // Parsed compaction request data (shared type for consistency) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 6f72665e0..b393d3a99 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -23,7 +23,7 @@ import { Ok, Err } from "@/common/types/result"; import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { MessageQueue } from "./messageQueue"; -import { buildContinueMessageOptions } from "./compactionContinueOptions"; + import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; import { CompactionHandler } from "./compactionHandler"; @@ -336,16 +336,16 @@ export class AgentSession { // If this is a compaction request with a continue message, queue it for auto-send after compaction const muxMeta = options?.muxMetadata; if (muxMeta?.type === "compaction-request" && muxMeta.parsed.continueMessage && options) { - // Strip out edit-specific and compaction-specific fields so the queued message is a fresh user message - const { muxMetadata, mode, editMessageId, imageParts, ...rest } = options; - const baseContinueOptions: SendMessageOptions = { ...rest }; - const sanitizedOptions = buildContinueMessageOptions( - baseContinueOptions, - muxMeta.parsed.continueMessage.model - ); + // Strip out compaction-specific fields so the queued message is a fresh user message + const { muxMetadata, mode, editMessageId, imageParts, maxOutputTokens, ...rest } = options; + const sanitizedOptions: SendMessageOptions = { + ...rest, + model: muxMeta.parsed.continueMessage.model ?? rest.model, + }; + const continueImageParts = muxMeta.parsed.continueMessage.imageParts; const continuePayload = - imageParts && imageParts.length > 0 - ? { ...sanitizedOptions, imageParts } + continueImageParts && continueImageParts.length > 0 + ? { ...sanitizedOptions, imageParts: continueImageParts } : sanitizedOptions; this.messageQueue.add(muxMeta.parsed.continueMessage.text, continuePayload); this.emitQueuedMessageChanged(); diff --git a/src/node/services/compactionContinueOptions.test.ts b/src/node/services/compactionContinueOptions.test.ts deleted file mode 100644 index 3593a0b75..000000000 --- a/src/node/services/compactionContinueOptions.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import type { SendMessageOptions } from "@/common/types/ipc"; -import { buildContinueMessageOptions } from "./compactionContinueOptions"; - -const baseOptions = (): SendMessageOptions => ({ - model: "anthropic:claude-3-5-sonnet", - thinkingLevel: "medium", - toolPolicy: [], - additionalSystemInstructions: "be helpful", - mode: "compact", - maxOutputTokens: 2048, -}); - -describe("buildContinueMessageOptions", () => { - it("uses resumeModel when provided and drops compact overrides", () => { - const options = baseOptions(); - const result = buildContinueMessageOptions(options, "anthropic:claude-3-5-haiku"); - - expect(result).not.toBe(options); - expect(result.model).toBe("anthropic:claude-3-5-haiku"); - expect(result.mode).toBeUndefined(); - expect(result.maxOutputTokens).toBeUndefined(); - expect(result.thinkingLevel).toBe("medium"); - expect(result.toolPolicy).toEqual([]); - // Ensure original options untouched - expect(options.model).toBe("anthropic:claude-3-5-sonnet"); - expect(options.mode).toBe("compact"); - expect(options.maxOutputTokens).toBe(2048); - }); - - it("falls back to compaction model when resumeModel is missing", () => { - const options = baseOptions(); - const result = buildContinueMessageOptions(options); - - expect(result.model).toBe(options.model); - }); -}); diff --git a/src/node/services/compactionContinueOptions.ts b/src/node/services/compactionContinueOptions.ts deleted file mode 100644 index 5c38fa694..000000000 --- a/src/node/services/compactionContinueOptions.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SendMessageOptions } from "@/common/types/ipc"; - -/** - * Build sanitized SendMessageOptions for auto-continue messages after compaction. - * - * - Drops compaction-specific overrides (mode="compact", maxOutputTokens) - * - Removes frontend metadata (muxMetadata) - * - Restores the original workspace model when provided - */ -export function buildContinueMessageOptions( - options: SendMessageOptions, - resumeModel?: string -): SendMessageOptions { - const { - muxMetadata: _ignoredMetadata, - maxOutputTokens: _ignoredMaxOutputTokens, - mode: _ignoredMode, - ...rest - } = options; - - const nextModel = resumeModel ?? options.model; - - return { - ...rest, - model: nextModel, - }; -} From 17262687c51765dc9088b5b0c5ab0cf63c1eee95 Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 25 Nov 2025 13:01:29 +1100 Subject: [PATCH 15/20] fixup --- src/browser/components/CompactionWarning.tsx | 2 +- src/browser/utils/chatCommands.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/browser/components/CompactionWarning.tsx b/src/browser/components/CompactionWarning.tsx index a48711200..0216ad81a 100644 --- a/src/browser/components/CompactionWarning.tsx +++ b/src/browser/components/CompactionWarning.tsx @@ -25,7 +25,7 @@ export const CompactionWarning: React.FC<{ if (willCompactNext) { return (
- ⚠️ Context limit reached. Next message will trigger auto-compaction. + ⚠️ Context limit reached. Next message will trigger Auto-Compaction.
); } diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 273453ae6..2acc50671 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -609,6 +609,9 @@ function formatCompactionCommand(options: CompactionOptions): string { if (options.model) { cmd += ` -m ${options.model}`; } + if (options.continueMessage) { + cmd += `\n${options.continueMessage.text}`; + } return cmd; } From b81219a8dd4a5f78bf73da92132acd15778e9219 Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 25 Nov 2025 13:22:09 +1100 Subject: [PATCH 16/20] use pending model for auto compact check --- src/browser/components/AIView.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index b8a5d3ab3..25a44c693 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -38,6 +38,7 @@ import { QueuedMessage } from "./Messages/QueuedMessage"; import { CompactionWarning } from "./CompactionWarning"; import { shouldAutoCompact } from "@/browser/utils/compaction/autoCompactionCheck"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; +import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; interface AIViewProps { workspaceId: string; @@ -328,8 +329,14 @@ const AIViewInner: React.FC = ({ // Get active stream message ID for token counting const activeStreamMessageId = aggregator.getActiveStreamMessageId(); - const autoCompactionCheck = currentModel - ? shouldAutoCompact(workspaceUsage, currentModel, use1M) + // Use pending send model for auto-compaction check, not the last stream's model. + // This ensures the threshold is based on the model the user will actually send with, + // preventing context-length errors when switching from a large-context to smaller model. + const pendingSendOptions = useSendMessageOptions(workspaceId); + const pendingModel = pendingSendOptions.model; + + const autoCompactionCheck = pendingModel + ? shouldAutoCompact(workspaceUsage, pendingModel, use1M) : { shouldShowWarning: false, usagePercentage: 0, thresholdPercentage: 70 }; // Show warning when: shouldShowWarning flag is true AND not currently compacting From 9ac09311328ee47a7046b944f9e2ee7103c27f80 Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 25 Nov 2025 13:25:52 +1100 Subject: [PATCH 17/20] fixup --- src/browser/components/AIView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 25a44c693..fad0738bf 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -141,6 +141,9 @@ const AIViewInner: React.FC = ({ markUserInteraction, } = useAutoScroll(); + // Use send options for auto-compaction check + const pendingSendOptions = useSendMessageOptions(workspaceId); + // ChatInput API for focus management const chatInputAPI = useRef(null); const handleChatInputReady = useCallback((api: ChatInputAPI) => { @@ -332,7 +335,6 @@ const AIViewInner: React.FC = ({ // Use pending send model for auto-compaction check, not the last stream's model. // This ensures the threshold is based on the model the user will actually send with, // preventing context-length errors when switching from a large-context to smaller model. - const pendingSendOptions = useSendMessageOptions(workspaceId); const pendingModel = pendingSendOptions.model; const autoCompactionCheck = pendingModel From da1e3ae03646df9fe8a72fdfaf50bdecfc619a2e Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 25 Nov 2025 13:33:06 +1100 Subject: [PATCH 18/20] fix mobile --- mobile/src/utils/slashCommandHelpers.test.ts | 23 ++++++++++++++++++++ mobile/src/utils/slashCommandHelpers.ts | 12 +++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/mobile/src/utils/slashCommandHelpers.test.ts b/mobile/src/utils/slashCommandHelpers.test.ts index 402b08511..d556086db 100644 --- a/mobile/src/utils/slashCommandHelpers.test.ts +++ b/mobile/src/utils/slashCommandHelpers.test.ts @@ -65,4 +65,27 @@ describe("buildMobileCompactionPayload", () => { expect(payload.sendOptions.mode).toBe("compact"); expect(payload.sendOptions.maxOutputTokens).toBe(800); }); + + it("omits continueMessage when no text provided", () => { + const baseOptions: SendMessageOptions = { + model: "anthropic:claude-sonnet-4-5", + mode: "plan", + thinkingLevel: "default", + }; + + const parsed = { + type: "compact" as const, + maxOutputTokens: 1000, + continueMessage: undefined, + model: undefined, + }; + + const payload = buildMobileCompactionPayload(parsed, baseOptions); + + if (payload.metadata.type !== "compaction-request") { + throw new Error("Expected compaction metadata"); + } + + expect(payload.metadata.parsed.continueMessage).toBeUndefined(); + }); }); diff --git a/mobile/src/utils/slashCommandHelpers.ts b/mobile/src/utils/slashCommandHelpers.ts index 8f70bb4b6..ea67bd061 100644 --- a/mobile/src/utils/slashCommandHelpers.ts +++ b/mobile/src/utils/slashCommandHelpers.ts @@ -56,11 +56,13 @@ export function buildMobileCompactionPayload( parsed: { model: parsed.model, maxOutputTokens: parsed.maxOutputTokens, - continueMessage: { - text: parsed.continueMessage ?? "", - imageParts: [], - model: baseOptions.model, - }, + continueMessage: parsed.continueMessage + ? { + text: parsed.continueMessage, + imageParts: [], + model: baseOptions.model, + } + : undefined, }, }; From 2456a85a611c26d9834e134bb27700c80ab22c0b Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 25 Nov 2025 13:47:52 +1100 Subject: [PATCH 19/20] call onMessageSent --- src/browser/components/ChatInput/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 5be7ccdf4..5a4a3f4c5 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -610,6 +610,7 @@ export const ChatInput: React.FC = (props) => { type: "success", message: `Context threshold reached - auto-compacting...`, }); + props.onMessageSent?.(); } } catch (error) { // Restore on unexpected error From 0a5c9383a32f6649fbdfcace91166188f3976e27 Mon Sep 17 00:00:00 2001 From: ethan Date: Tue, 25 Nov 2025 14:12:57 +1100 Subject: [PATCH 20/20] fixup --- src/browser/components/AIView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index c4e5d567b..1ce697f21 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -342,7 +342,7 @@ const AIViewInner: React.FC = ({ const autoCompactionResult = checkAutoCompaction( workspaceUsage, - currentModel, + pendingModel, use1M, autoCompactionEnabled, autoCompactionThreshold / 100