Skip to content

Commit c3f9bd1

Browse files
authored
🤖 Live compaction (#309)
Fixes #244 ## Summary Replaced the `compact_summary` tool with direct text streaming for conversation compaction. The model now streams the summary directly instead of using a tool call to return it. ## Changes - **Removed** `compact_summary` tool definition and service implementation - **Updated** `StreamingMessageAggregator` to extract compaction summary from text parts - **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**: Users can now see the compaction summary as it streams in real-time 2. **Context efficiency**: Eliminates duplicate text (no tool call + assistant message) 3. **Simpler protocol**: Fewer moving parts, less code to maintain ## Testing - ✅ Unit tests pass (`src/utils/messages/compactionOptions.test.ts`) - ✅ Type checking passes - ✅ All existing tests pass (573 tests) _Generated with `cmux`_
1 parent b9c3fe6 commit c3f9bd1

28 files changed

+516
-138
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ CODE_CHANGES.md
9090
README_COMPACT_HERE.md
9191
artifacts/
9292
tests/e2e/tmp/
93+
94+
# Test temporary directories
95+
src/test-temp-*/
96+
tests/**/test-temp-*/
97+
9398
runs/
9499

95100
# Python

src/components/AIView.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
345345
chatInputAPI,
346346
jumpToBottom,
347347
handleOpenTerminal,
348+
aggregator,
349+
setEditingMessage,
348350
});
349351

350352
// Clear editing state if the message being edited no longer exists
@@ -523,7 +525,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
523525
? `${getModelName(currentModel)} streaming...`
524526
: "streaming..."
525527
}
526-
cancelText={`hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel`}
528+
cancelText={
529+
isCompacting
530+
? `${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early`
531+
: `hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel`
532+
}
527533
tokenCount={
528534
activeStreamMessageId
529535
? aggregator.getStreamingTokenCount(activeStreamMessageId)

src/components/ChatInput.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ const ModelDisplayWrapper = styled.div`
120120

121121
export interface ChatInputAPI {
122122
focus: () => void;
123+
restoreText: (text: string) => void;
123124
}
124125

125126
export interface ChatInputProps {
@@ -430,12 +431,24 @@ export const ChatInput: React.FC<ChatInputProps> = ({
430431
});
431432
}, []);
432433

434+
// Method to restore text to input (used by compaction cancel)
435+
const restoreText = useCallback(
436+
(text: string) => {
437+
setInput(text);
438+
focusMessageInput();
439+
},
440+
[focusMessageInput]
441+
);
442+
433443
// Provide API to parent via callback
434444
useEffect(() => {
435445
if (onReady) {
436-
onReady({ focus: focusMessageInput });
446+
onReady({
447+
focus: focusMessageInput,
448+
restoreText,
449+
});
437450
}
438-
}, [onReady, focusMessageInput]);
451+
}, [onReady, focusMessageInput, restoreText]);
439452

440453
useEffect(() => {
441454
const handleGlobalKeyDown = (event: KeyboardEvent) => {
@@ -948,7 +961,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
948961
return `Edit your message... (${formatKeybind(KEYBINDS.CANCEL_EDIT)} to cancel, ${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send)`;
949962
}
950963
if (isCompacting) {
951-
return `Compacting... (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)`;
964+
return `Compacting... (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early)`;
952965
}
953966

954967
// Build hints for normal input

src/components/Messages/AssistantMessage.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { MessageWindow } from "./MessageWindow";
88
import { useStartHere } from "@/hooks/useStartHere";
99
import { COMPACTED_EMOJI } from "@/constants/ui";
1010
import { ModelDisplay } from "./ModelDisplay";
11+
import { CompactingMessageContent } from "./CompactingMessageContent";
12+
import { CompactionBackground } from "./CompactionBackground";
1113

1214
const RawContent = styled.pre`
1315
font-family: var(--font-monospace);
@@ -49,13 +51,15 @@ interface AssistantMessageProps {
4951
message: DisplayedMessage & { type: "assistant" };
5052
className?: string;
5153
workspaceId?: string;
54+
isCompacting?: boolean;
5255
clipboardWriteText?: (data: string) => Promise<void>;
5356
}
5457

5558
export const AssistantMessage: React.FC<AssistantMessageProps> = ({
5659
message,
5760
className,
5861
workspaceId,
62+
isCompacting = false,
5963
clipboardWriteText = (data: string) => navigator.clipboard.writeText(data),
6064
}) => {
6165
const [showRaw, setShowRaw] = useState(false);
@@ -64,6 +68,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
6468
const content = message.content;
6569
const isStreaming = message.isStreaming;
6670
const isCompacted = message.isCompacted;
71+
const isStreamingCompaction = isStreaming && isCompacting;
6772

6873
// Use Start Here hook for final assistant messages
6974
const {
@@ -120,7 +125,14 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
120125

121126
// Streaming text gets typewriter effect
122127
if (isStreaming) {
123-
return <TypewriterMarkdown deltas={[content]} isComplete={false} />;
128+
const contentElement = <TypewriterMarkdown deltas={[content]} isComplete={false} />;
129+
130+
// Wrap streaming compaction in special container
131+
if (isStreamingCompaction) {
132+
return <CompactingMessageContent>{contentElement}</CompactingMessageContent>;
133+
}
134+
135+
return contentElement;
124136
}
125137

126138
// Completed text renders as static content
@@ -154,6 +166,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
154166
message={message}
155167
buttons={buttons}
156168
className={className}
169+
backgroundEffect={isStreamingCompaction ? <CompactionBackground /> : undefined}
157170
>
158171
{renderContent()}
159172
</MessageWindow>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from "react";
2+
import styled from "@emotion/styled";
3+
4+
/**
5+
* Wrapper for compaction streaming content
6+
* Provides max-height constraint with fade effect to imply content above
7+
* No scrolling - content stays anchored to bottom, older content fades at top
8+
*/
9+
10+
const Container = styled.div`
11+
max-height: 300px;
12+
overflow: hidden;
13+
position: relative;
14+
display: flex;
15+
flex-direction: column;
16+
justify-content: flex-end; /* Anchor content to bottom */
17+
18+
/* Fade effect: content fades progressively from top to bottom */
19+
mask-image: linear-gradient(
20+
to bottom,
21+
transparent 0%,
22+
rgba(0, 0, 0, 0.3) 5%,
23+
rgba(0, 0, 0, 0.6) 10%,
24+
rgba(0, 0, 0, 0.85) 15%,
25+
black 20%
26+
);
27+
-webkit-mask-image: linear-gradient(
28+
to bottom,
29+
transparent 0%,
30+
rgba(0, 0, 0, 0.3) 5%,
31+
rgba(0, 0, 0, 0.6) 10%,
32+
rgba(0, 0, 0, 0.85) 15%,
33+
black 20%
34+
);
35+
`;
36+
37+
interface CompactingMessageContentProps {
38+
children: React.ReactNode;
39+
}
40+
41+
export const CompactingMessageContent: React.FC<CompactingMessageContentProps> = ({ children }) => {
42+
return <Container>{children}</Container>;
43+
};
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React from "react";
2+
import styled from "@emotion/styled";
3+
import { keyframes } from "@emotion/react";
4+
5+
/**
6+
* Animated background for compaction streaming
7+
* Shimmer effect with moving gradient and particles for dynamic appearance
8+
*/
9+
10+
const shimmer = keyframes`
11+
0% {
12+
background-position: -1000px 0;
13+
}
14+
100% {
15+
background-position: 1000px 0;
16+
}
17+
`;
18+
19+
const gradientMove = keyframes`
20+
0% {
21+
background-position: 0% 50%;
22+
}
23+
50% {
24+
background-position: 100% 50%;
25+
}
26+
100% {
27+
background-position: 0% 50%;
28+
}
29+
`;
30+
31+
const Container = styled.div`
32+
position: absolute;
33+
top: 0;
34+
left: 0;
35+
right: 0;
36+
bottom: 0;
37+
overflow: hidden;
38+
pointer-events: none;
39+
border-radius: 6px;
40+
`;
41+
42+
const AnimatedGradient = styled.div`
43+
position: absolute;
44+
top: 0;
45+
left: 0;
46+
right: 0;
47+
bottom: 0;
48+
background: linear-gradient(
49+
-45deg,
50+
var(--color-plan-mode-alpha),
51+
color-mix(in srgb, var(--color-plan-mode) 30%, transparent),
52+
var(--color-plan-mode-alpha),
53+
color-mix(in srgb, var(--color-plan-mode) 25%, transparent)
54+
);
55+
background-size: 400% 400%;
56+
animation: ${gradientMove} 8s ease infinite;
57+
opacity: 0.4;
58+
`;
59+
60+
const ShimmerLayer = styled.div`
61+
position: absolute;
62+
top: 0;
63+
left: 0;
64+
right: 0;
65+
bottom: 0;
66+
background: linear-gradient(
67+
90deg,
68+
transparent 0%,
69+
transparent 40%,
70+
var(--color-plan-mode-alpha) 50%,
71+
transparent 60%,
72+
transparent 100%
73+
);
74+
background-size: 1000px 100%;
75+
animation: ${shimmer} 3s infinite linear;
76+
`;
77+
78+
export const CompactionBackground: React.FC = () => {
79+
return (
80+
<Container>
81+
<AnimatedGradient />
82+
<ShimmerLayer />
83+
</Container>
84+
);
85+
};

src/components/Messages/MessageRenderer.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
3131
);
3232
case "assistant":
3333
return (
34-
<AssistantMessage message={message} className={className} workspaceId={workspaceId} />
34+
<AssistantMessage
35+
message={message}
36+
className={className}
37+
workspaceId={workspaceId}
38+
isCompacting={isCompacting}
39+
/>
3540
);
3641
case "tool":
3742
return <ToolMessage message={message} className={className} workspaceId={workspaceId} />;

src/components/Messages/MessageWindow.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { formatTimestamp } from "@/utils/ui/dateTime";
77
import { TooltipWrapper, Tooltip } from "../Tooltip";
88

99
const MessageBlock = styled.div<{ borderColor: string; backgroundColor?: string }>`
10+
position: relative;
1011
margin-bottom: 15px;
1112
margin-top: 15px;
1213
background: ${(props) => props.backgroundColor ?? "#1e1e1e"};
@@ -16,6 +17,8 @@ const MessageBlock = styled.div<{ borderColor: string; backgroundColor?: string
1617
`;
1718

1819
const MessageHeader = styled.div`
20+
position: relative;
21+
z-index: 1;
1922
padding: 8px 12px;
2023
background: rgba(255, 255, 255, 0.05);
2124
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
@@ -51,6 +54,8 @@ const ButtonGroup = styled.div`
5154
`;
5255

5356
const MessageContent = styled.div`
57+
position: relative;
58+
z-index: 1;
5459
padding: 12px;
5560
`;
5661

@@ -85,6 +90,7 @@ interface MessageWindowProps {
8590
children: ReactNode;
8691
className?: string;
8792
rightLabel?: ReactNode;
93+
backgroundEffect?: ReactNode; // Optional background effect (e.g., animation)
8894
}
8995

9096
export const MessageWindow: React.FC<MessageWindowProps> = ({
@@ -96,6 +102,7 @@ export const MessageWindow: React.FC<MessageWindowProps> = ({
96102
children,
97103
className,
98104
rightLabel,
105+
backgroundEffect,
99106
}) => {
100107
const [showJson, setShowJson] = useState(false);
101108

@@ -111,6 +118,7 @@ export const MessageWindow: React.FC<MessageWindowProps> = ({
111118

112119
return (
113120
<MessageBlock borderColor={borderColor} backgroundColor={backgroundColor} className={className}>
121+
{backgroundEffect}
114122
<MessageHeader>
115123
<LeftSection>
116124
<MessageTypeLabel>{label}</MessageTypeLabel>

src/components/Messages/UserMessage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
102102
onClick: handleEdit,
103103
disabled: isCompacting,
104104
tooltip: isCompacting
105-
? `Cannot edit while compacting (press ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)`
105+
? `Cannot edit while compacting (${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel)`
106106
: undefined,
107107
},
108108
]

src/constants/storage.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ export function getLastThinkingByModelKey(modelName: string): string {
4747
return `lastThinkingByModel:${modelName}`;
4848
}
4949

50+
/**
51+
* Get storage key for cancelled compaction tracking.
52+
* Stores compaction-request user message ID to verify freshness across reloads.
53+
*/
54+
export function getCancelledCompactionKey(workspaceId: string): string {
55+
return `workspace:${workspaceId}:cancelled-compaction`;
56+
}
57+
5058
/**
5159
* Get the localStorage key for the UI mode for a workspace
5260
* Format: "mode:{workspaceId}"

0 commit comments

Comments
 (0)