Skip to content

Commit 04231ce

Browse files
authored
🤖 feat: post-compaction context preservation system (#1035)
## Summary - Add post-compaction attachment system to preserve context after history compaction - Implement `extractEditedFiles` utility to track and combine file edit diffs - Add `PostCompactionSection` UI to show what will be re-injected - Support opening plans in external editors with auto-refresh on focus - New API endpoints for post-compaction state, bash execution, and editor integration ## Test plan - [ ] Verify post-compaction attachments inject correctly after compaction - [ ] Test file edit tracking across multiple edits to same file - [ ] Confirm PostCompactionSection displays plan and edited files - [ ] Test external editor opening and content refresh on window focus - [ ] Run existing test suite: `bun test src/common/utils/messages/extractEditedFiles.test.ts` --- _Generated with `mux`_
1 parent 7ddf958 commit 04231ce

37 files changed

+2302
-76
lines changed

src/browser/App.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { SettingsProvider, useSettings } from "./contexts/SettingsContext";
4242
import { SettingsModal } from "./components/Settings/SettingsModal";
4343
import { TutorialProvider } from "./contexts/TutorialContext";
4444
import { TooltipProvider } from "./components/ui/tooltip";
45+
import { ExperimentsProvider } from "./contexts/ExperimentsContext";
4546
import { getWorkspaceSidebarKey } from "./utils/workspace";
4647

4748
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
@@ -693,15 +694,17 @@ function AppInner() {
693694
function App() {
694695
return (
695696
<ThemeProvider>
696-
<TooltipProvider delayDuration={200}>
697-
<SettingsProvider>
698-
<TutorialProvider>
699-
<CommandRegistryProvider>
700-
<AppInner />
701-
</CommandRegistryProvider>
702-
</TutorialProvider>
703-
</SettingsProvider>
704-
</TooltipProvider>
697+
<ExperimentsProvider>
698+
<TooltipProvider delayDuration={200}>
699+
<SettingsProvider>
700+
<TutorialProvider>
701+
<CommandRegistryProvider>
702+
<AppInner />
703+
</CommandRegistryProvider>
704+
</TutorialProvider>
705+
</SettingsProvider>
706+
</TooltipProvider>
707+
</ExperimentsProvider>
705708
</ThemeProvider>
706709
);
707710
}

src/browser/components/RightSidebar/CostsTab.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { ConsumerBreakdown } from "./ConsumerBreakdown";
1111
import { HorizontalThresholdSlider } from "./ThresholdSlider";
1212
import { useAutoCompactionSettings } from "@/browser/hooks/useAutoCompactionSettings";
1313
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
14+
import { PostCompactionSection } from "./PostCompactionSection";
15+
import { usePostCompactionState } from "@/browser/hooks/usePostCompactionState";
16+
import { useExperimentValue } from "@/browser/contexts/ExperimentsContext";
17+
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
1418

1519
// Format token display - show k for thousands with 1 decimal
1620
const formatTokens = (tokens: number) =>
@@ -65,6 +69,10 @@ const CostsTabComponent: React.FC<CostsTabProps> = ({ workspaceId }) => {
6569
const { options } = useProviderOptions();
6670
const use1M = options.anthropic?.use1MContext ?? false;
6771

72+
// Post-compaction context state for UI display (gated by experiment)
73+
const postCompactionEnabled = useExperimentValue(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT);
74+
const postCompactionState = usePostCompactionState(workspaceId);
75+
6876
// Get model from context usage for per-model threshold storage
6977
// Use lastContextUsage for context window display (last step's usage)
7078
const contextUsageForModel = usage.liveUsage ?? usage.lastContextUsage;
@@ -265,6 +273,15 @@ const CostsTabComponent: React.FC<CostsTabProps> = ({ workspaceId }) => {
265273
);
266274
})()}
267275
</div>
276+
{postCompactionEnabled && (
277+
<PostCompactionSection
278+
workspaceId={workspaceId}
279+
planPath={postCompactionState.planPath}
280+
trackedFilePaths={postCompactionState.trackedFilePaths}
281+
excludedItems={postCompactionState.excludedItems}
282+
onToggleExclusion={postCompactionState.toggleExclusion}
283+
/>
284+
)}
268285
</div>
269286
)}
270287

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import React, { useMemo, useState } from "react";
2+
import { ChevronRight, FileText, ExternalLink, Check, Eye, EyeOff } from "lucide-react";
3+
import { usePersistedState } from "@/browser/hooks/usePersistedState";
4+
import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor";
5+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/ui/tooltip";
6+
7+
interface PostCompactionSectionProps {
8+
workspaceId: string;
9+
planPath: string | null;
10+
trackedFilePaths: string[];
11+
excludedItems: Set<string>;
12+
onToggleExclusion: (itemId: string) => Promise<void>;
13+
}
14+
15+
/** Extract just the filename from a full path */
16+
function getFileName(filePath: string): string {
17+
return filePath.split("/").pop() ?? filePath;
18+
}
19+
20+
/**
21+
* Displays what context will be injected after compaction.
22+
* Collapsible section in the right sidebar below the context usage bar.
23+
*/
24+
export const PostCompactionSection: React.FC<PostCompactionSectionProps> = (props) => {
25+
const openInEditor = useOpenInEditor();
26+
const [collapsed, setCollapsed] = usePersistedState("postCompaction:collapsed", true);
27+
const [filesExpanded, setFilesExpanded] = usePersistedState(
28+
"postCompaction:filesExpanded",
29+
false
30+
);
31+
const [copied, setCopied] = useState(false);
32+
33+
const handleCopyPath = async () => {
34+
if (!props.planPath) return;
35+
await navigator.clipboard.writeText(props.planPath);
36+
setCopied(true);
37+
setTimeout(() => setCopied(false), 1500);
38+
};
39+
40+
const handleOpenPlan = (e: React.MouseEvent) => {
41+
e.stopPropagation();
42+
if (!props.planPath) return;
43+
void openInEditor(props.workspaceId, props.planPath);
44+
};
45+
46+
// Derive values from props
47+
const planExists = props.planPath !== null;
48+
const trackedFilesCount = props.trackedFilePaths.length;
49+
const isPlanExcluded = props.excludedItems.has("plan");
50+
51+
// Format file names for display - show just filename, with parent dir if duplicates
52+
const formattedFiles = useMemo(() => {
53+
const nameCount = new Map<string, number>();
54+
props.trackedFilePaths.forEach((p) => {
55+
const name = getFileName(p);
56+
nameCount.set(name, (nameCount.get(name) ?? 0) + 1);
57+
});
58+
59+
return props.trackedFilePaths.map((fullPath) => {
60+
const name = getFileName(fullPath);
61+
const needsContext = (nameCount.get(name) ?? 0) > 1;
62+
const parts = fullPath.split("/");
63+
const displayName = needsContext && parts.length > 1 ? parts.slice(-2).join("/") : name;
64+
const itemId = `file:${fullPath}`;
65+
const isExcluded = props.excludedItems.has(itemId);
66+
return { fullPath, displayName, itemId, isExcluded };
67+
});
68+
}, [props.trackedFilePaths, props.excludedItems]);
69+
70+
// Count how many items are included (not excluded)
71+
const includedFilesCount = formattedFiles.filter((f) => !f.isExcluded).length;
72+
73+
// Don't render if nothing will be injected
74+
if (!planExists && trackedFilesCount === 0) {
75+
return null;
76+
}
77+
78+
return (
79+
<div className="border-border-light mt-4 border-t pt-4">
80+
<button
81+
onClick={() => setCollapsed((prev) => !prev)}
82+
className="flex w-full items-center justify-between text-left"
83+
type="button"
84+
>
85+
<span className="text-muted text-xs font-medium">Post-Compaction Context</span>
86+
<ChevronRight
87+
className={`text-muted h-3.5 w-3.5 transition-transform duration-200 ${
88+
collapsed ? "" : "rotate-90"
89+
}`}
90+
/>
91+
</button>
92+
93+
{!collapsed && (
94+
<div className="mt-2 flex flex-col gap-2">
95+
{planExists && props.planPath && (
96+
<div className={`flex items-center gap-1 ${isPlanExcluded ? "opacity-50" : ""}`}>
97+
<Tooltip>
98+
<TooltipTrigger asChild>
99+
<button
100+
onClick={() => void props.onToggleExclusion("plan")}
101+
className="text-subtle hover:text-foreground p-0.5 transition-colors"
102+
type="button"
103+
>
104+
{isPlanExcluded ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
105+
</button>
106+
</TooltipTrigger>
107+
<TooltipContent side="top" showArrow={false}>
108+
{isPlanExcluded ? "Include in context" : "Exclude from context"}
109+
</TooltipContent>
110+
</Tooltip>
111+
<Tooltip>
112+
<TooltipTrigger asChild>
113+
<button
114+
onClick={() => void handleCopyPath()}
115+
className={`text-subtle hover:text-foreground flex items-center gap-2 text-left text-xs transition-colors ${isPlanExcluded ? "line-through" : ""}`}
116+
type="button"
117+
>
118+
<FileText className="h-3.5 w-3.5" />
119+
<span>Plan file</span>
120+
{copied && <Check className="h-3 w-3 text-green-500" />}
121+
</button>
122+
</TooltipTrigger>
123+
<TooltipContent side="top" showArrow={false}>
124+
Click to copy path
125+
</TooltipContent>
126+
</Tooltip>
127+
<Tooltip>
128+
<TooltipTrigger asChild>
129+
<button
130+
onClick={handleOpenPlan}
131+
className="text-subtle hover:text-foreground p-0.5 transition-colors"
132+
type="button"
133+
>
134+
<ExternalLink className="h-3 w-3" />
135+
</button>
136+
</TooltipTrigger>
137+
<TooltipContent side="top" showArrow={false}>
138+
Open in editor
139+
</TooltipContent>
140+
</Tooltip>
141+
</div>
142+
)}
143+
144+
{trackedFilesCount > 0 && (
145+
<div className="flex flex-col">
146+
<div className="flex items-center gap-1">
147+
<Tooltip>
148+
<TooltipTrigger asChild>
149+
<button
150+
onClick={(e) => {
151+
e.stopPropagation();
152+
// Toggle all files: if any included, exclude all; otherwise include all
153+
const shouldExclude = includedFilesCount > 0;
154+
void (async () => {
155+
for (const file of formattedFiles) {
156+
if (shouldExclude !== file.isExcluded) {
157+
await props.onToggleExclusion(file.itemId);
158+
}
159+
}
160+
})();
161+
}}
162+
className="text-subtle hover:text-foreground p-0.5 transition-colors"
163+
type="button"
164+
>
165+
{includedFilesCount === 0 ? (
166+
<EyeOff className="h-3 w-3" />
167+
) : (
168+
<Eye className="h-3 w-3" />
169+
)}
170+
</button>
171+
</TooltipTrigger>
172+
<TooltipContent side="top" showArrow={false}>
173+
{includedFilesCount === 0 ? "Include all files" : "Exclude all files"}
174+
</TooltipContent>
175+
</Tooltip>
176+
<button
177+
onClick={() => setFilesExpanded((prev) => !prev)}
178+
className="text-subtle hover:text-foreground flex items-center gap-2 text-left text-xs transition-colors"
179+
type="button"
180+
>
181+
<ChevronRight
182+
className={`h-3 w-3 transition-transform duration-200 ${filesExpanded ? "rotate-90" : ""}`}
183+
/>
184+
<span>
185+
{includedFilesCount}/{trackedFilesCount} file diff
186+
{trackedFilesCount !== 1 ? "s" : ""}
187+
</span>
188+
</button>
189+
</div>
190+
191+
{filesExpanded && formattedFiles.length > 0 && (
192+
<div className="mt-1 ml-5 flex flex-col gap-0.5">
193+
{formattedFiles.map((file) => (
194+
<div
195+
key={file.fullPath}
196+
className={`flex items-center gap-1 ${file.isExcluded ? "opacity-50" : ""}`}
197+
>
198+
<Tooltip>
199+
<TooltipTrigger asChild>
200+
<button
201+
onClick={() => void props.onToggleExclusion(file.itemId)}
202+
className="text-subtle hover:text-foreground p-0.5 transition-colors"
203+
type="button"
204+
>
205+
{file.isExcluded ? (
206+
<EyeOff className="h-2.5 w-2.5" />
207+
) : (
208+
<Eye className="h-2.5 w-2.5" />
209+
)}
210+
</button>
211+
</TooltipTrigger>
212+
<TooltipContent side="top" showArrow={false}>
213+
{file.isExcluded ? "Include in context" : "Exclude from context"}
214+
</TooltipContent>
215+
</Tooltip>
216+
<span
217+
className={`text-muted text-[10px] ${file.isExcluded ? "line-through" : ""}`}
218+
>
219+
{file.displayName}
220+
</span>
221+
</div>
222+
))}
223+
</div>
224+
)}
225+
</div>
226+
)}
227+
228+
<p className="text-muted mt-1 text-[10px] italic">
229+
Keeps agent aligned with your plan and prior edits
230+
</p>
231+
</div>
232+
)}
233+
</div>
234+
);
235+
};

src/browser/components/Settings/SettingsModal.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import React from "react";
2-
import { Settings, Key, Cpu, X, Briefcase } from "lucide-react";
2+
import { Settings, Key, Cpu, X, Briefcase, FlaskConical } from "lucide-react";
33
import { useSettings } from "@/browser/contexts/SettingsContext";
44
import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog";
55
import { GeneralSection } from "./sections/GeneralSection";
66
import { ProvidersSection } from "./sections/ProvidersSection";
77
import { ModelsSection } from "./sections/ModelsSection";
88
import { Button } from "@/browser/components/ui/button";
99
import { ProjectSettingsSection } from "./sections/ProjectSettingsSection";
10+
import { ExperimentsSection } from "./sections/ExperimentsSection";
1011
import type { SettingsSection } from "./types";
1112

1213
const SECTIONS: SettingsSection[] = [
@@ -34,6 +35,12 @@ const SECTIONS: SettingsSection[] = [
3435
icon: <Cpu className="h-4 w-4" />,
3536
component: ModelsSection,
3637
},
38+
{
39+
id: "experiments",
40+
label: "Experiments",
41+
icon: <FlaskConical className="h-4 w-4" />,
42+
component: ExperimentsSection,
43+
},
3744
];
3845

3946
export function SettingsModal() {

0 commit comments

Comments
 (0)