From 5df7ade35e10ba0f7f875c3ff04a10bbe047d163 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 18:40:04 -0500 Subject: [PATCH 01/37] =?UTF-8?q?=F0=9F=A4=96=20Simplify=20compaction=20at?= =?UTF-8?q?=20protocol=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove compact_summary tool and replace with direct text streaming: - Model now streams summary text directly instead of using a tool call - Removed compact_summary tool definition and service implementation - Updated StreamingMessageAggregator to extract text from compaction streams - Updated WorkspaceStore to handle compaction on stream-end instead of tool completion - Removed toolPolicy requirement forcing compact_summary tool usage - Updated tests and mock scenarios to reflect new flow Benefits: 1. Live feedback: Summary streams as it's being generated (visible to user) 2. Context efficiency: No duplicate text in tool calls and assistant message 3. Simpler protocol: Fewer moving parts, less code to maintain Generated with cmux --- src/services/mock/scenarios/slashCommands.ts | 43 ++----------------- src/services/tools/compact_summary.ts | 24 ----------- src/stores/WorkspaceStore.ts | 43 +++++++++++-------- .../messages/StreamingMessageAggregator.ts | 17 ++++++++ src/utils/messages/compactionOptions.test.ts | 5 ++- src/utils/messages/compactionOptions.ts | 1 - src/utils/tools/toolDefinitions.ts | 12 ------ src/utils/tools/tools.ts | 2 - src/utils/ui/modeUtils.ts | 2 - 9 files changed, 48 insertions(+), 101 deletions(-) delete mode 100644 src/services/tools/compact_summary.ts diff --git a/src/services/mock/scenarios/slashCommands.ts b/src/services/mock/scenarios/slashCommands.ts index 59d82c726..3dd6ca4ac 100644 --- a/src/services/mock/scenarios/slashCommands.ts +++ b/src/services/mock/scenarios/slashCommands.ts @@ -29,36 +29,11 @@ const compactConversationTurn: ScenarioTurn = { { kind: "stream-delta", delay: STREAM_BASE_DELAY, - text: "Preparing a compact technical summary based on prior tool results…\n", - }, - { - kind: "tool-start", - delay: STREAM_BASE_DELAY * 2, - toolCallId: "tool-compact-summary", - toolName: "compact_summary", - args: { - targetWords: 385, - }, - }, - { - kind: "tool-end", - delay: STREAM_BASE_DELAY * 3, - toolCallId: "tool-compact-summary", - toolName: "compact_summary", - result: { - success: true, - summary: COMPACT_SUMMARY_TEXT, - message: "Summary generated successfully.", - }, - }, - { - kind: "stream-delta", - delay: STREAM_BASE_DELAY * 3 + 100, - text: "Summary ready. Replacing history with the compacted version now.", + text: COMPACT_SUMMARY_TEXT, }, { kind: "stream-end", - delay: STREAM_BASE_DELAY * 4, + delay: STREAM_BASE_DELAY * 2, metadata: { model: "anthropic:claude-sonnet-4-5", inputTokens: 220, @@ -68,19 +43,7 @@ const compactConversationTurn: ScenarioTurn = { parts: [ { type: "text", - text: "Summary ready. Replacing history with the compacted version now.", - }, - { - type: "dynamic-tool", - toolCallId: "tool-compact-summary", - toolName: "compact_summary", - state: "output-available", - input: { - targetWords: 385, - }, - output: { - summary: COMPACT_SUMMARY_TEXT, - }, + text: COMPACT_SUMMARY_TEXT, }, ], }, diff --git a/src/services/tools/compact_summary.ts b/src/services/tools/compact_summary.ts deleted file mode 100644 index e75260794..000000000 --- a/src/services/tools/compact_summary.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { tool } from "ai"; -import type { ToolFactory } from "@/utils/tools/tools"; -import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions"; - -/** - * Compact summary tool factory for context compaction - * Creates a tool that allows the AI to provide a conversation summary - * @param config Required configuration (not used for this tool, but required by interface) - */ -export const createCompactSummaryTool: ToolFactory = () => { - return tool({ - description: TOOL_DEFINITIONS.compact_summary.description, - inputSchema: TOOL_DEFINITIONS.compact_summary.schema, - execute: ({ summary }) => { - // Tool execution is a no-op on the backend - // The summary is intercepted by the frontend and used to replace history - return Promise.resolve({ - success: true, - summary, - message: "Summary generated successfully.", - }); - }, - }); -}; diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index f48171575..974801da4 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -415,30 +415,38 @@ export class WorkspaceStore { * Handle compact_summary tool completion. * Returns true if compaction was handled (caller should early return). */ - private handleCompactSummaryCompletion( + private handleCompactionCompletion( workspaceId: string, aggregator: StreamingMessageAggregator, data: WorkspaceChatMessage ): boolean { - // Type guard: only StreamEndEvent has parts - if (!("parts" in data) || !data.parts) return false; - - for (const part of data.parts) { - if (part.type === "dynamic-tool" && part.toolName === "compact_summary") { - const output = part.output as { summary?: string } | undefined; - if (output?.summary) { - this.performCompaction(workspaceId, aggregator, data, output.summary); - return true; - } - break; - } + // Type guard: only StreamEndEvent has messageId + if (!("messageId" in data)) return false; + + // Check if this was a compaction stream by looking at the last user message + const messages = aggregator.getAllMessages(); + const lastUserMsg = [...messages] + .reverse() + .find((m) => m.role === "user"); + + if (lastUserMsg?.metadata?.cmuxMetadata?.type !== "compaction-request") { + return false; + } + + // Extract the summary text from the assistant's response + const summary = aggregator.getCompactionSummary(data.messageId); + if (!summary) { + console.warn("[WorkspaceStore] Compaction completed but no summary text found"); + return false; } - return false; + + this.performCompaction(workspaceId, aggregator, data, summary); + return true; } /** * Perform history compaction by replacing chat history with summary message. - * Type-safe: only called when we've verified data has parts (i.e., StreamEndEvent). + * Type-safe: only called when we've verified data is a StreamEndEvent. */ private performCompaction( workspaceId: string, @@ -446,7 +454,6 @@ export class WorkspaceStore { data: WorkspaceChatMessage, summary: string ): void { - // We know data is StreamEndEvent because handleCompactSummaryCompletion verified it has parts // Extract metadata safely with type guard const metadata = "metadata" in data ? data.metadata : undefined; @@ -781,8 +788,8 @@ export class WorkspaceStore { aggregator.handleStreamEnd(data); aggregator.clearTokenState(data.messageId); - // Early return if compact_summary handled (async replacement in progress) - if (this.handleCompactSummaryCompletion(workspaceId, aggregator, data)) { + // Early return if compaction handled (async replacement in progress) + if (this.handleCompactionCompletion(workspaceId, aggregator, data)) { return; } diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index 7959ba8da..fb82c2ffb 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -92,6 +92,23 @@ export class StreamingMessageAggregator { return this.currentTodos; } + /** + * Extract compaction summary text from a completed assistant message. + * Used when a compaction stream completes to get the summary for history replacement. + * @param messageId The ID of the assistant message to extract text from + * @returns The concatenated text from all text parts, or undefined if message not found + */ + getCompactionSummary(messageId: string): string | undefined { + const message = this.messages.get(messageId); + if (!message) return undefined; + + // Concatenate all text parts (ignore tool calls and reasoning) + return message.parts + .filter((part): part is { type: "text"; text: string } => part.type === "text") + .map((part) => part.text) + .join(""); + } + /** * Clean up stream-scoped state when stream ends (normally or abnormally). * Called by handleStreamEnd, handleStreamAbort, and handleStreamError. diff --git a/src/utils/messages/compactionOptions.test.ts b/src/utils/messages/compactionOptions.test.ts index 3b78b9f41..0eefa8011 100644 --- a/src/utils/messages/compactionOptions.test.ts +++ b/src/utils/messages/compactionOptions.test.ts @@ -58,12 +58,13 @@ describe("applyCompactionOverrides", () => { expect(result.maxOutputTokens).toBe(8000); }); - it("sets compact mode and tool policy", () => { + it("sets compact mode", () => { const compactData: CompactionRequestData = {}; const result = applyCompactionOverrides(baseOptions, compactData); expect(result.mode).toBe("compact"); - expect(result.toolPolicy).toEqual([{ regex_match: "compact_summary", action: "require" }]); + // No special toolPolicy for compaction - uses base options + expect(result.toolPolicy).toEqual([]); }); it("applies all overrides together", () => { diff --git a/src/utils/messages/compactionOptions.ts b/src/utils/messages/compactionOptions.ts index 97809b20a..6f4d6cc40 100644 --- a/src/utils/messages/compactionOptions.ts +++ b/src/utils/messages/compactionOptions.ts @@ -34,7 +34,6 @@ export function applyCompactionOverrides( ...baseOptions, model: compactionModel, thinkingLevel: isAnthropic ? "off" : baseOptions.thinkingLevel, - toolPolicy: [{ regex_match: "compact_summary", action: "require" }], maxOutputTokens: compactData.maxOutputTokens, mode: "compact" as const, }; diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index 36288f17e..edbe57245 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -141,17 +141,6 @@ export const TOOL_DEFINITIONS = { ), }), }, - compact_summary: { - description: - "Summarize the conversation history into a compact form. This tool is used during context compaction to reduce token usage while preserving key information.", - schema: z.object({ - summary: z - .string() - .describe( - "Compact summary of the conversation, preserving key decisions, context, and important details. Include enough information for the conversation to continue meaningfully." - ), - }), - }, todo_write: { description: "Create or update the todo list for tracking multi-step tasks (limit: 7 items). " + @@ -229,7 +218,6 @@ export function getAvailableTools(modelString: string): string[] { // "file_edit_replace_lines", // DISABLED: causes models to break repo state "file_edit_insert", "propose_plan", - "compact_summary", "todo_write", "todo_read", ]; diff --git a/src/utils/tools/tools.ts b/src/utils/tools/tools.ts index 923ccfb6b..b924dbd9f 100644 --- a/src/utils/tools/tools.ts +++ b/src/utils/tools/tools.ts @@ -5,7 +5,6 @@ import { createFileEditReplaceStringTool } from "@/services/tools/file_edit_repl // DISABLED: import { createFileEditReplaceLinesTool } from "@/services/tools/file_edit_replace_lines"; import { createFileEditInsertTool } from "@/services/tools/file_edit_insert"; import { createProposePlanTool } from "@/services/tools/propose_plan"; -import { createCompactSummaryTool } from "@/services/tools/compact_summary"; import { createTodoWriteTool, createTodoReadTool } from "@/services/tools/todo"; import { log } from "@/services/log"; @@ -58,7 +57,6 @@ export async function getToolsForModel( file_edit_insert: createFileEditInsertTool(config), bash: createBashTool(config), propose_plan: createProposePlanTool(config), - compact_summary: createCompactSummaryTool(config), todo_write: createTodoWriteTool(config), todo_read: createTodoReadTool(config), }; diff --git a/src/utils/ui/modeUtils.ts b/src/utils/ui/modeUtils.ts index 0d9d10452..7f2e27af4 100644 --- a/src/utils/ui/modeUtils.ts +++ b/src/utils/ui/modeUtils.ts @@ -19,7 +19,6 @@ export function modeToToolPolicy(mode: UIMode): ToolPolicy { if (mode === "plan") { return [ { regex_match: "file_edit_.*", action: "disable" }, - { regex_match: "compact_summary", action: "disable" }, { regex_match: "propose_plan", action: "enable" }, ]; } @@ -27,7 +26,6 @@ export function modeToToolPolicy(mode: UIMode): ToolPolicy { // exec mode return [ { regex_match: "propose_plan", action: "disable" }, - { regex_match: "compact_summary", action: "disable" }, { regex_match: "file_edit_.*", action: "enable" }, ]; } From 1b0779df7c620b578532aaa9eca5bdf71dfd2f69 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 18:42:56 -0500 Subject: [PATCH 02/37] Fix formatting --- src/stores/WorkspaceStore.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 974801da4..b0c10ec50 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -425,10 +425,8 @@ export class WorkspaceStore { // Check if this was a compaction stream by looking at the last user message const messages = aggregator.getAllMessages(); - const lastUserMsg = [...messages] - .reverse() - .find((m) => m.role === "user"); - + const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); + if (lastUserMsg?.metadata?.cmuxMetadata?.type !== "compaction-request") { return false; } From 8f5938ed782560f26346b8f60df1ce64d95fb5ad Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 18:46:54 -0500 Subject: [PATCH 03/37] Update E2E test for new compaction flow --- tests/e2e/scenarios/slashCommands.spec.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/e2e/scenarios/slashCommands.spec.ts b/tests/e2e/scenarios/slashCommands.spec.ts index b1544a8fe..4dddc1b72 100644 --- a/tests/e2e/scenarios/slashCommands.spec.ts +++ b/tests/e2e/scenarios/slashCommands.spec.ts @@ -81,7 +81,7 @@ test.describe("slash command flows", () => { }); } - const timeline = await ui.chat.captureStreamTimeline( + await ui.chat.captureStreamTimeline( async () => { await ui.chat.sendMessage("/compact -t 500"); }, @@ -89,11 +89,9 @@ test.describe("slash command flows", () => { ); await ui.chat.expectStatusMessageContains("Compaction started"); - const compactToolStart = timeline.events.find( - (event) => event.type === "tool-call-start" && event.toolName === "compact_summary" - ); - expect(compactToolStart).toBeDefined(); - + + // Compaction now uses direct text streaming instead of a tool call + // Verify the summary text appears in the transcript const transcript = page.getByRole("log", { name: "Conversation transcript" }); await ui.chat.expectTranscriptContains(COMPACT_SUMMARY_TEXT); await expect(transcript).toContainText(COMPACT_SUMMARY_TEXT); From 58d0695214048423ad71c2b999a4c4b4df21cab6 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 18:48:31 -0500 Subject: [PATCH 04/37] Fix formatting --- tests/e2e/scenarios/slashCommands.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/scenarios/slashCommands.spec.ts b/tests/e2e/scenarios/slashCommands.spec.ts index 4dddc1b72..b118633ec 100644 --- a/tests/e2e/scenarios/slashCommands.spec.ts +++ b/tests/e2e/scenarios/slashCommands.spec.ts @@ -89,7 +89,7 @@ test.describe("slash command flows", () => { ); await ui.chat.expectStatusMessageContains("Compaction started"); - + // Compaction now uses direct text streaming instead of a tool call // Verify the summary text appears in the transcript const transcript = page.getByRole("log", { name: "Conversation transcript" }); From da41f63c608c33d509ac0a41b249d0a4d0171347 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:20:14 -0500 Subject: [PATCH 05/37] =?UTF-8?q?=F0=9F=A4=96=20Style=20compaction=20strea?= =?UTF-8?q?ming=20with=20visual=20effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add special styling for compaction messages as they stream: New components (isolated complexity): - CompactionBackground: Animated shimmer effect during compaction - CompactingMessageContent: Container with max-height (300px) and sticky scroll Changes: - Added --color-plan-mode-rgb CSS variable for alpha blending - Pass isCompacting prop through MessageRenderer to AssistantMessage - Wrap streaming compaction content in special container with animation - Auto-scroll to bottom as compaction text streams in - Custom scrollbar styling with plan-mode colors UX improvements: - Visual distinction for compaction streams - Constrained height prevents overwhelming UI - Smooth shimmer animation provides feedback - Sticky scroll keeps latest content visible Generated with cmux --- src/components/Messages/AssistantMessage.tsx | 13 +++- .../Messages/CompactingMessageContent.tsx | 63 +++++++++++++++++++ .../Messages/CompactionBackground.tsx | 36 +++++++++++ src/components/Messages/MessageRenderer.tsx | 7 ++- src/styles/colors.tsx | 1 + 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 src/components/Messages/CompactingMessageContent.tsx create mode 100644 src/components/Messages/CompactionBackground.tsx diff --git a/src/components/Messages/AssistantMessage.tsx b/src/components/Messages/AssistantMessage.tsx index 7a99e86ae..99de6e07b 100644 --- a/src/components/Messages/AssistantMessage.tsx +++ b/src/components/Messages/AssistantMessage.tsx @@ -8,6 +8,7 @@ import { MessageWindow } from "./MessageWindow"; import { useStartHere } from "@/hooks/useStartHere"; import { COMPACTED_EMOJI } from "@/constants/ui"; import { ModelDisplay } from "./ModelDisplay"; +import { CompactingMessageContent } from "./CompactingMessageContent"; const RawContent = styled.pre` font-family: var(--font-monospace); @@ -49,6 +50,7 @@ interface AssistantMessageProps { message: DisplayedMessage & { type: "assistant" }; className?: string; workspaceId?: string; + isCompacting?: boolean; clipboardWriteText?: (data: string) => Promise; } @@ -56,6 +58,7 @@ export const AssistantMessage: React.FC = ({ message, className, workspaceId, + isCompacting = false, clipboardWriteText = (data: string) => navigator.clipboard.writeText(data), }) => { const [showRaw, setShowRaw] = useState(false); @@ -64,6 +67,7 @@ export const AssistantMessage: React.FC = ({ const content = message.content; const isStreaming = message.isStreaming; const isCompacted = message.isCompacted; + const isStreamingCompaction = isStreaming && isCompacting; // Use Start Here hook for final assistant messages const { @@ -120,7 +124,14 @@ export const AssistantMessage: React.FC = ({ // Streaming text gets typewriter effect if (isStreaming) { - return ; + const contentElement = ; + + // Wrap streaming compaction in special container + if (isStreamingCompaction) { + return {contentElement}; + } + + return contentElement; } // Completed text renders as static content diff --git a/src/components/Messages/CompactingMessageContent.tsx b/src/components/Messages/CompactingMessageContent.tsx new file mode 100644 index 000000000..36a9dee5e --- /dev/null +++ b/src/components/Messages/CompactingMessageContent.tsx @@ -0,0 +1,63 @@ +import React, { useRef, useEffect } from "react"; +import styled from "@emotion/styled"; +import { CompactionBackground } from "./CompactionBackground"; + +/** + * Wrapper for compaction streaming content + * Provides max-height constraint with sticky scroll to bottom + */ + +const Container = styled.div` + position: relative; + max-height: 300px; + overflow-y: auto; + overflow-x: hidden; + + /* Subtle indicator that content is scrollable */ + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(var(--color-plan-mode-rgb), 0.3); + border-radius: 4px; + + &:hover { + background: rgba(var(--color-plan-mode-rgb), 0.5); + } + } +`; + +const ContentWrapper = styled.div` + position: relative; + z-index: 1; +`; + +interface CompactingMessageContentProps { + children: React.ReactNode; +} + +export const CompactingMessageContent: React.FC = ({ + children, +}) => { + const containerRef = useRef(null); + + // Auto-scroll to bottom as content streams in + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [children]); + + return ( + + + {children} + + ); +}; diff --git a/src/components/Messages/CompactionBackground.tsx b/src/components/Messages/CompactionBackground.tsx new file mode 100644 index 000000000..d10f96487 --- /dev/null +++ b/src/components/Messages/CompactionBackground.tsx @@ -0,0 +1,36 @@ +import styled from "@emotion/styled"; +import { keyframes } from "@emotion/react"; + +/** + * Animated background for compaction streaming + * Isolated component for visual effect during compaction + */ + +const shimmer = keyframes` + 0% { + background-position: -200% center; + } + 100% { + background-position: 200% center; + } +`; + +export const CompactionBackground = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(var(--color-plan-mode-rgb), 0.08) 25%, + rgba(var(--color-plan-mode-rgb), 0.15) 50%, + rgba(var(--color-plan-mode-rgb), 0.08) 75%, + transparent 100% + ); + background-size: 200% 100%; + animation: ${shimmer} 3s ease-in-out infinite; + pointer-events: none; + border-radius: 6px; +`; diff --git a/src/components/Messages/MessageRenderer.tsx b/src/components/Messages/MessageRenderer.tsx index 6f8f9f2ca..e8824e750 100644 --- a/src/components/Messages/MessageRenderer.tsx +++ b/src/components/Messages/MessageRenderer.tsx @@ -31,7 +31,12 @@ export const MessageRenderer = React.memo( ); case "assistant": return ( - + ); case "tool": return ; diff --git a/src/styles/colors.tsx b/src/styles/colors.tsx index 82ef85cae..25de72911 100644 --- a/src/styles/colors.tsx +++ b/src/styles/colors.tsx @@ -17,6 +17,7 @@ export const GlobalColors = () => ( :root { /* Plan Mode Colors (Blue) */ --color-plan-mode: hsl(210 70% 40%); + --color-plan-mode-rgb: 31, 107, 184; /* RGB equivalent for alpha blending */ --color-plan-mode-hover: color-mix(in srgb, var(--color-plan-mode), white 20%); --color-plan-mode-light: color-mix(in srgb, var(--color-plan-mode) 60%, white); --color-plan-mode-alpha: hsl(from var(--color-plan-mode) h s l / 0.1); From d37192251f0196693761beb40d006d236842a3d0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:20:30 -0500 Subject: [PATCH 06/37] Fix formatting --- src/components/Messages/AssistantMessage.tsx | 4 ++-- src/components/Messages/CompactingMessageContent.tsx | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/Messages/AssistantMessage.tsx b/src/components/Messages/AssistantMessage.tsx index 99de6e07b..3fdcbaaf3 100644 --- a/src/components/Messages/AssistantMessage.tsx +++ b/src/components/Messages/AssistantMessage.tsx @@ -125,12 +125,12 @@ export const AssistantMessage: React.FC = ({ // Streaming text gets typewriter effect if (isStreaming) { const contentElement = ; - + // Wrap streaming compaction in special container if (isStreamingCompaction) { return {contentElement}; } - + return contentElement; } diff --git a/src/components/Messages/CompactingMessageContent.tsx b/src/components/Messages/CompactingMessageContent.tsx index 36a9dee5e..c70f921eb 100644 --- a/src/components/Messages/CompactingMessageContent.tsx +++ b/src/components/Messages/CompactingMessageContent.tsx @@ -12,21 +12,21 @@ const Container = styled.div` max-height: 300px; overflow-y: auto; overflow-x: hidden; - + /* Subtle indicator that content is scrollable */ &::-webkit-scrollbar { width: 8px; } - + &::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.1); border-radius: 4px; } - + &::-webkit-scrollbar-thumb { background: rgba(var(--color-plan-mode-rgb), 0.3); border-radius: 4px; - + &:hover { background: rgba(var(--color-plan-mode-rgb), 0.5); } @@ -42,9 +42,7 @@ interface CompactingMessageContentProps { children: React.ReactNode; } -export const CompactingMessageContent: React.FC = ({ - children, -}) => { +export const CompactingMessageContent: React.FC = ({ children }) => { const containerRef = useRef(null); // Auto-scroll to bottom as content streams in From 8b166340bac0fbc33775ff5c414fe38a8452bb93 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:25:42 -0500 Subject: [PATCH 07/37] =?UTF-8?q?=F0=9F=A4=96=20Fix=20compaction=20backgro?= =?UTF-8?q?und=20to=20cover=20full=20message=20area?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move CompactionBackground from content wrapper to MessageWindow level: - Added backgroundEffect prop to MessageWindow for flexible background effects - Set MessageBlock to position: relative for absolute background positioning - Added z-index to MessageHeader and MessageContent to layer above background - CompactionBackground now covers entire message area, not just text content - Removed CompactionBackground from CompactingMessageContent (now at window level) This fixes the uncomfortable rectangular border effect by extending the shimmer animation to cover the full message window. Generated with cmux --- src/components/Messages/AssistantMessage.tsx | 2 ++ .../Messages/CompactingMessageContent.tsx | 14 +------------- src/components/Messages/MessageWindow.tsx | 8 ++++++++ src/test-temp-x2vNLl/test-repo | 1 + 4 files changed, 12 insertions(+), 13 deletions(-) create mode 160000 src/test-temp-x2vNLl/test-repo diff --git a/src/components/Messages/AssistantMessage.tsx b/src/components/Messages/AssistantMessage.tsx index 3fdcbaaf3..e0c9071d0 100644 --- a/src/components/Messages/AssistantMessage.tsx +++ b/src/components/Messages/AssistantMessage.tsx @@ -9,6 +9,7 @@ import { useStartHere } from "@/hooks/useStartHere"; import { COMPACTED_EMOJI } from "@/constants/ui"; import { ModelDisplay } from "./ModelDisplay"; import { CompactingMessageContent } from "./CompactingMessageContent"; +import { CompactionBackground } from "./CompactionBackground"; const RawContent = styled.pre` font-family: var(--font-monospace); @@ -165,6 +166,7 @@ export const AssistantMessage: React.FC = ({ message={message} buttons={buttons} className={className} + backgroundEffect={isStreamingCompaction ? : undefined} > {renderContent()} diff --git a/src/components/Messages/CompactingMessageContent.tsx b/src/components/Messages/CompactingMessageContent.tsx index c70f921eb..2c4fa8dcc 100644 --- a/src/components/Messages/CompactingMessageContent.tsx +++ b/src/components/Messages/CompactingMessageContent.tsx @@ -1,6 +1,5 @@ import React, { useRef, useEffect } from "react"; import styled from "@emotion/styled"; -import { CompactionBackground } from "./CompactionBackground"; /** * Wrapper for compaction streaming content @@ -8,7 +7,6 @@ import { CompactionBackground } from "./CompactionBackground"; */ const Container = styled.div` - position: relative; max-height: 300px; overflow-y: auto; overflow-x: hidden; @@ -33,11 +31,6 @@ const Container = styled.div` } `; -const ContentWrapper = styled.div` - position: relative; - z-index: 1; -`; - interface CompactingMessageContentProps { children: React.ReactNode; } @@ -52,10 +45,5 @@ export const CompactingMessageContent: React.FC = } }, [children]); - return ( - - - {children} - - ); + return {children}; }; diff --git a/src/components/Messages/MessageWindow.tsx b/src/components/Messages/MessageWindow.tsx index 1e4edf2de..b2e30acf5 100644 --- a/src/components/Messages/MessageWindow.tsx +++ b/src/components/Messages/MessageWindow.tsx @@ -7,6 +7,7 @@ import { formatTimestamp } from "@/utils/ui/dateTime"; import { TooltipWrapper, Tooltip } from "../Tooltip"; const MessageBlock = styled.div<{ borderColor: string; backgroundColor?: string }>` + position: relative; margin-bottom: 15px; margin-top: 15px; background: ${(props) => props.backgroundColor ?? "#1e1e1e"}; @@ -16,6 +17,8 @@ const MessageBlock = styled.div<{ borderColor: string; backgroundColor?: string `; const MessageHeader = styled.div` + position: relative; + z-index: 1; padding: 8px 12px; background: rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.1); @@ -51,6 +54,8 @@ const ButtonGroup = styled.div` `; const MessageContent = styled.div` + position: relative; + z-index: 1; padding: 12px; `; @@ -85,6 +90,7 @@ interface MessageWindowProps { children: ReactNode; className?: string; rightLabel?: ReactNode; + backgroundEffect?: ReactNode; // Optional background effect (e.g., animation) } export const MessageWindow: React.FC = ({ @@ -96,6 +102,7 @@ export const MessageWindow: React.FC = ({ children, className, rightLabel, + backgroundEffect, }) => { const [showJson, setShowJson] = useState(false); @@ -111,6 +118,7 @@ export const MessageWindow: React.FC = ({ return ( + {backgroundEffect} {label} diff --git a/src/test-temp-x2vNLl/test-repo b/src/test-temp-x2vNLl/test-repo new file mode 160000 index 000000000..fea2254cc --- /dev/null +++ b/src/test-temp-x2vNLl/test-repo @@ -0,0 +1 @@ +Subproject commit fea2254cc0d5ebb228eb185268af29c9f12cb7ac From 9ce7bed06746f0ac6f40efdc4bd3040b87108e65 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:26:48 -0500 Subject: [PATCH 08/37] =?UTF-8?q?=F0=9F=A4=96=20Change=20compaction=20effe?= =?UTF-8?q?ct=20to=20green=20laser=20scanning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace horizontal shimmer with futuristic green laser scanning effect: - Vertical bar sweeps back and forth across the message window - Green color (rgba 34, 197, 94) with gradient fade - Box shadow for glow effect - 2.5s scan cycle with ease-in-out timing - Refactored to proper React component structure This creates a more futuristic, sci-fi feel that better communicates the active compaction process. Generated with cmux --- .../Messages/CompactionBackground.tsx | 46 ++++++++++++++----- src/test-temp-x2vNLl/test-repo | 1 - src/test-temp-zbpEr8/test-repo | 1 + 3 files changed, 35 insertions(+), 13 deletions(-) delete mode 160000 src/test-temp-x2vNLl/test-repo create mode 160000 src/test-temp-zbpEr8/test-repo diff --git a/src/components/Messages/CompactionBackground.tsx b/src/components/Messages/CompactionBackground.tsx index d10f96487..f47bff97a 100644 --- a/src/components/Messages/CompactionBackground.tsx +++ b/src/components/Messages/CompactionBackground.tsx @@ -1,36 +1,58 @@ +import React from "react"; import styled from "@emotion/styled"; import { keyframes } from "@emotion/react"; /** * Animated background for compaction streaming - * Isolated component for visual effect during compaction + * Green laser scanning effect - futuristic vertical bar sweeping back and forth */ -const shimmer = keyframes` +const scan = keyframes` 0% { - background-position: -200% center; + left: -10%; + } + 50% { + left: 110%; } 100% { - background-position: 200% center; + left: -10%; } `; -export const CompactionBackground = styled.div` +const Container = styled.div` position: absolute; top: 0; left: 0; right: 0; bottom: 0; + overflow: hidden; + pointer-events: none; + border-radius: 6px; +`; + +const LaserBar = styled.div` + position: absolute; + top: 0; + bottom: 0; + width: 80px; background: linear-gradient( 90deg, transparent 0%, - rgba(var(--color-plan-mode-rgb), 0.08) 25%, - rgba(var(--color-plan-mode-rgb), 0.15) 50%, - rgba(var(--color-plan-mode-rgb), 0.08) 75%, + rgba(34, 197, 94, 0.05) 20%, + rgba(34, 197, 94, 0.15) 40%, + rgba(34, 197, 94, 0.25) 50%, + rgba(34, 197, 94, 0.15) 60%, + rgba(34, 197, 94, 0.05) 80%, transparent 100% ); - background-size: 200% 100%; - animation: ${shimmer} 3s ease-in-out infinite; - pointer-events: none; - border-radius: 6px; + box-shadow: 0 0 20px rgba(34, 197, 94, 0.3); + animation: ${scan} 2.5s ease-in-out infinite; `; + +export const CompactionBackground: React.FC = () => { + return ( + + + + ); +}; diff --git a/src/test-temp-x2vNLl/test-repo b/src/test-temp-x2vNLl/test-repo deleted file mode 160000 index fea2254cc..000000000 --- a/src/test-temp-x2vNLl/test-repo +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fea2254cc0d5ebb228eb185268af29c9f12cb7ac diff --git a/src/test-temp-zbpEr8/test-repo b/src/test-temp-zbpEr8/test-repo new file mode 160000 index 000000000..0f63a0b07 --- /dev/null +++ b/src/test-temp-zbpEr8/test-repo @@ -0,0 +1 @@ +Subproject commit 0f63a0b07d6ad2443b04f1df017c8e19205a66e7 From a86085f43d7414767bc7e382c01489566d2c43a4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:31:28 -0500 Subject: [PATCH 09/37] =?UTF-8?q?=F0=9F=A4=96=20Refine=20compaction=20effe?= =?UTF-8?q?ct=20to=20subtle=20gradient=20wave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace tacky laser scanning with elegant gradient wave: - Smooth gradient using plan-mode color variables (matches theme) - Single directional sweep (left to right, 3s cycle) - Subtle opacity (0.6) for understated effect - Uses existing CSS color variables for consistency - Wider gradient spread (30-70% instead of sharp bar) Creates a more polished, professional appearance while still providing visual feedback that compaction is in progress. Generated with cmux --- .../Messages/CompactionBackground.tsx | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/components/Messages/CompactionBackground.tsx b/src/components/Messages/CompactionBackground.tsx index f47bff97a..0c355836b 100644 --- a/src/components/Messages/CompactionBackground.tsx +++ b/src/components/Messages/CompactionBackground.tsx @@ -4,18 +4,15 @@ import { keyframes } from "@emotion/react"; /** * Animated background for compaction streaming - * Green laser scanning effect - futuristic vertical bar sweeping back and forth + * Subtle gradient wave effect that sweeps across the message */ -const scan = keyframes` +const sweep = keyframes` 0% { - left: -10%; - } - 50% { - left: 110%; + transform: translateX(-100%); } 100% { - left: -10%; + transform: translateX(200%); } `; @@ -30,29 +27,27 @@ const Container = styled.div` border-radius: 6px; `; -const LaserBar = styled.div` +const GradientWave = styled.div` position: absolute; top: 0; bottom: 0; - width: 80px; + width: 100%; background: linear-gradient( 90deg, transparent 0%, - rgba(34, 197, 94, 0.05) 20%, - rgba(34, 197, 94, 0.15) 40%, - rgba(34, 197, 94, 0.25) 50%, - rgba(34, 197, 94, 0.15) 60%, - rgba(34, 197, 94, 0.05) 80%, + var(--color-plan-mode-alpha) 30%, + var(--color-plan-mode-alpha-hover) 50%, + var(--color-plan-mode-alpha) 70%, transparent 100% ); - box-shadow: 0 0 20px rgba(34, 197, 94, 0.3); - animation: ${scan} 2.5s ease-in-out infinite; + animation: ${sweep} 3s ease-in-out infinite; + opacity: 0.6; `; export const CompactionBackground: React.FC = () => { return ( - + ); }; From ea7eb232ae7d855c684782300eebb1d8acca7e48 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:32:48 -0500 Subject: [PATCH 10/37] Make compaction gradient more prominent Increase visibility of gradient wave effect: - Increase opacity from 0.6 to 1.0 - Use stronger color-mix for center (25% plan-mode color) - Tighter gradient spread (25-75% instead of 30-70%) Effect is now more noticeable while still being elegant. Generated with cmux --- src/components/Messages/CompactionBackground.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Messages/CompactionBackground.tsx b/src/components/Messages/CompactionBackground.tsx index 0c355836b..cb24f19fe 100644 --- a/src/components/Messages/CompactionBackground.tsx +++ b/src/components/Messages/CompactionBackground.tsx @@ -35,13 +35,13 @@ const GradientWave = styled.div` background: linear-gradient( 90deg, transparent 0%, - var(--color-plan-mode-alpha) 30%, - var(--color-plan-mode-alpha-hover) 50%, - var(--color-plan-mode-alpha) 70%, + var(--color-plan-mode-alpha-hover) 25%, + color-mix(in srgb, var(--color-plan-mode) 25%, transparent) 50%, + var(--color-plan-mode-alpha-hover) 75%, transparent 100% ); animation: ${sweep} 3s ease-in-out infinite; - opacity: 0.6; + opacity: 1; `; export const CompactionBackground: React.FC = () => { From 5380f7ce13d8f61bc62e4bf646dc2ab1cb3cdfed Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:36:00 -0500 Subject: [PATCH 11/37] =?UTF-8?q?=F0=9F=A4=96=20Polish=20compaction=20grad?= =?UTF-8?q?ient=20with=20layered=20effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add multi-layered gradient for more polished appearance: - BaseGlow: Subtle pulsing radial gradient (provides depth) - GradientWave: Main sweeping gradient with slight blur (smooth motion) - HighlightWave: Crisp highlight that follows the main wave (adds definition) Visual improvements: - Smoother cubic-bezier easing for more natural motion - Multiple gradient stops for better color distribution - Slight blur on main wave for softer appearance - Pulsing base glow synced to 2s cycle (complements 3s sweep) Creates a more premium, polished look with depth and dimension. Generated with cmux --- .../Messages/CompactionBackground.tsx | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/components/Messages/CompactionBackground.tsx b/src/components/Messages/CompactionBackground.tsx index cb24f19fe..9a6ec5e5e 100644 --- a/src/components/Messages/CompactionBackground.tsx +++ b/src/components/Messages/CompactionBackground.tsx @@ -4,7 +4,7 @@ import { keyframes } from "@emotion/react"; /** * Animated background for compaction streaming - * Subtle gradient wave effect that sweeps across the message + * Multi-layered gradient wave with subtle glow for polished appearance */ const sweep = keyframes` @@ -16,6 +16,15 @@ const sweep = keyframes` } `; +const pulse = keyframes` + 0%, 100% { + opacity: 0.3; + } + 50% { + opacity: 0.5; + } +`; + const Container = styled.div` position: absolute; top: 0; @@ -27,6 +36,16 @@ const Container = styled.div` border-radius: 6px; `; +const BaseGlow = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: radial-gradient(ellipse at center, var(--color-plan-mode-alpha) 0%, transparent 70%); + animation: ${pulse} 2s ease-in-out infinite; +`; + const GradientWave = styled.div` position: absolute; top: 0; @@ -35,19 +54,39 @@ const GradientWave = styled.div` background: linear-gradient( 90deg, transparent 0%, - var(--color-plan-mode-alpha-hover) 25%, + var(--color-plan-mode-alpha) 20%, + color-mix(in srgb, var(--color-plan-mode) 20%, transparent) 40%, color-mix(in srgb, var(--color-plan-mode) 25%, transparent) 50%, - var(--color-plan-mode-alpha-hover) 75%, + color-mix(in srgb, var(--color-plan-mode) 20%, transparent) 60%, + var(--color-plan-mode-alpha) 80%, + transparent 100% + ); + animation: ${sweep} 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; + filter: blur(1px); +`; + +const HighlightWave = styled.div` + position: absolute; + top: 0; + bottom: 0; + width: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + transparent 35%, + var(--color-plan-mode-alpha-hover) 50%, + transparent 65%, transparent 100% ); - animation: ${sweep} 3s ease-in-out infinite; - opacity: 1; + animation: ${sweep} 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; `; export const CompactionBackground: React.FC = () => { return ( + + ); }; From 0be450f3f2a65d654430ab095a7e333b087416dc Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:37:55 -0500 Subject: [PATCH 12/37] =?UTF-8?q?=F0=9F=A4=96=20Make=20compaction=20gradie?= =?UTF-8?q?nt=20sweep=20continuously?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve animation smoothness and consistency: - Wider gradient waves (150% width) so always visible during sweep - Adjusted translate range (-50% to 100%) for seamless loop - Changed to linear timing for consistent, predictable motion - Slowed to 4s cycle for more relaxed, satisfying pace - Gradient stops repositioned for continuous presence The wave now walks smoothly across without start/stop gaps, creating a more satisfying, meditative effect. Generated with cmux --- .../Messages/CompactionBackground.tsx | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/components/Messages/CompactionBackground.tsx b/src/components/Messages/CompactionBackground.tsx index 9a6ec5e5e..581020c2a 100644 --- a/src/components/Messages/CompactionBackground.tsx +++ b/src/components/Messages/CompactionBackground.tsx @@ -9,10 +9,10 @@ import { keyframes } from "@emotion/react"; const sweep = keyframes` 0% { - transform: translateX(-100%); + transform: translateX(-50%); } 100% { - transform: translateX(200%); + transform: translateX(100%); } `; @@ -50,18 +50,18 @@ const GradientWave = styled.div` position: absolute; top: 0; bottom: 0; - width: 100%; + width: 150%; background: linear-gradient( 90deg, - transparent 0%, - var(--color-plan-mode-alpha) 20%, - color-mix(in srgb, var(--color-plan-mode) 20%, transparent) 40%, - color-mix(in srgb, var(--color-plan-mode) 25%, transparent) 50%, - color-mix(in srgb, var(--color-plan-mode) 20%, transparent) 60%, - var(--color-plan-mode-alpha) 80%, + var(--color-plan-mode-alpha) 0%, + color-mix(in srgb, var(--color-plan-mode) 20%, transparent) 20%, + color-mix(in srgb, var(--color-plan-mode) 25%, transparent) 33%, + color-mix(in srgb, var(--color-plan-mode) 20%, transparent) 46%, + var(--color-plan-mode-alpha) 60%, + transparent 80%, transparent 100% ); - animation: ${sweep} 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; + animation: ${sweep} 4s linear infinite; filter: blur(1px); `; @@ -69,16 +69,16 @@ const HighlightWave = styled.div` position: absolute; top: 0; bottom: 0; - width: 100%; + width: 150%; background: linear-gradient( 90deg, transparent 0%, - transparent 35%, - var(--color-plan-mode-alpha-hover) 50%, - transparent 65%, + transparent 25%, + var(--color-plan-mode-alpha-hover) 33%, + transparent 41%, transparent 100% ); - animation: ${sweep} 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; + animation: ${sweep} 4s linear infinite; `; export const CompactionBackground: React.FC = () => { From a42c9d79aec765e467ca54a9c40a25e57ee657c9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:41:23 -0500 Subject: [PATCH 13/37] =?UTF-8?q?=F0=9F=A4=96=20Enhance=20compaction=20bac?= =?UTF-8?q?kground=20with=20shimmer=20and=20particle=20effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace static gradient wave with more dynamic animation: - Animated multi-directional gradient (400% size, 8s cycle) - Fast-moving shimmer layer (3s horizontal sweep with white highlight) - 8 floating particles with varied timing for organic movement Technical approach: - Background gradient animates position for smooth color flow - Shimmer uses background-position animation (GPU-accelerated) - Particles use transform: translate for float effect - All animations staggered with different delays/durations Visual improvements over previous version: - More fluid motion (multiple animation cycles at different speeds) - Shimmer adds light-reflection feel - Particles add depth and activity - Less predictable/monotonous than single linear sweep Inspired by modern loading animations from uiverse.io, CodePen shimmer effects, and animated gradient patterns. --- .../Messages/CompactionBackground.tsx | 117 ++++++++++++------ 1 file changed, 82 insertions(+), 35 deletions(-) diff --git a/src/components/Messages/CompactionBackground.tsx b/src/components/Messages/CompactionBackground.tsx index 581020c2a..6590b3517 100644 --- a/src/components/Messages/CompactionBackground.tsx +++ b/src/components/Messages/CompactionBackground.tsx @@ -4,25 +4,47 @@ import { keyframes } from "@emotion/react"; /** * Animated background for compaction streaming - * Multi-layered gradient wave with subtle glow for polished appearance + * Shimmer effect with moving gradient and particles for dynamic appearance */ -const sweep = keyframes` +const shimmer = keyframes` 0% { - transform: translateX(-50%); + background-position: -1000px 0; } 100% { - transform: translateX(100%); + background-position: 1000px 0; } `; -const pulse = keyframes` +const gradientMove = keyframes` + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +`; + +const float = keyframes` 0%, 100% { - opacity: 0.3; + transform: translateY(0) translateX(0); + opacity: 0.4; + } + 25% { + transform: translateY(-10px) translateX(5px); + opacity: 0.7; } 50% { + transform: translateY(-15px) translateX(-5px); opacity: 0.5; } + 75% { + transform: translateY(-8px) translateX(3px); + opacity: 0.6; + } `; const Container = styled.div` @@ -36,57 +58,82 @@ const Container = styled.div` border-radius: 6px; `; -const BaseGlow = styled.div` +const AnimatedGradient = styled.div` position: absolute; top: 0; left: 0; right: 0; bottom: 0; - background: radial-gradient(ellipse at center, var(--color-plan-mode-alpha) 0%, transparent 70%); - animation: ${pulse} 2s ease-in-out infinite; -`; - -const GradientWave = styled.div` - position: absolute; - top: 0; - bottom: 0; - width: 150%; background: linear-gradient( - 90deg, - var(--color-plan-mode-alpha) 0%, - color-mix(in srgb, var(--color-plan-mode) 20%, transparent) 20%, - color-mix(in srgb, var(--color-plan-mode) 25%, transparent) 33%, - color-mix(in srgb, var(--color-plan-mode) 20%, transparent) 46%, - var(--color-plan-mode-alpha) 60%, - transparent 80%, - transparent 100% + -45deg, + var(--color-plan-mode-alpha), + color-mix(in srgb, var(--color-plan-mode) 30%, transparent), + var(--color-plan-mode-alpha), + color-mix(in srgb, var(--color-plan-mode) 25%, transparent) ); - animation: ${sweep} 4s linear infinite; - filter: blur(1px); + background-size: 400% 400%; + animation: ${gradientMove} 8s ease infinite; + opacity: 0.4; `; -const HighlightWave = styled.div` +const ShimmerLayer = styled.div` position: absolute; top: 0; + left: 0; + right: 0; bottom: 0; - width: 150%; background: linear-gradient( 90deg, transparent 0%, - transparent 25%, - var(--color-plan-mode-alpha-hover) 33%, - transparent 41%, + transparent 40%, + rgba(255, 255, 255, 0.15) 50%, + transparent 60%, transparent 100% ); - animation: ${sweep} 4s linear infinite; + background-size: 1000px 100%; + animation: ${shimmer} 3s infinite linear; +`; + +const Particle = styled.div<{ delay: number; duration: number; left: string; size: number }>` + position: absolute; + left: ${(props) => props.left}; + bottom: 20%; + width: ${(props) => props.size}px; + height: ${(props) => props.size}px; + background: var(--color-plan-mode); + border-radius: 50%; + opacity: 0.3; + animation: ${float} ${(props) => props.duration}s ease-in-out infinite; + animation-delay: ${(props) => props.delay}s; + filter: blur(2px); `; export const CompactionBackground: React.FC = () => { + // Generate particles with different properties for organic feel + const particles = [ + { delay: 0, duration: 4, left: "10%", size: 4 }, + { delay: 0.5, duration: 5, left: "25%", size: 6 }, + { delay: 1, duration: 4.5, left: "45%", size: 3 }, + { delay: 1.5, duration: 5.5, left: "60%", size: 5 }, + { delay: 0.8, duration: 4.8, left: "75%", size: 4 }, + { delay: 2, duration: 5.2, left: "85%", size: 3 }, + { delay: 0.3, duration: 5, left: "35%", size: 5 }, + { delay: 1.8, duration: 4.3, left: "55%", size: 4 }, + ]; + return ( - - - + + + {particles.map((particle, index) => ( + + ))} ); }; From 4eed689df5ee37d7fa7030f2bd55ff5b65b9ec33 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:43:18 -0500 Subject: [PATCH 14/37] =?UTF-8?q?=F0=9F=A4=96=20Add=20[truncated]=20sentin?= =?UTF-8?q?el=20for=20interrupted=20compaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When compaction is interrupted (e.g. user presses Ctrl+C), append "[truncated]" marker on a new line to the partial summary before saving it. Implementation: - Added handleCompactionAbort() method parallel to handleCompactionCompletion() - Checks if aborted stream was a compaction request (same logic as completion) - Extracts partial summary text streamed before interruption - Appends "\n\n[truncated]" to indicate incomplete summary - Calls performCompaction() with truncated summary Flow: 1. User interrupts compaction stream (Ctrl+C) 2. StreamManager emits stream-abort event 3. WorkspaceStore.processStreamEvent() receives abort 4. handleCompactionAbort() detects it was compaction 5. Fetches partial summary via aggregator.getCompactionSummary() 6. Adds [truncated] sentinel 7. performCompaction() replaces history with truncated summary This makes it clear when compaction didn't complete, helping users understand the summary is partial. --- src/stores/WorkspaceStore.ts | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index b0c10ec50..e2a41a0be 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -442,6 +442,41 @@ export class WorkspaceStore { return true; } + /** + * Handle interruption (Ctrl+C) of a compaction stream. + * Saves partial summary with [truncated] sentinel. + */ + private handleCompactionAbort( + workspaceId: string, + aggregator: StreamingMessageAggregator, + data: WorkspaceChatMessage + ): boolean { + // Type guard: only StreamAbortEvent has messageId + if (!("messageId" in data)) return false; + + // Check if this was a compaction stream by looking at the last user message + const messages = aggregator.getAllMessages(); + const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); + + if (lastUserMsg?.metadata?.cmuxMetadata?.type !== "compaction-request") { + return false; + } + + // Extract whatever summary text was streamed before interruption + const partialSummary = aggregator.getCompactionSummary(data.messageId); + if (!partialSummary) { + console.warn("[WorkspaceStore] Compaction aborted but no partial summary found"); + return false; + } + + // Append [truncated] sentinel on new line to indicate incomplete summary + const truncatedSummary = partialSummary.trim() + "\n\n[truncated]"; + + this.performCompaction(workspaceId, aggregator, data, truncatedSummary); + return true; + } + + /** * Perform history compaction by replacing chat history with summary message. * Type-safe: only called when we've verified data is a StreamEndEvent. @@ -805,6 +840,14 @@ export class WorkspaceStore { if (isStreamAbort(data)) { aggregator.clearTokenState(data.messageId); aggregator.handleStreamAbort(data); + + // Check if this was a compaction stream that got interrupted + if (this.handleCompactionAbort(workspaceId, aggregator, data)) { + // Compaction abort handled, don't do normal abort processing + return; + } + + // Normal abort handling this.states.bump(workspaceId); this.dispatchResumeCheck(workspaceId); From f5c940f97bf9cc83a7a1a568a3567fbbd1f86eab Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:44:20 -0500 Subject: [PATCH 15/37] =?UTF-8?q?=F0=9F=A4=96=20Reduce=20shimmer=20intensi?= =?UTF-8?q?ty=20and=20remove=20particles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed sparkle particles and reduced shimmer opacity from 0.15 to 0.06 for a more subtle compaction background effect. Changes: - Reduced ShimmerLayer white opacity: 0.15 → 0.06 - Removed Particle styled component and related float animation - Removed particle array generation and rendering The animated gradient and shimmer are now the only visual elements, providing a cleaner, less busy background during compaction. --- .../Messages/CompactionBackground.tsx | 56 +------------------ 1 file changed, 1 insertion(+), 55 deletions(-) diff --git a/src/components/Messages/CompactionBackground.tsx b/src/components/Messages/CompactionBackground.tsx index 6590b3517..5b6013409 100644 --- a/src/components/Messages/CompactionBackground.tsx +++ b/src/components/Messages/CompactionBackground.tsx @@ -28,25 +28,6 @@ const gradientMove = keyframes` } `; -const float = keyframes` - 0%, 100% { - transform: translateY(0) translateX(0); - opacity: 0.4; - } - 25% { - transform: translateY(-10px) translateX(5px); - opacity: 0.7; - } - 50% { - transform: translateY(-15px) translateX(-5px); - opacity: 0.5; - } - 75% { - transform: translateY(-8px) translateX(3px); - opacity: 0.6; - } -`; - const Container = styled.div` position: absolute; top: 0; @@ -86,7 +67,7 @@ const ShimmerLayer = styled.div` 90deg, transparent 0%, transparent 40%, - rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.06) 50%, transparent 60%, transparent 100% ); @@ -94,46 +75,11 @@ const ShimmerLayer = styled.div` animation: ${shimmer} 3s infinite linear; `; -const Particle = styled.div<{ delay: number; duration: number; left: string; size: number }>` - position: absolute; - left: ${(props) => props.left}; - bottom: 20%; - width: ${(props) => props.size}px; - height: ${(props) => props.size}px; - background: var(--color-plan-mode); - border-radius: 50%; - opacity: 0.3; - animation: ${float} ${(props) => props.duration}s ease-in-out infinite; - animation-delay: ${(props) => props.delay}s; - filter: blur(2px); -`; - export const CompactionBackground: React.FC = () => { - // Generate particles with different properties for organic feel - const particles = [ - { delay: 0, duration: 4, left: "10%", size: 4 }, - { delay: 0.5, duration: 5, left: "25%", size: 6 }, - { delay: 1, duration: 4.5, left: "45%", size: 3 }, - { delay: 1.5, duration: 5.5, left: "60%", size: 5 }, - { delay: 0.8, duration: 4.8, left: "75%", size: 4 }, - { delay: 2, duration: 5.2, left: "85%", size: 3 }, - { delay: 0.3, duration: 5, left: "35%", size: 5 }, - { delay: 1.8, duration: 4.3, left: "55%", size: 4 }, - ]; - return ( - {particles.map((particle, index) => ( - - ))} ); }; From b8c10d8036f8b190da46dd895506e8ea83926ed9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:46:07 -0500 Subject: [PATCH 16/37] =?UTF-8?q?=F0=9F=A4=96=20Use=20plan-mode=20color=20?= =?UTF-8?q?for=20shimmer=20instead=20of=20white?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed shimmer scan line from white (rgba(255,255,255,0.06)) to plan-mode blue (--color-plan-mode-alpha) so it blends better with the animated gradient background and appears less distinct as a separate scan line element. --- src/components/Messages/CompactionBackground.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Messages/CompactionBackground.tsx b/src/components/Messages/CompactionBackground.tsx index 5b6013409..65b006032 100644 --- a/src/components/Messages/CompactionBackground.tsx +++ b/src/components/Messages/CompactionBackground.tsx @@ -67,7 +67,7 @@ const ShimmerLayer = styled.div` 90deg, transparent 0%, transparent 40%, - rgba(255, 255, 255, 0.06) 50%, + var(--color-plan-mode-alpha) 50%, transparent 60%, transparent 100% ); From 6a109630823f10db3bf58003a0a781baebddf217 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:47:52 -0500 Subject: [PATCH 17/37] =?UTF-8?q?=F0=9F=A4=96=20Fix=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/stores/WorkspaceStore.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index e2a41a0be..e8425115b 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -476,7 +476,6 @@ export class WorkspaceStore { return true; } - /** * Perform history compaction by replacing chat history with summary message. * Type-safe: only called when we've verified data is a StreamEndEvent. From 56cbac77d6a2543a0bdd6cc65dae325f87154b7a Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:49:34 -0500 Subject: [PATCH 18/37] =?UTF-8?q?=F0=9F=A4=96=20Remove=20test-temp=20submo?= =?UTF-8?q?dule=20and=20add=20.gitignore=20exclusion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed src/test-temp-zbpEr8/test-repo submodule that accidentally got committed during testing. Added .gitignore patterns to prevent future test temporary directories from being committed: - src/test-temp-*/ - tests/**/test-temp-*/ This prevents similar mistakes where test fixtures create git repos that get accidentally tracked as submodules. --- .gitignore | 5 +++++ src/test-temp-zbpEr8/test-repo | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) delete mode 160000 src/test-temp-zbpEr8/test-repo diff --git a/.gitignore b/.gitignore index c32901afa..ba563038b 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,11 @@ CODE_CHANGES.md README_COMPACT_HERE.md artifacts/ tests/e2e/tmp/ + +# Test temporary directories +src/test-temp-*/ +tests/**/test-temp-*/ + runs/ # Python diff --git a/src/test-temp-zbpEr8/test-repo b/src/test-temp-zbpEr8/test-repo deleted file mode 160000 index 0f63a0b07..000000000 --- a/src/test-temp-zbpEr8/test-repo +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0f63a0b07d6ad2443b04f1df017c8e19205a66e7 From 765a029c54c1dbacaadf88bc1b1f8ef935537995 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:53:03 -0500 Subject: [PATCH 19/37] Replace scrolling with fade effect in compacting messages - Remove overflow-y: auto and auto-scroll logic - Add CSS mask-image gradient for line-by-line fade from top - Implies content above without janky scroll behavior --- .../Messages/CompactingMessageContent.tsx | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/src/components/Messages/CompactingMessageContent.tsx b/src/components/Messages/CompactingMessageContent.tsx index 2c4fa8dcc..8fb8f77c9 100644 --- a/src/components/Messages/CompactingMessageContent.tsx +++ b/src/components/Messages/CompactingMessageContent.tsx @@ -1,34 +1,34 @@ -import React, { useRef, useEffect } from "react"; +import React from "react"; import styled from "@emotion/styled"; /** * Wrapper for compaction streaming content - * Provides max-height constraint with sticky scroll to bottom + * Provides max-height constraint with fade effect to imply content above + * No scrolling - content fades out at the top line by line */ const Container = styled.div` max-height: 300px; - overflow-y: auto; - overflow-x: hidden; - - /* Subtle indicator that content is scrollable */ - &::-webkit-scrollbar { - width: 8px; - } - - &::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.1); - border-radius: 4px; - } - - &::-webkit-scrollbar-thumb { - background: rgba(var(--color-plan-mode-rgb), 0.3); - border-radius: 4px; - - &:hover { - background: rgba(var(--color-plan-mode-rgb), 0.5); - } - } + overflow: hidden; + position: relative; + + /* Fade effect: content fades progressively from top to bottom */ + mask-image: linear-gradient( + to bottom, + transparent 0%, + rgba(0, 0, 0, 0.3) 5%, + rgba(0, 0, 0, 0.6) 10%, + rgba(0, 0, 0, 0.85) 15%, + black 20% + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + rgba(0, 0, 0, 0.3) 5%, + rgba(0, 0, 0, 0.6) 10%, + rgba(0, 0, 0, 0.85) 15%, + black 20% + ); `; interface CompactingMessageContentProps { @@ -36,14 +36,5 @@ interface CompactingMessageContentProps { } export const CompactingMessageContent: React.FC = ({ children }) => { - const containerRef = useRef(null); - - // Auto-scroll to bottom as content streams in - useEffect(() => { - if (containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight; - } - }, [children]); - - return {children}; + return {children}; }; From a4381e9ed7d73d60038a5ca9652fc1a74af018df Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 19:58:35 -0500 Subject: [PATCH 20/37] Fix compacting message issues 1. Anchor content to bottom of viewport - Add flexbox with justify-content: flex-end - Keeps newest content visible at bottom as it streams - Older content fades at top when exceeding max-height 2. Disable all tools during compaction - Set toolPolicy: [] in applyCompactionOverrides - Ensures compaction never has tool access regardless of UI mode - Add test to verify tools disabled even with base policy --- .../Messages/CompactingMessageContent.tsx | 5 ++++- src/utils/messages/compactionOptions.test.ts | 15 +++++++++++++-- src/utils/messages/compactionOptions.ts | 1 + 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/components/Messages/CompactingMessageContent.tsx b/src/components/Messages/CompactingMessageContent.tsx index 8fb8f77c9..5e349fb69 100644 --- a/src/components/Messages/CompactingMessageContent.tsx +++ b/src/components/Messages/CompactingMessageContent.tsx @@ -4,13 +4,16 @@ import styled from "@emotion/styled"; /** * Wrapper for compaction streaming content * Provides max-height constraint with fade effect to imply content above - * No scrolling - content fades out at the top line by line + * No scrolling - content stays anchored to bottom, older content fades at top */ const Container = styled.div` max-height: 300px; overflow: hidden; position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; /* Anchor content to bottom */ /* Fade effect: content fades progressively from top to bottom */ mask-image: linear-gradient( diff --git a/src/utils/messages/compactionOptions.test.ts b/src/utils/messages/compactionOptions.test.ts index 0eefa8011..6a413edde 100644 --- a/src/utils/messages/compactionOptions.test.ts +++ b/src/utils/messages/compactionOptions.test.ts @@ -58,15 +58,26 @@ describe("applyCompactionOverrides", () => { expect(result.maxOutputTokens).toBe(8000); }); - it("sets compact mode", () => { + it("sets compact mode and disables all tools", () => { const compactData: CompactionRequestData = {}; const result = applyCompactionOverrides(baseOptions, compactData); expect(result.mode).toBe("compact"); - // No special toolPolicy for compaction - uses base options expect(result.toolPolicy).toEqual([]); }); + it("disables all tools even when base options has tool policy", () => { + const baseWithTools: SendMessageOptions = { + ...baseOptions, + toolPolicy: [{ regex_match: "bash", action: "enable" }], + }; + const compactData: CompactionRequestData = {}; + const result = applyCompactionOverrides(baseWithTools, compactData); + + expect(result.mode).toBe("compact"); + expect(result.toolPolicy).toEqual([]); // Tools always disabled for compaction + }); + it("applies all overrides together", () => { const compactData: CompactionRequestData = { model: "openai:gpt-5", diff --git a/src/utils/messages/compactionOptions.ts b/src/utils/messages/compactionOptions.ts index 6f4d6cc40..59ca409cd 100644 --- a/src/utils/messages/compactionOptions.ts +++ b/src/utils/messages/compactionOptions.ts @@ -36,5 +36,6 @@ export function applyCompactionOverrides( thinkingLevel: isAnthropic ? "off" : baseOptions.thinkingLevel, maxOutputTokens: compactData.maxOutputTokens, mode: "compact" as const, + toolPolicy: [], // Disable all tools during compaction }; } From c9d45bb8c71bbd460bd0c12e95e66695ff254c86 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:06:53 -0500 Subject: [PATCH 21/37] Add Ctrl+C/Ctrl+A compaction interrupt handling **Ctrl+C during compaction**: Cancel and restore command - Interrupts stream - Removes compaction request + partial summary from history - Restores original /compact command to input for re-editing **Ctrl+A during compaction**: Accept early with [truncated] - Uses existing handleCompactionAbort flow - Adds [truncated] sentinel to partial summary - Performs compaction with incomplete summary **Implementation**: - Created src/utils/compaction/handler.ts to consolidate logic - Added ACCEPT_EARLY_COMPACTION keybind (Ctrl+A) - Extended ChatInputAPI with restoreText() method - Updated useAIViewKeybinds to handle both cases - Updated UI hints in ChatInput, AIView, UserMessage **Architecture**: - Compaction logic grouped in dedicated handler module - Minimal indirection - direct calls to IPC layer - Uses percentage-based truncateHistory API for removal --- src/components/AIView.tsx | 7 ++- src/components/ChatInput.tsx | 16 ++++- src/components/Messages/UserMessage.tsx | 2 +- src/hooks/useAIViewKeybinds.ts | 31 +++++++++- src/utils/compaction/handler.ts | 79 +++++++++++++++++++++++++ src/utils/ui/keybinds.ts | 3 + 6 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 src/utils/compaction/handler.ts diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index e4e94dbb7..79f3d2a34 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -345,6 +345,7 @@ const AIViewInner: React.FC = ({ chatInputAPI, jumpToBottom, handleOpenTerminal, + aggregator, }); // Clear editing state if the message being edited no longer exists @@ -523,7 +524,11 @@ const AIViewInner: React.FC = ({ ? `${getModelName(currentModel)} streaming...` : "streaming..." } - cancelText={`hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel`} + cancelText={ + isCompacting + ? `${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early` + : `hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel` + } tokenCount={ activeStreamMessageId ? aggregator.getStreamingTokenCount(activeStreamMessageId) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index a5f0523d7..f9369c88b 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -120,6 +120,7 @@ const ModelDisplayWrapper = styled.div` export interface ChatInputAPI { focus: () => void; + restoreText: (text: string) => void; } export interface ChatInputProps { @@ -430,12 +431,21 @@ export const ChatInput: React.FC = ({ }); }, []); + // Method to restore text to input (used by compaction cancel) + const restoreText = useCallback((text: string) => { + setInput(text); + focusMessageInput(); + }, [focusMessageInput]); + // Provide API to parent via callback useEffect(() => { if (onReady) { - onReady({ focus: focusMessageInput }); + onReady({ + focus: focusMessageInput, + restoreText, + }); } - }, [onReady, focusMessageInput]); + }, [onReady, focusMessageInput, restoreText]); useEffect(() => { const handleGlobalKeyDown = (event: KeyboardEvent) => { @@ -948,7 +958,7 @@ export const ChatInput: React.FC = ({ return `Edit your message... (${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`; } if (isCompacting) { - return `Compacting... (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)`; + return `Compacting... (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early)`; } // Build hints for normal input diff --git a/src/components/Messages/UserMessage.tsx b/src/components/Messages/UserMessage.tsx index c23739987..1307d4597 100644 --- a/src/components/Messages/UserMessage.tsx +++ b/src/components/Messages/UserMessage.tsx @@ -102,7 +102,7 @@ export const UserMessage: React.FC = ({ onClick: handleEdit, disabled: isCompacting, tooltip: isCompacting - ? `Cannot edit while compacting (press ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)` + ? `Cannot edit while compacting (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)` : undefined, }, ] diff --git a/src/hooks/useAIViewKeybinds.ts b/src/hooks/useAIViewKeybinds.ts index 5dce224d1..bbbca589f 100644 --- a/src/hooks/useAIViewKeybinds.ts +++ b/src/hooks/useAIViewKeybinds.ts @@ -7,6 +7,8 @@ import type { ThinkingLevel, ThinkingLevelOn } from "@/types/thinking"; import { DEFAULT_THINKING_LEVEL } from "@/types/thinking"; import { getThinkingPolicyForModel } from "@/utils/thinking/policy"; import { getDefaultModelFromLRU } from "@/hooks/useModelLRU"; +import type { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; +import { isCompactingStream, cancelCompaction } from "@/utils/compaction/handler"; interface UseAIViewKeybindsParams { workspaceId: string; @@ -19,6 +21,7 @@ interface UseAIViewKeybindsParams { chatInputAPI: React.RefObject; jumpToBottom: () => void; handleOpenTerminal: () => void; + aggregator: StreamingMessageAggregator; // For compaction detection } /** @@ -40,13 +43,24 @@ export function useAIViewKeybinds({ chatInputAPI, jumpToBottom, handleOpenTerminal, + aggregator, }: UseAIViewKeybindsParams): void { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Interrupt stream works anywhere (even in input fields) + // Ctrl+C during compaction: cancel and restore command to input if (matchesKeybind(e, KEYBINDS.INTERRUPT_STREAM)) { e.preventDefault(); - // If there's a stream or auto-retry in progress, stop it and disable auto-retry + + if (canInterrupt && isCompactingStream(aggregator)) { + // Special handling for compaction: delete messages and restore command + void cancelCompaction(workspaceId, aggregator, (command) => { + chatInputAPI.current?.restoreText(command); + }); + setAutoRetry(false); + return; + } + + // Normal stream interrupt if (canInterrupt || showRetryBarrier) { setAutoRetry(false); // User explicitly stopped - don't auto-retry void window.api.workspace.interruptStream(workspaceId); @@ -54,6 +68,18 @@ export function useAIViewKeybinds({ return; } + // Ctrl+A during compaction: accept early with [truncated] sentinel + if (matchesKeybind(e, KEYBINDS.ACCEPT_EARLY_COMPACTION)) { + e.preventDefault(); + + if (canInterrupt && isCompactingStream(aggregator)) { + // Interrupt stream - this triggers handleCompactionAbort in WorkspaceStore + setAutoRetry(false); + void window.api.workspace.interruptStream(workspaceId); + } + return; + } + // Focus chat input works anywhere (even in input fields) if (matchesKeybind(e, KEYBINDS.FOCUS_CHAT)) { e.preventDefault(); @@ -125,5 +151,6 @@ export function useAIViewKeybinds({ currentWorkspaceThinking, setThinkingLevel, chatInputAPI, + aggregator, ]); } diff --git a/src/utils/compaction/handler.ts b/src/utils/compaction/handler.ts new file mode 100644 index 000000000..0070d722e --- /dev/null +++ b/src/utils/compaction/handler.ts @@ -0,0 +1,79 @@ +/** + * Compaction interrupt handling + * + * Consolidated logic for handling Ctrl+C and Ctrl+A during compaction streams. + */ + +import type { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; + +/** + * Check if the workspace is currently in a compaction stream + */ +export function isCompactingStream(aggregator: StreamingMessageAggregator): boolean { + const messages = aggregator.getAllMessages(); + const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); + return lastUserMsg?.metadata?.cmuxMetadata?.type === "compaction-request"; +} + +/** + * Get the original /compact command from the last user message + */ +export function getCompactionCommand(aggregator: StreamingMessageAggregator): string | null { + const messages = aggregator.getAllMessages(); + const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); + + if (lastUserMsg?.metadata?.cmuxMetadata?.type !== "compaction-request") { + return null; + } + + return lastUserMsg.metadata.cmuxMetadata.rawCommand ?? null; +} + +/** + * Cancel compaction: interrupt stream, restore state before compaction + * + * This removes both the compaction request user message and the partial assistant summary, + * leaving the history as it was before /compact was invoked. + */ +export async function cancelCompaction( + workspaceId: string, + aggregator: StreamingMessageAggregator, + restoreCommandToInput: (command: string) => void +): Promise { + // Get the command before we modify history + const command = getCompactionCommand(aggregator); + if (!command) { + return false; + } + + // Get all messages before interrupting + const messages = aggregator.getAllMessages(); + + // Find the compaction request message + const compactionRequestIndex = messages.findIndex( + (m) => m.role === "user" && m.metadata?.cmuxMetadata?.type === "compaction-request" + ); + + if (compactionRequestIndex === -1) { + return false; + } + + // Interrupt the stream first + await window.api.workspace.interruptStream(workspaceId); + + // Calculate percentage to keep: everything before the compaction request + // After interrupt, we have: [...history, compactionRequest, partialSummary] + // We want to keep: [...history] + const totalMessages = messages.length + 1; // +1 for partial summary that will be committed + const messagesToKeep = compactionRequestIndex; + const percentageToKeep = messagesToKeep / totalMessages; + + // Truncate to remove compaction request + partial summary + await window.api.workspace.truncateHistory(workspaceId, percentageToKeep); + + // Restore command to input + restoreCommandToInput(command); + + return true; +} + diff --git a/src/utils/ui/keybinds.ts b/src/utils/ui/keybinds.ts index c59de04f5..5cc21e3cf 100644 --- a/src/utils/ui/keybinds.ts +++ b/src/utils/ui/keybinds.ts @@ -195,6 +195,9 @@ export const KEYBINDS = { /** Interrupt active stream (destructive - stops AI generation) */ INTERRUPT_STREAM: { key: "c", ctrl: true, macCtrlBehavior: "control" }, + /** Accept partial compaction early (adds [truncated] sentinel) */ + ACCEPT_EARLY_COMPACTION: { key: "a", ctrl: true, macCtrlBehavior: "control" }, + /** Focus chat input */ FOCUS_INPUT_I: { key: "i" }, From 98bbcc2bc138263d83d770580d3d4b88412580e8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:23:47 -0500 Subject: [PATCH 22/37] =?UTF-8?q?=F0=9F=A4=96=20Fix=20Ctrl+C=20compaction?= =?UTF-8?q?=20cancel=20bug=20with=20flag-based=20approach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem**: Ctrl+C during compaction resulted in empty chat because both Ctrl+C (cancel) and Ctrl+A (accept early) called interruptStream, triggering handleCompactionAbort which would always perform compaction. **Solution**: Add cancellingCompaction flag to distinguish between the two flows. **Implementation**: - WorkspaceStore: Added cancellingCompaction Set and markCompactionCancelling() method - handleCompactionAbort: Check flag before compaction, skip if set (Ctrl+C path) - cancelCompaction: Set flag before interrupt, prevent compaction - useAIViewKeybinds: Pass workspaceStore to cancelCompaction **Two distinct flows**: - Ctrl+C: Set flag → interrupt → skip compaction → truncate → restore command - Ctrl+A: No flag → interrupt → perform compaction with [truncated] **Benefits**: - Simple: Single short-lived flag (~10ms), self-cleaning - Frontend-only: All compaction logic stays in frontend - Well-commented: Clear explanations for future Assistants All tests pass (574 unit tests). _Generated with `cmux`_ --- src/hooks/useAIViewKeybinds.ts | 18 +++++++++--- src/stores/WorkspaceStore.ts | 43 +++++++++++++++++++++++++++-- src/utils/compaction/handler.ts | 49 ++++++++++++++++++++++----------- 3 files changed, 87 insertions(+), 23 deletions(-) diff --git a/src/hooks/useAIViewKeybinds.ts b/src/hooks/useAIViewKeybinds.ts index bbbca589f..0fd7384ea 100644 --- a/src/hooks/useAIViewKeybinds.ts +++ b/src/hooks/useAIViewKeybinds.ts @@ -9,6 +9,7 @@ import { getThinkingPolicyForModel } from "@/utils/thinking/policy"; import { getDefaultModelFromLRU } from "@/hooks/useModelLRU"; import type { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; import { isCompactingStream, cancelCompaction } from "@/utils/compaction/handler"; +import { useWorkspaceStoreRaw } from "@/stores/WorkspaceStore"; interface UseAIViewKeybindsParams { workspaceId: string; @@ -31,6 +32,8 @@ interface UseAIViewKeybindsParams { * - Ctrl+Shift+T: Toggle thinking level * - Ctrl+G: Jump to bottom * - Ctrl+T: Open terminal + * - Ctrl+C (during compaction): Cancel compaction, restore command + * - Ctrl+A (during compaction): Accept early with [truncated] */ export function useAIViewKeybinds({ workspaceId, @@ -45,22 +48,26 @@ export function useAIViewKeybinds({ handleOpenTerminal, aggregator, }: UseAIViewKeybindsParams): void { + const workspaceStore = useWorkspaceStoreRaw(); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl+C during compaction: cancel and restore command to input + // (different from Ctrl+A which accepts early with [truncated]) if (matchesKeybind(e, KEYBINDS.INTERRUPT_STREAM)) { e.preventDefault(); if (canInterrupt && isCompactingStream(aggregator)) { - // Special handling for compaction: delete messages and restore command - void cancelCompaction(workspaceId, aggregator, (command) => { + // Ctrl+C during compaction: restore original state + // Sets flag to prevent handleCompactionAbort from performing compaction + void cancelCompaction(workspaceId, aggregator, workspaceStore, (command) => { chatInputAPI.current?.restoreText(command); }); setAutoRetry(false); return; } - // Normal stream interrupt + // Normal stream interrupt (non-compaction) if (canInterrupt || showRetryBarrier) { setAutoRetry(false); // User explicitly stopped - don't auto-retry void window.api.workspace.interruptStream(workspaceId); @@ -69,11 +76,13 @@ export function useAIViewKeybinds({ } // Ctrl+A during compaction: accept early with [truncated] sentinel + // (different from Ctrl+C which cancels and restores original state) if (matchesKeybind(e, KEYBINDS.ACCEPT_EARLY_COMPACTION)) { e.preventDefault(); if (canInterrupt && isCompactingStream(aggregator)) { - // Interrupt stream - this triggers handleCompactionAbort in WorkspaceStore + // Ctrl+A during compaction: perform compaction with partial summary + // No flag set - handleCompactionAbort will perform compaction with [truncated] setAutoRetry(false); void window.api.workspace.interruptStream(workspaceId); } @@ -152,5 +161,6 @@ export function useAIViewKeybinds({ setThinkingLevel, chatInputAPI, aggregator, + workspaceStore, ]); } diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index e8425115b..dee999aba 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -133,6 +133,11 @@ export class WorkspaceStore { // Track model usage (injected dependency for useModelLRU integration) private readonly onModelUsed?: (model: string) => void; + // Track workspaces where compaction is being cancelled (Ctrl+C) vs accepted early (Ctrl+A) + // This flag prevents handleCompactionAbort from performing compaction during cancel flow. + // Short-lived: exists only between markCompactionCancelling() and handleCompactionAbort(). + private cancellingCompaction = new Set(); + constructor(onModelUsed?: (model: string) => void) { this.onModelUsed = onModelUsed; @@ -158,6 +163,25 @@ export class WorkspaceStore { ); } + /** + * Mark a workspace as cancelling compaction (Ctrl+C flow). + * + * This flag prevents handleCompactionAbort from performing compaction when the + * stream is interrupted via cancelCompaction. Without this flag, both Ctrl+C + * (cancel) and Ctrl+A (accept early) would trigger the same abort handler. + * + * Flag lifecycle: + * 1. cancelCompaction() sets flag before calling interruptStream + * 2. handleCompactionAbort() checks flag and skips compaction if set + * 3. handleCompactionAbort() cleans up flag immediately + * + * The flag exists for ~10ms during the cancel operation and is cleaned up + * by the abort handler itself. + */ + markCompactionCancelling(workspaceId: string): void { + this.cancellingCompaction.add(workspaceId); + } + /** * Check if any workspace's recency changed and bump global recency if so. * Uses cached recency values from aggregators for O(1) comparison per workspace. @@ -443,8 +467,13 @@ export class WorkspaceStore { } /** - * Handle interruption (Ctrl+C) of a compaction stream. - * Saves partial summary with [truncated] sentinel. + * Handle interruption of a compaction stream (StreamAbortEvent). + * + * Two distinct flows trigger this: + * - **Ctrl+A (accept early)**: Perform compaction with [truncated] sentinel + * - **Ctrl+C (cancel)**: Skip compaction, let cancelCompaction handle cleanup + * + * The cancellingCompaction flag distinguishes between these two flows. */ private handleCompactionAbort( workspaceId: string, @@ -462,7 +491,15 @@ export class WorkspaceStore { return false; } - // Extract whatever summary text was streamed before interruption + // Ctrl+C flow: Check if compaction is being cancelled + // If flag is set, skip compaction and clean up the flag + // cancelCompaction() will handle truncation and input restoration + if (this.cancellingCompaction.has(workspaceId)) { + this.cancellingCompaction.delete(workspaceId); + return false; // Return false to skip compaction + } + + // Ctrl+A flow: Accept early with [truncated] sentinel const partialSummary = aggregator.getCompactionSummary(data.messageId); if (!partialSummary) { console.warn("[WorkspaceStore] Compaction aborted but no partial summary found"); diff --git a/src/utils/compaction/handler.ts b/src/utils/compaction/handler.ts index 0070d722e..c34370520 100644 --- a/src/utils/compaction/handler.ts +++ b/src/utils/compaction/handler.ts @@ -1,10 +1,13 @@ /** * Compaction interrupt handling * - * Consolidated logic for handling Ctrl+C and Ctrl+A during compaction streams. + * Two interrupt flows during compaction: + * - Ctrl+C (cancel): Abort compaction, restore original history + command to input + * - Ctrl+A (accept early): Complete compaction with [truncated] sentinel */ import type { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; +import type { WorkspaceStore } from "@/stores/WorkspaceStore"; /** * Check if the workspace is currently in a compaction stream @@ -30,26 +33,36 @@ export function getCompactionCommand(aggregator: StreamingMessageAggregator): st } /** - * Cancel compaction: interrupt stream, restore state before compaction + * Cancel compaction (Ctrl+C flow) * - * This removes both the compaction request user message and the partial assistant summary, - * leaving the history as it was before /compact was invoked. + * Aborts the compaction stream and restores state to before /compact was invoked: + * - Interrupts stream without performing compaction (via flag) + * - Removes compaction request + partial summary from history + * - Restores original /compact command to input for re-editing + * + * Flow: + * 1. Set flag in WorkspaceStore to prevent handleCompactionAbort from compacting + * 2. Interrupt stream (triggers StreamAbortEvent) + * 3. handleCompactionAbort sees flag, skips compaction, cleans up flag + * 4. Truncate history to remove compaction request + partial summary + * 5. Restore command to input */ export async function cancelCompaction( workspaceId: string, aggregator: StreamingMessageAggregator, + workspaceStore: WorkspaceStore, restoreCommandToInput: (command: string) => void ): Promise { - // Get the command before we modify history + // Extract command before modifying history const command = getCompactionCommand(aggregator); if (!command) { return false; } - // Get all messages before interrupting + // Get messages snapshot before interrupting const messages = aggregator.getAllMessages(); - // Find the compaction request message + // Find where compaction request is located const compactionRequestIndex = messages.findIndex( (m) => m.role === "user" && m.metadata?.cmuxMetadata?.type === "compaction-request" ); @@ -58,20 +71,24 @@ export async function cancelCompaction( return false; } - // Interrupt the stream first + // CRITICAL: Mark workspace as cancelling BEFORE interrupt + // This tells handleCompactionAbort to skip compaction (Ctrl+C path vs Ctrl+A path) + workspaceStore.markCompactionCancelling(workspaceId); + + // Interrupt stream - triggers StreamAbortEvent → handleCompactionAbort + // handleCompactionAbort will see the flag and skip performCompaction await window.api.workspace.interruptStream(workspaceId); - // Calculate percentage to keep: everything before the compaction request - // After interrupt, we have: [...history, compactionRequest, partialSummary] - // We want to keep: [...history] - const totalMessages = messages.length + 1; // +1 for partial summary that will be committed - const messagesToKeep = compactionRequestIndex; - const percentageToKeep = messagesToKeep / totalMessages; + // Calculate truncation: keep everything before compaction request + // After interrupt: [...history, compactionRequest, partialSummary] + // We want: [...history] + const totalMessages = messages.length + 1; // +1 for partial summary committed by interrupt + const percentageToKeep = compactionRequestIndex / totalMessages; - // Truncate to remove compaction request + partial summary + // Truncate history to remove compaction artifacts await window.api.workspace.truncateHistory(workspaceId, percentageToKeep); - // Restore command to input + // Restore command to input so user can edit and retry restoreCommandToInput(command); return true; From b058c261cfda0fc0eb3f8c5e1d009361435f0747 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:40:46 -0500 Subject: [PATCH 23/37] =?UTF-8?q?=F0=9F=A4=96=20Fix=20Ctrl+C=20compaction?= =?UTF-8?q?=20cancel=20with=20localStorage=20(reload-safe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem**: Previous fix (98bbcc2b) used in-memory flag that was lost on reload, causing compaction to happen anyway after page refresh. **Root cause**: Flag stored in WorkspaceStore (in-memory) doesn't persist across reloads. StreamAbortEvent arrives, flag is gone, compaction happens. **Solution**: Use localStorage to persist cancellation intent across reloads. **Implementation**: - Store cancellation marker in localStorage BEFORE calling interruptStream - Include messageId for freshness verification - handleCompactionAbort checks localStorage and verifies messageId matches - Clean up marker after handling (prevents stale data) - No flag state, no backend changes needed **Flow**: 1. Ctrl+C: Store {messageId, timestamp} in localStorage 2. Interrupt stream → StreamAbortEvent 3. handleCompactionAbort checks localStorage, verifies messageId 4. If cancelled: skip compaction, clean up marker 5. cancelCompaction truncates history and restores command **Benefits**: - Reload-safe: localStorage persists across page refreshes - MessageId verification ensures freshness (no stale cancellations) - Simpler than backend changes - Frontend-only solution All tests pass (574 unit tests). _Generated with `cmux`_ --- src/constants/storage.ts | 9 ++++++ src/hooks/useAIViewKeybinds.ts | 10 ++----- src/stores/WorkspaceStore.ts | 53 ++++++++++++++------------------- src/utils/compaction/handler.ts | 39 ++++++++++++++++++------ 4 files changed, 64 insertions(+), 47 deletions(-) diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 504fd2eeb..96921cc3c 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -47,6 +47,15 @@ export function getLastThinkingByModelKey(modelName: string): string { return `lastThinkingByModel:${modelName}`; } +/** + * Get storage key for cancelled compaction tracking. + * Stores messageId of assistant message being cancelled to verify freshness. + */ +export function getCancelledCompactionKey(workspaceId: string): string { + return `workspace:${workspaceId}:cancelled-compaction`; +} + + /** * Get the localStorage key for the UI mode for a workspace * Format: "mode:{workspaceId}" diff --git a/src/hooks/useAIViewKeybinds.ts b/src/hooks/useAIViewKeybinds.ts index 0fd7384ea..cf2ca0e4b 100644 --- a/src/hooks/useAIViewKeybinds.ts +++ b/src/hooks/useAIViewKeybinds.ts @@ -9,7 +9,6 @@ import { getThinkingPolicyForModel } from "@/utils/thinking/policy"; import { getDefaultModelFromLRU } from "@/hooks/useModelLRU"; import type { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; import { isCompactingStream, cancelCompaction } from "@/utils/compaction/handler"; -import { useWorkspaceStoreRaw } from "@/stores/WorkspaceStore"; interface UseAIViewKeybindsParams { workspaceId: string; @@ -32,7 +31,7 @@ interface UseAIViewKeybindsParams { * - Ctrl+Shift+T: Toggle thinking level * - Ctrl+G: Jump to bottom * - Ctrl+T: Open terminal - * - Ctrl+C (during compaction): Cancel compaction, restore command + * - Ctrl+C (during compaction): Cancel compaction, restore command (uses localStorage) * - Ctrl+A (during compaction): Accept early with [truncated] */ export function useAIViewKeybinds({ @@ -48,8 +47,6 @@ export function useAIViewKeybinds({ handleOpenTerminal, aggregator, }: UseAIViewKeybindsParams): void { - const workspaceStore = useWorkspaceStoreRaw(); - useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl+C during compaction: cancel and restore command to input @@ -59,8 +56,8 @@ export function useAIViewKeybinds({ if (canInterrupt && isCompactingStream(aggregator)) { // Ctrl+C during compaction: restore original state - // Sets flag to prevent handleCompactionAbort from performing compaction - void cancelCompaction(workspaceId, aggregator, workspaceStore, (command) => { + // Stores cancellation marker in localStorage (persists across reloads) + void cancelCompaction(workspaceId, aggregator, (command) => { chatInputAPI.current?.restoreText(command); }); setAutoRetry(false); @@ -161,6 +158,5 @@ export function useAIViewKeybinds({ setThinkingLevel, chatInputAPI, aggregator, - workspaceStore, ]); } diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index dee999aba..1bed0fed2 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -28,6 +28,7 @@ import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager"; import type { ChatUsageDisplay } from "@/utils/tokens/usageAggregator"; import type { TokenConsumer } from "@/types/chatStats"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; +import { getCancelledCompactionKey } from "@/constants/storage"; export interface WorkspaceState { messages: DisplayedMessage[]; @@ -133,11 +134,6 @@ export class WorkspaceStore { // Track model usage (injected dependency for useModelLRU integration) private readonly onModelUsed?: (model: string) => void; - // Track workspaces where compaction is being cancelled (Ctrl+C) vs accepted early (Ctrl+A) - // This flag prevents handleCompactionAbort from performing compaction during cancel flow. - // Short-lived: exists only between markCompactionCancelling() and handleCompactionAbort(). - private cancellingCompaction = new Set(); - constructor(onModelUsed?: (model: string) => void) { this.onModelUsed = onModelUsed; @@ -163,25 +159,6 @@ export class WorkspaceStore { ); } - /** - * Mark a workspace as cancelling compaction (Ctrl+C flow). - * - * This flag prevents handleCompactionAbort from performing compaction when the - * stream is interrupted via cancelCompaction. Without this flag, both Ctrl+C - * (cancel) and Ctrl+A (accept early) would trigger the same abort handler. - * - * Flag lifecycle: - * 1. cancelCompaction() sets flag before calling interruptStream - * 2. handleCompactionAbort() checks flag and skips compaction if set - * 3. handleCompactionAbort() cleans up flag immediately - * - * The flag exists for ~10ms during the cancel operation and is cleaned up - * by the abort handler itself. - */ - markCompactionCancelling(workspaceId: string): void { - this.cancellingCompaction.add(workspaceId); - } - /** * Check if any workspace's recency changed and bump global recency if so. * Uses cached recency values from aggregators for O(1) comparison per workspace. @@ -473,7 +450,10 @@ export class WorkspaceStore { * - **Ctrl+A (accept early)**: Perform compaction with [truncated] sentinel * - **Ctrl+C (cancel)**: Skip compaction, let cancelCompaction handle cleanup * - * The cancellingCompaction flag distinguishes between these two flows. + * Uses localStorage to distinguish flows: + * - Checks for cancellation marker in localStorage + * - Verifies messageId matches for freshness + * - Reload-safe: localStorage persists across page reloads */ private handleCompactionAbort( workspaceId: string, @@ -491,12 +471,23 @@ export class WorkspaceStore { return false; } - // Ctrl+C flow: Check if compaction is being cancelled - // If flag is set, skip compaction and clean up the flag - // cancelCompaction() will handle truncation and input restoration - if (this.cancellingCompaction.has(workspaceId)) { - this.cancellingCompaction.delete(workspaceId); - return false; // Return false to skip compaction + // Ctrl+C flow: Check localStorage for cancellation marker + // Verify messageId matches to ensure this is a recent/valid cancellation + const storageKey = getCancelledCompactionKey(workspaceId); + const cancelData = localStorage.getItem(storageKey); + if (cancelData) { + try { + const { messageId } = JSON.parse(cancelData); + if (messageId === data.messageId) { + // This is a cancelled compaction - clean up marker and skip compaction + localStorage.removeItem(storageKey); + return false; // Skip compaction, cancelCompaction() handles cleanup + } + } catch (error) { + console.error("[WorkspaceStore] Failed to parse cancellation data:", error); + } + // If messageId doesn't match or parse failed, clean up stale data + localStorage.removeItem(storageKey); } // Ctrl+A flow: Accept early with [truncated] sentinel diff --git a/src/utils/compaction/handler.ts b/src/utils/compaction/handler.ts index c34370520..a0607d789 100644 --- a/src/utils/compaction/handler.ts +++ b/src/utils/compaction/handler.ts @@ -4,10 +4,15 @@ * Two interrupt flows during compaction: * - Ctrl+C (cancel): Abort compaction, restore original history + command to input * - Ctrl+A (accept early): Complete compaction with [truncated] sentinel + * + * Uses localStorage to persist cancellation intent across reloads: + * - Before interrupt, store messageId in localStorage + * - handleCompactionAbort checks localStorage and verifies messageId matches + * - Reload-safe: localStorage persists, messageId ensures freshness */ import type { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; -import type { WorkspaceStore } from "@/stores/WorkspaceStore"; +import { getCancelledCompactionKey } from "@/constants/storage"; /** * Check if the workspace is currently in a compaction stream @@ -36,21 +41,22 @@ export function getCompactionCommand(aggregator: StreamingMessageAggregator): st * Cancel compaction (Ctrl+C flow) * * Aborts the compaction stream and restores state to before /compact was invoked: - * - Interrupts stream without performing compaction (via flag) + * - Interrupts stream without performing compaction (via localStorage marker) * - Removes compaction request + partial summary from history * - Restores original /compact command to input for re-editing * * Flow: - * 1. Set flag in WorkspaceStore to prevent handleCompactionAbort from compacting + * 1. Store cancellation marker in localStorage with messageId for verification * 2. Interrupt stream (triggers StreamAbortEvent) - * 3. handleCompactionAbort sees flag, skips compaction, cleans up flag + * 3. handleCompactionAbort checks localStorage, verifies messageId, skips compaction * 4. Truncate history to remove compaction request + partial summary * 5. Restore command to input + * + * Reload-safe: localStorage persists across reloads, messageId ensures freshness */ export async function cancelCompaction( workspaceId: string, aggregator: StreamingMessageAggregator, - workspaceStore: WorkspaceStore, restoreCommandToInput: (command: string) => void ): Promise { // Extract command before modifying history @@ -71,12 +77,27 @@ export async function cancelCompaction( return false; } - // CRITICAL: Mark workspace as cancelling BEFORE interrupt - // This tells handleCompactionAbort to skip compaction (Ctrl+C path vs Ctrl+A path) - workspaceStore.markCompactionCancelling(workspaceId); + // Get the current streaming assistant message ID from messages + // During compaction, there should be a streaming assistant message after the compaction request + const streamingMessage = messages.find( + (m) => m.role === "assistant" && m.metadata?.partial === true + ); + + if (!streamingMessage) { + // No streaming message found - shouldn't happen during active compaction + return false; + } + + // CRITICAL: Store cancellation marker in localStorage BEFORE interrupt + // This persists across reloads and includes messageId for verification + const storageKey = getCancelledCompactionKey(workspaceId); + localStorage.setItem(storageKey, JSON.stringify({ + messageId: streamingMessage.id, + timestamp: Date.now(), + })); // Interrupt stream - triggers StreamAbortEvent → handleCompactionAbort - // handleCompactionAbort will see the flag and skip performCompaction + // handleCompactionAbort will check localStorage and verify messageId matches await window.api.workspace.interruptStream(workspaceId); // Calculate truncation: keep everything before compaction request From e2037c85af2eb57476c6fc2803371af1aece368b Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:43:34 -0500 Subject: [PATCH 24/37] Add debug logging for compaction cancel flow --- src/stores/WorkspaceStore.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 1bed0fed2..1cccd660e 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -475,11 +475,19 @@ export class WorkspaceStore { // Verify messageId matches to ensure this is a recent/valid cancellation const storageKey = getCancelledCompactionKey(workspaceId); const cancelData = localStorage.getItem(storageKey); + console.log("[WorkspaceStore] handleCompactionAbort - checking localStorage:", { + workspaceId, + storageKey, + cancelData, + eventMessageId: data.messageId, + }); if (cancelData) { try { const { messageId } = JSON.parse(cancelData); + console.log("[WorkspaceStore] Parsed cancellation data:", { stored: messageId, event: data.messageId, match: messageId === data.messageId }); if (messageId === data.messageId) { // This is a cancelled compaction - clean up marker and skip compaction + console.log("[WorkspaceStore] Cancellation confirmed - skipping compaction"); localStorage.removeItem(storageKey); return false; // Skip compaction, cancelCompaction() handles cleanup } @@ -487,6 +495,7 @@ export class WorkspaceStore { console.error("[WorkspaceStore] Failed to parse cancellation data:", error); } // If messageId doesn't match or parse failed, clean up stale data + console.log("[WorkspaceStore] Cleaning up stale/mismatched cancellation marker"); localStorage.removeItem(storageKey); } From d21d577db351fc59f4e8cec970ff6da118dfbd09 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:45:39 -0500 Subject: [PATCH 25/37] Use compaction-request user message ID (stable across retries) The assistant message ID changes with each stream attempt, causing mismatches. The compaction-request user message ID is stable and correctly identifies which compaction is being cancelled. --- src/stores/WorkspaceStore.ts | 17 +++++++++++------ src/utils/compaction/handler.ts | 18 +++++------------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 1cccd660e..046ae5732 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -472,20 +472,25 @@ export class WorkspaceStore { } // Ctrl+C flow: Check localStorage for cancellation marker - // Verify messageId matches to ensure this is a recent/valid cancellation + // Verify compaction-request user message ID matches (stable across retries) const storageKey = getCancelledCompactionKey(workspaceId); const cancelData = localStorage.getItem(storageKey); console.log("[WorkspaceStore] handleCompactionAbort - checking localStorage:", { workspaceId, storageKey, cancelData, - eventMessageId: data.messageId, + lastUserMsgId: lastUserMsg?.id, }); if (cancelData) { try { - const { messageId } = JSON.parse(cancelData); - console.log("[WorkspaceStore] Parsed cancellation data:", { stored: messageId, event: data.messageId, match: messageId === data.messageId }); - if (messageId === data.messageId) { + const { compactionRequestId } = JSON.parse(cancelData); + const match = compactionRequestId === lastUserMsg?.id; + console.log("[WorkspaceStore] Parsed cancellation data:", { + stored: compactionRequestId, + current: lastUserMsg?.id, + match + }); + if (match) { // This is a cancelled compaction - clean up marker and skip compaction console.log("[WorkspaceStore] Cancellation confirmed - skipping compaction"); localStorage.removeItem(storageKey); @@ -494,7 +499,7 @@ export class WorkspaceStore { } catch (error) { console.error("[WorkspaceStore] Failed to parse cancellation data:", error); } - // If messageId doesn't match or parse failed, clean up stale data + // If compactionRequestId doesn't match or parse failed, clean up stale data console.log("[WorkspaceStore] Cleaning up stale/mismatched cancellation marker"); localStorage.removeItem(storageKey); } diff --git a/src/utils/compaction/handler.ts b/src/utils/compaction/handler.ts index a0607d789..b590b9428 100644 --- a/src/utils/compaction/handler.ts +++ b/src/utils/compaction/handler.ts @@ -68,7 +68,7 @@ export async function cancelCompaction( // Get messages snapshot before interrupting const messages = aggregator.getAllMessages(); - // Find where compaction request is located + // Find the compaction request message const compactionRequestIndex = messages.findIndex( (m) => m.role === "user" && m.metadata?.cmuxMetadata?.type === "compaction-request" ); @@ -77,22 +77,14 @@ export async function cancelCompaction( return false; } - // Get the current streaming assistant message ID from messages - // During compaction, there should be a streaming assistant message after the compaction request - const streamingMessage = messages.find( - (m) => m.role === "assistant" && m.metadata?.partial === true - ); - - if (!streamingMessage) { - // No streaming message found - shouldn't happen during active compaction - return false; - } + const compactionRequestMsg = messages[compactionRequestIndex]; // CRITICAL: Store cancellation marker in localStorage BEFORE interrupt - // This persists across reloads and includes messageId for verification + // Use the compaction-request user message ID (stable across retries) + // This persists across reloads and verifies we're cancelling the right compaction const storageKey = getCancelledCompactionKey(workspaceId); localStorage.setItem(storageKey, JSON.stringify({ - messageId: streamingMessage.id, + compactionRequestId: compactionRequestMsg.id, timestamp: Date.now(), })); From 152b3d8b08289c3994873e791690cc45c83e882a Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:53:11 -0500 Subject: [PATCH 26/37] =?UTF-8?q?=F0=9F=A4=96=20Add=20abandonPartial=20fla?= =?UTF-8?q?g=20to=20interruptStream=20for=20clean=20cancellation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem**: cancelCompaction was trying to calculate percentage truncation after committing partial, leading to incorrect truncation removing too much history. **Solution**: Add abandonPartial flag to interruptStream that deletes the partial instead of committing it to history. **Implementation**: - Add options parameter to interruptStream IPC (abandonPartial?: boolean) - Backend: if abandonPartial is true, delete partial instead of committing - AIService: check if partial exists before committing on stream-abort - cancelCompaction: pass {abandonPartial: true} when interrupting - Simplified truncation: now just remove compaction-request message **Benefits**: - No race conditions with partial commit timing - Simple percentage calculation (no +1 guesswork) - Backend explicitly handles the cleanup - Clear separation: Ctrl+C abandons partial, Ctrl+A commits with [truncated] All tests pass (574 unit tests). _Generated with `cmux`_ --- src/preload.ts | 4 ++-- src/services/aiService.ts | 13 +++++++++---- src/services/ipcMain.ts | 11 +++++++++-- src/utils/compaction/handler.ts | 16 ++++++++-------- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/preload.ts b/src/preload.ts index 632d7394e..0bfb2d462 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -61,8 +61,8 @@ const api: IPCApi = { ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options), resumeStream: (workspaceId, options) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options), - interruptStream: (workspaceId) => - ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId), + interruptStream: (workspaceId, options?) => + ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), truncateHistory: (workspaceId, percentage) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage), replaceChatHistory: (workspaceId, summaryMessage) => diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 6febb7d5d..029f81a81 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -138,12 +138,17 @@ export class AIService extends EventEmitter { this.streamManager.on("stream-end", (data) => this.emit("stream-end", data)); // Handle stream-abort: commit partial to history before forwarding + // Note: If abandonPartial option was used, partial is already deleted by IPC handler this.streamManager.on("stream-abort", (data: StreamAbortEvent) => { void (async () => { - // Commit interrupted message to history with partial:true metadata - // This ensures /clear and /truncate can clean up interrupted messages - await this.partialService.commitToHistory(data.workspaceId); - await this.partialService.deletePartial(data.workspaceId); + // Check if partial still exists (not abandoned) + const partial = await this.partialService.readPartial(data.workspaceId); + if (partial) { + // Commit interrupted message to history with partial:true metadata + // This ensures /clear and /truncate can clean up interrupted messages + await this.partialService.commitToHistory(data.workspaceId); + await this.partialService.deletePartial(data.workspaceId); + } // Forward abort event to consumers this.emit("stream-abort", data); diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 01fd1b4c1..17792da8f 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -588,8 +588,8 @@ export class IpcMain { } ); - ipcMain.handle(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, async (_event, workspaceId: string) => { - log.debug("interruptStream handler: Received", { workspaceId }); + ipcMain.handle(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, async (_event, workspaceId: string, options?: { abandonPartial?: boolean }) => { + log.debug("interruptStream handler: Received", { workspaceId, options }); try { const session = this.getOrCreateSession(workspaceId); const stopResult = await session.interruptStream(); @@ -597,6 +597,13 @@ export class IpcMain { log.error("Failed to stop stream:", stopResult.error); return { success: false, error: stopResult.error }; } + + // If abandonPartial is true, delete the partial instead of committing it + if (options?.abandonPartial) { + log.debug("Abandoning partial for workspace:", workspaceId); + await this.partialService.deletePartial(workspaceId); + } + return { success: true, data: undefined }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/utils/compaction/handler.ts b/src/utils/compaction/handler.ts index b590b9428..1973118a9 100644 --- a/src/utils/compaction/handler.ts +++ b/src/utils/compaction/handler.ts @@ -88,17 +88,17 @@ export async function cancelCompaction( timestamp: Date.now(), })); - // Interrupt stream - triggers StreamAbortEvent → handleCompactionAbort - // handleCompactionAbort will check localStorage and verify messageId matches - await window.api.workspace.interruptStream(workspaceId); + // Interrupt stream with abandonPartial flag + // This tells backend to DELETE the partial instead of committing it + // Result: history ends with the compaction-request user message + await window.api.workspace.interruptStream(workspaceId, { abandonPartial: true }); - // Calculate truncation: keep everything before compaction request - // After interrupt: [...history, compactionRequest, partialSummary] + // Truncate history to remove compaction-request message + // After interrupt with abandonPartial: [...history, compactionRequest] // We want: [...history] - const totalMessages = messages.length + 1; // +1 for partial summary committed by interrupt - const percentageToKeep = compactionRequestIndex / totalMessages; + const percentageToKeep = compactionRequestIndex / messages.length; - // Truncate history to remove compaction artifacts + // Truncate to remove compaction request await window.api.workspace.truncateHistory(workspaceId, percentageToKeep); // Restore command to input so user can edit and retry From dd1353cca90472b474f4c573b447920734a2e57d Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:53:51 -0500 Subject: [PATCH 27/37] Simplify: keep compaction-request in history on cancel No need to truncate - the compaction-request message is just a normal user message. Leaving it in history is fine and shows what the user tried to do. Simplifies the code by removing all truncation logic from cancelCompaction. --- src/utils/compaction/handler.ts | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/utils/compaction/handler.ts b/src/utils/compaction/handler.ts index 1973118a9..07d89344f 100644 --- a/src/utils/compaction/handler.ts +++ b/src/utils/compaction/handler.ts @@ -40,19 +40,19 @@ export function getCompactionCommand(aggregator: StreamingMessageAggregator): st /** * Cancel compaction (Ctrl+C flow) * - * Aborts the compaction stream and restores state to before /compact was invoked: - * - Interrupts stream without performing compaction (via localStorage marker) - * - Removes compaction request + partial summary from history + * Aborts the compaction stream and restores command to input: + * - Interrupts stream with abandonPartial flag (deletes partial, doesn't commit) + * - Skips compaction (via localStorage marker checked by handleCompactionAbort) * - Restores original /compact command to input for re-editing + * - Leaves compaction-request message in history (harmless user message) * * Flow: - * 1. Store cancellation marker in localStorage with messageId for verification - * 2. Interrupt stream (triggers StreamAbortEvent) - * 3. handleCompactionAbort checks localStorage, verifies messageId, skips compaction - * 4. Truncate history to remove compaction request + partial summary - * 5. Restore command to input + * 1. Store cancellation marker in localStorage with compactionRequestId for verification + * 2. Interrupt stream with {abandonPartial: true} - backend deletes partial + * 3. handleCompactionAbort checks localStorage, verifies compactionRequestId, skips compaction + * 4. Restore command to input * - * Reload-safe: localStorage persists across reloads, messageId ensures freshness + * Reload-safe: localStorage persists across reloads, compactionRequestId ensures freshness */ export async function cancelCompaction( workspaceId: string, @@ -90,18 +90,11 @@ export async function cancelCompaction( // Interrupt stream with abandonPartial flag // This tells backend to DELETE the partial instead of committing it - // Result: history ends with the compaction-request user message + // Result: history ends with the compaction-request user message (which is fine - just a user message) await window.api.workspace.interruptStream(workspaceId, { abandonPartial: true }); - // Truncate history to remove compaction-request message - // After interrupt with abandonPartial: [...history, compactionRequest] - // We want: [...history] - const percentageToKeep = compactionRequestIndex / messages.length; - - // Truncate to remove compaction request - await window.api.workspace.truncateHistory(workspaceId, percentageToKeep); - // Restore command to input so user can edit and retry + // Note: We leave the compaction-request message in history - user can delete it manually if desired restoreCommandToInput(command); return true; From 404362159e3d7df4b4e6f249ece788a595ead8bb Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:54:14 -0500 Subject: [PATCH 28/37] Fix type errors in preload.ts --- src/preload.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/preload.ts b/src/preload.ts index 0bfb2d462..dd4513a91 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -61,7 +61,7 @@ const api: IPCApi = { ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options), resumeStream: (workspaceId, options) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options), - interruptStream: (workspaceId, options?) => + interruptStream: (workspaceId: string, options?: { abandonPartial?: boolean }) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), truncateHistory: (workspaceId, percentage) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage), From fc7851f8d19ae02bc9ff9c270d790b093052a05c Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:56:42 -0500 Subject: [PATCH 29/37] Fix interruptStream type declaration in ipc.ts --- src/types/ipc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 128875a0a..4642b3a2b 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -209,7 +209,7 @@ export interface IPCApi { workspaceId: string, options: SendMessageOptions ): Promise>; - interruptStream(workspaceId: string): Promise>; + interruptStream(workspaceId: string, options?: { abandonPartial?: boolean }): Promise>; truncateHistory(workspaceId: string, percentage?: number): Promise>; replaceChatHistory( workspaceId: string, From 3fbd600ecf561fd7879e634295f9ce40dab9c7db Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:57:28 -0500 Subject: [PATCH 30/37] Enter edit mode on compaction-request after Ctrl+C cancel When user cancels compaction with Ctrl+C, drop them into edit mode on the compaction-request message with the original /compact command. This makes it easy to edit the command or delete the message. --- src/hooks/useAIViewKeybinds.ts | 6 +++--- src/utils/compaction/handler.ts | 15 ++++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/hooks/useAIViewKeybinds.ts b/src/hooks/useAIViewKeybinds.ts index cf2ca0e4b..faaa062ab 100644 --- a/src/hooks/useAIViewKeybinds.ts +++ b/src/hooks/useAIViewKeybinds.ts @@ -55,10 +55,10 @@ export function useAIViewKeybinds({ e.preventDefault(); if (canInterrupt && isCompactingStream(aggregator)) { - // Ctrl+C during compaction: restore original state + // Ctrl+C during compaction: restore original state and enter edit mode // Stores cancellation marker in localStorage (persists across reloads) - void cancelCompaction(workspaceId, aggregator, (command) => { - chatInputAPI.current?.restoreText(command); + void cancelCompaction(workspaceId, aggregator, (messageId, command) => { + chatInputAPI.current?.startEditing(messageId, command); }); setAutoRetry(false); return; diff --git a/src/utils/compaction/handler.ts b/src/utils/compaction/handler.ts index 07d89344f..f669b2740 100644 --- a/src/utils/compaction/handler.ts +++ b/src/utils/compaction/handler.ts @@ -40,24 +40,25 @@ export function getCompactionCommand(aggregator: StreamingMessageAggregator): st /** * Cancel compaction (Ctrl+C flow) * - * Aborts the compaction stream and restores command to input: + * Aborts the compaction stream and puts user in edit mode for compaction-request: * - Interrupts stream with abandonPartial flag (deletes partial, doesn't commit) * - Skips compaction (via localStorage marker checked by handleCompactionAbort) + * - Enters edit mode on compaction-request message * - Restores original /compact command to input for re-editing - * - Leaves compaction-request message in history (harmless user message) + * - Leaves compaction-request message in history (can edit or delete it) * * Flow: * 1. Store cancellation marker in localStorage with compactionRequestId for verification * 2. Interrupt stream with {abandonPartial: true} - backend deletes partial * 3. handleCompactionAbort checks localStorage, verifies compactionRequestId, skips compaction - * 4. Restore command to input + * 4. Enter edit mode on compaction-request message with original command * * Reload-safe: localStorage persists across reloads, compactionRequestId ensures freshness */ export async function cancelCompaction( workspaceId: string, aggregator: StreamingMessageAggregator, - restoreCommandToInput: (command: string) => void + startEditingMessage: (messageId: string, initialText: string) => void ): Promise { // Extract command before modifying history const command = getCompactionCommand(aggregator); @@ -93,9 +94,9 @@ export async function cancelCompaction( // Result: history ends with the compaction-request user message (which is fine - just a user message) await window.api.workspace.interruptStream(workspaceId, { abandonPartial: true }); - // Restore command to input so user can edit and retry - // Note: We leave the compaction-request message in history - user can delete it manually if desired - restoreCommandToInput(command); + // Enter edit mode on the compaction-request message with original command + // This lets user immediately edit the message or delete it + startEditingMessage(compactionRequestMsg.id, command); return true; } From f799239d7305b5ecaeebe1b0b8f5152bcaeb5e45 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:57:56 -0500 Subject: [PATCH 31/37] Add startEditing method to ChatInputAPI --- src/components/ChatInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index f9369c88b..633f7e61d 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -121,6 +121,7 @@ const ModelDisplayWrapper = styled.div` export interface ChatInputAPI { focus: () => void; restoreText: (text: string) => void; + startEditing: (messageId: string, initialText: string) => void; } export interface ChatInputProps { From 3611335a6d2965f9f3b33e57d2f99f78e227a988 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:58:13 -0500 Subject: [PATCH 32/37] Use setEditingMessage directly instead of ChatInputAPI Editing state lives in AIView, not ChatInput. Pass setEditingMessage to useAIViewKeybinds and call it directly from cancelCompaction callback. --- src/components/ChatInput.tsx | 9 ++++++++- src/hooks/useAIViewKeybinds.ts | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 633f7e61d..5e314b13f 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -439,14 +439,21 @@ export const ChatInput: React.FC = ({ }, [focusMessageInput]); // Provide API to parent via callback + const startEditing = useCallback((messageId: string, initialText: string) => { + setEditingMessageId(messageId); + setInput(initialText); + focusMessageInput(); + }, [focusMessageInput]); + useEffect(() => { if (onReady) { onReady({ focus: focusMessageInput, restoreText, + startEditing, }); } - }, [onReady, focusMessageInput, restoreText]); + }, [onReady, focusMessageInput, restoreText, startEditing]); useEffect(() => { const handleGlobalKeyDown = (event: KeyboardEvent) => { diff --git a/src/hooks/useAIViewKeybinds.ts b/src/hooks/useAIViewKeybinds.ts index faaa062ab..e0fcf1c04 100644 --- a/src/hooks/useAIViewKeybinds.ts +++ b/src/hooks/useAIViewKeybinds.ts @@ -22,6 +22,7 @@ interface UseAIViewKeybindsParams { jumpToBottom: () => void; handleOpenTerminal: () => void; aggregator: StreamingMessageAggregator; // For compaction detection + setEditingMessage: (editing: { id: string; content: string } | undefined) => void; } /** @@ -46,6 +47,7 @@ export function useAIViewKeybinds({ jumpToBottom, handleOpenTerminal, aggregator, + setEditingMessage, }: UseAIViewKeybindsParams): void { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -58,7 +60,7 @@ export function useAIViewKeybinds({ // Ctrl+C during compaction: restore original state and enter edit mode // Stores cancellation marker in localStorage (persists across reloads) void cancelCompaction(workspaceId, aggregator, (messageId, command) => { - chatInputAPI.current?.startEditing(messageId, command); + setEditingMessage({ id: messageId, content: command }); }); setAutoRetry(false); return; @@ -158,5 +160,6 @@ export function useAIViewKeybinds({ setThinkingLevel, chatInputAPI, aggregator, + setEditingMessage, ]); } From 6a8afc1954a0f3d50d83bdb69658cdacd488b466 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:58:56 -0500 Subject: [PATCH 33/37] Revert ChatInput API changes - not needed for edit mode approach --- src/components/ChatInput.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 5e314b13f..f9369c88b 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -121,7 +121,6 @@ const ModelDisplayWrapper = styled.div` export interface ChatInputAPI { focus: () => void; restoreText: (text: string) => void; - startEditing: (messageId: string, initialText: string) => void; } export interface ChatInputProps { @@ -439,21 +438,14 @@ export const ChatInput: React.FC = ({ }, [focusMessageInput]); // Provide API to parent via callback - const startEditing = useCallback((messageId: string, initialText: string) => { - setEditingMessageId(messageId); - setInput(initialText); - focusMessageInput(); - }, [focusMessageInput]); - useEffect(() => { if (onReady) { onReady({ focus: focusMessageInput, restoreText, - startEditing, }); } - }, [onReady, focusMessageInput, restoreText, startEditing]); + }, [onReady, focusMessageInput, restoreText]); useEffect(() => { const handleGlobalKeyDown = (event: KeyboardEvent) => { From 6e117d6bd3d6d2b9538a719969a17e11175dacdf Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:59:01 -0500 Subject: [PATCH 34/37] Pass setEditingMessage to useAIViewKeybinds --- src/components/AIView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 79f3d2a34..18fecf8d6 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -346,6 +346,7 @@ const AIViewInner: React.FC = ({ jumpToBottom, handleOpenTerminal, aggregator, + setEditingMessage, }); // Clear editing state if the message being edited no longer exists From fc4d0f3f11159be2948de9a04422347f94bfe20e Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 20:59:16 -0500 Subject: [PATCH 35/37] Remove debug logging from WorkspaceStore --- src/stores/WorkspaceStore.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 046ae5732..92b42882f 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -475,24 +475,11 @@ export class WorkspaceStore { // Verify compaction-request user message ID matches (stable across retries) const storageKey = getCancelledCompactionKey(workspaceId); const cancelData = localStorage.getItem(storageKey); - console.log("[WorkspaceStore] handleCompactionAbort - checking localStorage:", { - workspaceId, - storageKey, - cancelData, - lastUserMsgId: lastUserMsg?.id, - }); if (cancelData) { try { const { compactionRequestId } = JSON.parse(cancelData); - const match = compactionRequestId === lastUserMsg?.id; - console.log("[WorkspaceStore] Parsed cancellation data:", { - stored: compactionRequestId, - current: lastUserMsg?.id, - match - }); - if (match) { + if (compactionRequestId === lastUserMsg?.id) { // This is a cancelled compaction - clean up marker and skip compaction - console.log("[WorkspaceStore] Cancellation confirmed - skipping compaction"); localStorage.removeItem(storageKey); return false; // Skip compaction, cancelCompaction() handles cleanup } @@ -500,7 +487,6 @@ export class WorkspaceStore { console.error("[WorkspaceStore] Failed to parse cancellation data:", error); } // If compactionRequestId doesn't match or parse failed, clean up stale data - console.log("[WorkspaceStore] Cleaning up stale/mismatched cancellation marker"); localStorage.removeItem(storageKey); } From bebbc41a9ef7588423e163389549cf9d018bf091 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 21:10:06 -0500 Subject: [PATCH 36/37] =?UTF-8?q?=F0=9F=A4=96=20Refactor:=20Extract=20comp?= =?UTF-8?q?action=20helper=20functions=20to=20reduce=20duplication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added findCompactionRequestMessage() helper to eliminate duplicated message finding logic - Refactored WorkspaceStore to use isCompactingStream() and findCompactionRequestMessage() - Simplified cancelCompaction() to use the new helper - Fixed outdated comment in storage.ts about messageId tracking Net result: -13 lines of duplicated code across WorkspaceStore methods --- src/constants/storage.ts | 2 +- src/stores/WorkspaceStore.ts | 26 +++++++++---------- src/utils/compaction/handler.ts | 46 +++++++++++++++++++-------------- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 96921cc3c..5a01b1e95 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -49,7 +49,7 @@ export function getLastThinkingByModelKey(modelName: string): string { /** * Get storage key for cancelled compaction tracking. - * Stores messageId of assistant message being cancelled to verify freshness. + * Stores compaction-request user message ID to verify freshness across reloads. */ export function getCancelledCompactionKey(workspaceId: string): string { return `workspace:${workspaceId}:cancelled-compaction`; diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 92b42882f..00e898df4 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -29,6 +29,7 @@ import type { ChatUsageDisplay } from "@/utils/tokens/usageAggregator"; import type { TokenConsumer } from "@/types/chatStats"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { getCancelledCompactionKey } from "@/constants/storage"; +import { isCompactingStream, findCompactionRequestMessage } from "@/utils/compaction/handler"; export interface WorkspaceState { messages: DisplayedMessage[]; @@ -424,11 +425,8 @@ export class WorkspaceStore { // Type guard: only StreamEndEvent has messageId if (!("messageId" in data)) return false; - // Check if this was a compaction stream by looking at the last user message - const messages = aggregator.getAllMessages(); - const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); - - if (lastUserMsg?.metadata?.cmuxMetadata?.type !== "compaction-request") { + // Check if this was a compaction stream + if (!isCompactingStream(aggregator)) { return false; } @@ -463,11 +461,14 @@ export class WorkspaceStore { // Type guard: only StreamAbortEvent has messageId if (!("messageId" in data)) return false; - // Check if this was a compaction stream by looking at the last user message - const messages = aggregator.getAllMessages(); - const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); + // Check if this was a compaction stream + if (!isCompactingStream(aggregator)) { + return false; + } - if (lastUserMsg?.metadata?.cmuxMetadata?.type !== "compaction-request") { + // Get the compaction request message for ID verification + const compactionRequestMsg = findCompactionRequestMessage(aggregator); + if (!compactionRequestMsg) { return false; } @@ -478,7 +479,7 @@ export class WorkspaceStore { if (cancelData) { try { const { compactionRequestId } = JSON.parse(cancelData); - if (compactionRequestId === lastUserMsg?.id) { + if (compactionRequestId === compactionRequestMsg.id) { // This is a cancelled compaction - clean up marker and skip compaction localStorage.removeItem(storageKey); return false; // Skip compaction, cancelCompaction() handles cleanup @@ -518,10 +519,7 @@ export class WorkspaceStore { const metadata = "metadata" in data ? data.metadata : undefined; // Extract continueMessage from compaction-request before history gets replaced - const messages = aggregator.getAllMessages(); - const compactRequestMsg = [...messages] - .reverse() - .find((m) => m.role === "user" && m.metadata?.cmuxMetadata?.type === "compaction-request"); + const compactRequestMsg = findCompactionRequestMessage(aggregator); const cmuxMeta = compactRequestMsg?.metadata?.cmuxMetadata; const continueMessage = cmuxMeta?.type === "compaction-request" ? cmuxMeta.parsed.continueMessage : undefined; diff --git a/src/utils/compaction/handler.ts b/src/utils/compaction/handler.ts index f669b2740..300daa4df 100644 --- a/src/utils/compaction/handler.ts +++ b/src/utils/compaction/handler.ts @@ -23,18 +23,32 @@ export function isCompactingStream(aggregator: StreamingMessageAggregator): bool return lastUserMsg?.metadata?.cmuxMetadata?.type === "compaction-request"; } +/** + * Find the compaction-request user message in message history + */ +export function findCompactionRequestMessage( + aggregator: StreamingMessageAggregator +): ReturnType[number] | null { + const messages = aggregator.getAllMessages(); + return ( + [...messages] + .reverse() + .find((m) => m.role === "user" && m.metadata?.cmuxMetadata?.type === "compaction-request") ?? + null + ); +} + /** * Get the original /compact command from the last user message */ export function getCompactionCommand(aggregator: StreamingMessageAggregator): string | null { - const messages = aggregator.getAllMessages(); - const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); + const compactionMsg = findCompactionRequestMessage(aggregator); + if (!compactionMsg) return null; - if (lastUserMsg?.metadata?.cmuxMetadata?.type !== "compaction-request") { - return null; - } + const cmuxMeta = compactionMsg.metadata?.cmuxMetadata; + if (cmuxMeta?.type !== "compaction-request") return null; - return lastUserMsg.metadata.cmuxMetadata.rawCommand ?? null; + return cmuxMeta.rawCommand ?? null; } /** @@ -60,26 +74,18 @@ export async function cancelCompaction( aggregator: StreamingMessageAggregator, startEditingMessage: (messageId: string, initialText: string) => void ): Promise { - // Extract command before modifying history - const command = getCompactionCommand(aggregator); - if (!command) { + // Find the compaction request message + const compactionRequestMsg = findCompactionRequestMessage(aggregator); + if (!compactionRequestMsg) { return false; } - // Get messages snapshot before interrupting - const messages = aggregator.getAllMessages(); - - // Find the compaction request message - const compactionRequestIndex = messages.findIndex( - (m) => m.role === "user" && m.metadata?.cmuxMetadata?.type === "compaction-request" - ); - - if (compactionRequestIndex === -1) { + // Extract command before modifying history + const command = getCompactionCommand(aggregator); + if (!command) { return false; } - const compactionRequestMsg = messages[compactionRequestIndex]; - // CRITICAL: Store cancellation marker in localStorage BEFORE interrupt // Use the compaction-request user message ID (stable across retries) // This persists across reloads and verifies we're cancelling the right compaction From 93755a6f9fa51181ba9b3199d394bdf902023ff8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 17 Oct 2025 21:17:36 -0500 Subject: [PATCH 37/37] =?UTF-8?q?=F0=9F=A4=96=20Fix=20ESLint=20error:=20ty?= =?UTF-8?q?pe=20JSON.parse=20result=20in=20cancellation=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ChatInput.tsx | 13 ++++++---- src/constants/storage.ts | 1 - src/hooks/useAIViewKeybinds.ts | 6 ++--- src/services/ipcMain.ts | 43 ++++++++++++++++++--------------- src/stores/WorkspaceStore.ts | 8 +++--- src/types/ipc.ts | 5 +++- src/utils/compaction/handler.ts | 20 ++++++++------- 7 files changed, 53 insertions(+), 43 deletions(-) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index f9369c88b..22e57141d 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -432,15 +432,18 @@ export const ChatInput: React.FC = ({ }, []); // Method to restore text to input (used by compaction cancel) - const restoreText = useCallback((text: string) => { - setInput(text); - focusMessageInput(); - }, [focusMessageInput]); + const restoreText = useCallback( + (text: string) => { + setInput(text); + focusMessageInput(); + }, + [focusMessageInput] + ); // Provide API to parent via callback useEffect(() => { if (onReady) { - onReady({ + onReady({ focus: focusMessageInput, restoreText, }); diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 5a01b1e95..537fede58 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -55,7 +55,6 @@ export function getCancelledCompactionKey(workspaceId: string): string { return `workspace:${workspaceId}:cancelled-compaction`; } - /** * Get the localStorage key for the UI mode for a workspace * Format: "mode:{workspaceId}" diff --git a/src/hooks/useAIViewKeybinds.ts b/src/hooks/useAIViewKeybinds.ts index e0fcf1c04..8dced943a 100644 --- a/src/hooks/useAIViewKeybinds.ts +++ b/src/hooks/useAIViewKeybinds.ts @@ -55,7 +55,7 @@ export function useAIViewKeybinds({ // (different from Ctrl+A which accepts early with [truncated]) if (matchesKeybind(e, KEYBINDS.INTERRUPT_STREAM)) { e.preventDefault(); - + if (canInterrupt && isCompactingStream(aggregator)) { // Ctrl+C during compaction: restore original state and enter edit mode // Stores cancellation marker in localStorage (persists across reloads) @@ -65,7 +65,7 @@ export function useAIViewKeybinds({ setAutoRetry(false); return; } - + // Normal stream interrupt (non-compaction) if (canInterrupt || showRetryBarrier) { setAutoRetry(false); // User explicitly stopped - don't auto-retry @@ -78,7 +78,7 @@ export function useAIViewKeybinds({ // (different from Ctrl+C which cancels and restores original state) if (matchesKeybind(e, KEYBINDS.ACCEPT_EARLY_COMPACTION)) { e.preventDefault(); - + if (canInterrupt && isCompactingStream(aggregator)) { // Ctrl+A during compaction: perform compaction with partial summary // No flag set - handleCompactionAbort will perform compaction with [truncated] diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 17792da8f..293fe42ef 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -588,29 +588,32 @@ export class IpcMain { } ); - ipcMain.handle(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, async (_event, workspaceId: string, options?: { abandonPartial?: boolean }) => { - log.debug("interruptStream handler: Received", { workspaceId, options }); - try { - const session = this.getOrCreateSession(workspaceId); - const stopResult = await session.interruptStream(); - if (!stopResult.success) { - log.error("Failed to stop stream:", stopResult.error); - return { success: false, error: stopResult.error }; - } + ipcMain.handle( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + async (_event, workspaceId: string, options?: { abandonPartial?: boolean }) => { + log.debug("interruptStream handler: Received", { workspaceId, options }); + try { + const session = this.getOrCreateSession(workspaceId); + const stopResult = await session.interruptStream(); + if (!stopResult.success) { + log.error("Failed to stop stream:", stopResult.error); + return { success: false, error: stopResult.error }; + } - // If abandonPartial is true, delete the partial instead of committing it - if (options?.abandonPartial) { - log.debug("Abandoning partial for workspace:", workspaceId); - await this.partialService.deletePartial(workspaceId); - } + // If abandonPartial is true, delete the partial instead of committing it + if (options?.abandonPartial) { + log.debug("Abandoning partial for workspace:", workspaceId); + await this.partialService.deletePartial(workspaceId); + } - return { success: true, data: undefined }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Unexpected error in interruptStream handler:", error); - return { success: false, error: `Failed to interrupt stream: ${errorMessage}` }; + return { success: true, data: undefined }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error("Unexpected error in interruptStream handler:", error); + return { success: false, error: `Failed to interrupt stream: ${errorMessage}` }; + } } - }); + ); ipcMain.handle( IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 00e898df4..be1f0218f 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -443,11 +443,11 @@ export class WorkspaceStore { /** * Handle interruption of a compaction stream (StreamAbortEvent). - * + * * Two distinct flows trigger this: * - **Ctrl+A (accept early)**: Perform compaction with [truncated] sentinel * - **Ctrl+C (cancel)**: Skip compaction, let cancelCompaction handle cleanup - * + * * Uses localStorage to distinguish flows: * - Checks for cancellation marker in localStorage * - Verifies messageId matches for freshness @@ -478,8 +478,8 @@ export class WorkspaceStore { const cancelData = localStorage.getItem(storageKey); if (cancelData) { try { - const { compactionRequestId } = JSON.parse(cancelData); - if (compactionRequestId === compactionRequestMsg.id) { + const parsed = JSON.parse(cancelData) as { compactionRequestId: string; timestamp: number }; + if (parsed.compactionRequestId === compactionRequestMsg.id) { // This is a cancelled compaction - clean up marker and skip compaction localStorage.removeItem(storageKey); return false; // Skip compaction, cancelCompaction() handles cleanup diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 4642b3a2b..38828f266 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -209,7 +209,10 @@ export interface IPCApi { workspaceId: string, options: SendMessageOptions ): Promise>; - interruptStream(workspaceId: string, options?: { abandonPartial?: boolean }): Promise>; + interruptStream( + workspaceId: string, + options?: { abandonPartial?: boolean } + ): Promise>; truncateHistory(workspaceId: string, percentage?: number): Promise>; replaceChatHistory( workspaceId: string, diff --git a/src/utils/compaction/handler.ts b/src/utils/compaction/handler.ts index 300daa4df..45f2fbfb8 100644 --- a/src/utils/compaction/handler.ts +++ b/src/utils/compaction/handler.ts @@ -4,7 +4,7 @@ * Two interrupt flows during compaction: * - Ctrl+C (cancel): Abort compaction, restore original history + command to input * - Ctrl+A (accept early): Complete compaction with [truncated] sentinel - * + * * Uses localStorage to persist cancellation intent across reloads: * - Before interrupt, store messageId in localStorage * - handleCompactionAbort checks localStorage and verifies messageId matches @@ -53,20 +53,20 @@ export function getCompactionCommand(aggregator: StreamingMessageAggregator): st /** * Cancel compaction (Ctrl+C flow) - * + * * Aborts the compaction stream and puts user in edit mode for compaction-request: * - Interrupts stream with abandonPartial flag (deletes partial, doesn't commit) * - Skips compaction (via localStorage marker checked by handleCompactionAbort) * - Enters edit mode on compaction-request message * - Restores original /compact command to input for re-editing * - Leaves compaction-request message in history (can edit or delete it) - * + * * Flow: * 1. Store cancellation marker in localStorage with compactionRequestId for verification * 2. Interrupt stream with {abandonPartial: true} - backend deletes partial * 3. handleCompactionAbort checks localStorage, verifies compactionRequestId, skips compaction * 4. Enter edit mode on compaction-request message with original command - * + * * Reload-safe: localStorage persists across reloads, compactionRequestId ensures freshness */ export async function cancelCompaction( @@ -90,10 +90,13 @@ export async function cancelCompaction( // Use the compaction-request user message ID (stable across retries) // This persists across reloads and verifies we're cancelling the right compaction const storageKey = getCancelledCompactionKey(workspaceId); - localStorage.setItem(storageKey, JSON.stringify({ - compactionRequestId: compactionRequestMsg.id, - timestamp: Date.now(), - })); + localStorage.setItem( + storageKey, + JSON.stringify({ + compactionRequestId: compactionRequestMsg.id, + timestamp: Date.now(), + }) + ); // Interrupt stream with abandonPartial flag // This tells backend to DELETE the partial instead of committing it @@ -106,4 +109,3 @@ export async function cancelCompaction( return true; } -