authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-23 18:06:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 18:06:04-07:00
log51672b6ecb9d927d57d0b12dd071ca0d4a34ef36
tree300699e9fdaa714dd1d70120d316f51b708f826c
parent67a7b1a1d3443beeedc6a3bd7fe9dd875f016b11
signature Signed by PGP key B5690EEEBB952194

feat(web): collapse large git diffs by default to make chat more readable (#4409)


9 files changed, 455 insertions(+), 188 deletions(-)

apps/web/src/components/chat/ChangedFilesTree.test.tsx+67-4
......@@ -10,23 +10,86 @@ describe("ChangedFilesCard", () => {
1010 <ChangedFilesCard
1111 turnId={TurnId.make("turn-1")}
1212 files={[{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }]}
13 expanded
14 showCompactPreview={false}
1315 allDirectoriesExpanded
1416 resolvedTheme="light"
17 onExpandedChange={() => {}}
1518 onToggleAllDirectories={() => {}}
1619 onOpenTurnDiff={() => {}}
1720 />,
1821 );
1922
20 expect(markup).toContain('class="sticky top-0 z-10');
21 expect(markup).not.toContain("self-start");
23 expect(markup).toContain('data-changed-files-state="expanded"');
24 expect(markup).toContain('aria-expanded="true"');
2225 expect(markup).toContain("whitespace-nowrap");
2326 expect(markup).toContain("!size-[22px]");
2427 expect(markup).toContain("size-3");
25 expect(markup).toContain('aria-label="Collapse all"');
26 expect(markup).toContain('aria-label="View diff"');
28 expect(markup).toContain('aria-label="Collapse all folders"');
29 expect(markup).toContain('aria-label="Open diff"');
30 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');
2731 expect(markup).toContain("1 changed file");
2832 expect(markup).not.toContain("1 changed files");
2933 });
34
35 it("renders a scope and representative-file preview for a large latest change", () => {
36 const markup = renderToStaticMarkup(
37 <ChangedFilesCard
38 turnId={TurnId.make("turn-1")}
39 files={[
40 { path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 },
41 { path: "apps/web/src/App.test.tsx", kind: "modified", additions: 30, deletions: 2 },
42 {
43 path: "packages/shared/src/git.ts",
44 kind: "modified",
45 additions: 15,
46 deletions: 4,
47 },
48 { path: "README.md", kind: "modified", additions: 3, deletions: 0 },
49 ]}
50 expanded={false}
51 showCompactPreview
52 allDirectoriesExpanded={false}
53 resolvedTheme="light"
54 onExpandedChange={() => {}}
55 onToggleAllDirectories={() => {}}
56 onOpenTurnDiff={() => {}}
57 />,
58 );
59
60 expect(markup).toContain('data-changed-files-state="preview"');
61 expect(markup).toContain('aria-expanded="false"');
62 expect(markup).toContain("apps");
63 expect(markup).toContain("2 files");
64 expect(markup).toContain("packages");
65 expect(markup).toContain("root");
66 expect(markup).toContain("App.tsx");
67 expect(markup).toContain("git.ts");
68 expect(markup).toContain("README.md");
69 expect(markup).toContain("Show all 4 files");
70 expect(markup).not.toContain("App.test.tsx");
71 });
72
73 it("keeps older collapsed changes to a one-line receipt", () => {
74 const markup = renderToStaticMarkup(
75 <ChangedFilesCard
76 turnId={TurnId.make("turn-1")}
77 files={[{ path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 }]}
78 expanded={false}
79 showCompactPreview={false}
80 allDirectoriesExpanded={false}
81 resolvedTheme="light"
82 onExpandedChange={() => {}}
83 onToggleAllDirectories={() => {}}
84 onOpenTurnDiff={() => {}}
85 />,
86 );
87
88 expect(markup).toContain('data-changed-files-state="collapsed"');
89 expect(markup).toContain("1 changed file");
90 expect(markup).not.toContain("Show all");
91 expect(markup).not.toContain("App.tsx");
92 });
3093});
3194
3295describe("ChangedFilesTree", () => {
apps/web/src/components/chat/ChangedFilesTree.tsx+139-50
......@@ -19,95 +19,184 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
1919import { PierreEntryIcon } from "./PierreEntryIcon";
2020import { Button } from "../ui/button";
2121import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
22import {
23 changedFileName,
24 selectChangedFilePreview,
25 summarizeChangedFileScopes,
26} from "./changedFilesPresentation";
2227
2328const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2429
2530export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
2631 turnId: TurnId;
2732 files: ReadonlyArray<TurnDiffFileChange>;
33 expanded: boolean;
34 showCompactPreview: boolean;
2835 allDirectoriesExpanded: boolean;
2936 resolvedTheme: "light" | "dark";
37 onExpandedChange: (expanded: boolean) => void;
3038 onToggleAllDirectories: () => void;
3139 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
3240}) {
3341 const {
3442 turnId,
3543 files,
44 expanded,
45 showCompactPreview,
3646 allDirectoriesExpanded,
3747 resolvedTheme,
48 onExpandedChange,
3849 onToggleAllDirectories,
3950 onOpenTurnDiff,
4051 } = props;
4152 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
53 const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]);
54 const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]);
55 const compactPreviewVisible = showCompactPreview && !expanded;
4256
4357 return (
44 <div className="mt-4 rounded-2xl border border-border/70 bg-secondary p-2 pt-4 dark:border-transparent dark:bg-input/32">
45 <div className="sticky top-0 z-10 mb-3 flex items-center justify-between gap-2 bg-secondary px-2 before:absolute before:inset-x-0 before:-top-4 before:h-4 before:bg-secondary before:content-[''] dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))] dark:before:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]">
46 <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">
47 <span>
48 {files.length} changed file{files.length === 1 ? "" : "s"}
58 <div
59 className="mt-4 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32"
60 data-changed-files-state={
61 expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed"
62 }
63 >
64 <div
65 className={cn(
66 "flex items-center justify-between gap-2 rounded-xl px-1",
67 expanded &&
68 "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",
69 )}
70 >
71 <button
72 type="button"
73 aria-expanded={expanded}
74 data-scroll-anchor-ignore
75 className="group flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1 py-1.5 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
76 onClick={() => onExpandedChange(!expanded)}
77 >
78 <ChevronRightIcon
79 aria-hidden="true"
80 className={cn(
81 "size-3.5 shrink-0 text-muted-foreground transition-transform",
82 expanded && "rotate-90",
83 )}
84 />
85 <span className="flex min-w-0 items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">
86 <span>
87 {files.length} changed file{files.length === 1 ? "" : "s"}
88 </span>
89 {hasNonZeroStat(summaryStat) && (
90 <DiffStatLabel
91 additions={summaryStat.additions}
92 className="text-xs leading-4"
93 deletions={summaryStat.deletions}
94 layout="inline"
95 />
96 )}
4997 </span>
50 {hasNonZeroStat(summaryStat) && (
51 <DiffStatLabel
52 additions={summaryStat.additions}
53 className="text-xs leading-4"
54 deletions={summaryStat.deletions}
55 layout="inline"
56 />
57 )}
58 </p>
98 <span className="ml-1 hidden truncate text-[11px] text-muted-foreground group-hover:text-foreground/80 sm:inline">
99 {expanded ? "Hide files" : "Show files"}
100 </span>
101 </button>
59102 <div className="flex items-center gap-1.5">
103 {expanded ? (
104 <Tooltip>
105 <TooltipTrigger
106 render={
107 <Button
108 type="button"
109 size="icon-xs"
110 variant="outline"
111 className="!size-[22px]"
112 aria-label={
113 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
114 }
115 data-scroll-anchor-ignore
116 onClick={onToggleAllDirectories}
117 />
118 }
119 >
120 {allDirectoriesExpanded ? (
121 <ChevronsDownUpIcon className="size-3" />
122 ) : (
123 <ChevronsUpDownIcon className="size-3" />
124 )}
125 </TooltipTrigger>
126 <TooltipPopup side="top">
127 {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
128 </TooltipPopup>
129 </Tooltip>
130 ) : null}
60131 <Tooltip>
61132 <TooltipTrigger
62133 render={
63134 <Button
64135 type="button"
65 size="icon-xs"
66 variant="outline"
67 className="!size-[22px]"
68 aria-label={allDirectoriesExpanded ? "Collapse all" : "Expand all"}
69 data-scroll-anchor-ignore
70 onClick={onToggleAllDirectories}
71 />
72 }
73 >
74 {allDirectoriesExpanded ? (
75 <ChevronsDownUpIcon className="size-3" />
76 ) : (
77 <ChevronsUpDownIcon className="size-3" />
78 )}
79 </TooltipTrigger>
80 <TooltipPopup side="top">
81 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
82 </TooltipPopup>
83 </Tooltip>
84 <Tooltip>
85 <TooltipTrigger
86 render={
87 <Button
88 type="button"
89 size="icon-xs"
136 size="xs"
90137 variant="outline"
91 className="!size-[22px]"
92 aria-label="View diff"
138 aria-label="Open diff"
93139 onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}
94140 />
95141 }
96142 >
97143 <FileDiffIcon className="size-3" />
144 <span className="hidden sm:inline">Open diff</span>
98145 </TooltipTrigger>
99 <TooltipPopup side="top">View diff</TooltipPopup>
146 <TooltipPopup side="top">Open the full diff</TooltipPopup>
100147 </Tooltip>
101148 </div>
102149 </div>
103 <ChangedFilesTree
104 key={`changed-files-tree:${turnId}`}
105 turnId={turnId}
106 files={files}
107 allDirectoriesExpanded={allDirectoriesExpanded}
108 resolvedTheme={resolvedTheme}
109 onOpenTurnDiff={onOpenTurnDiff}
110 />
150 {expanded ? (
151 <ChangedFilesTree
152 key={`changed-files-tree:${turnId}`}
153 turnId={turnId}
154 files={files}
155 allDirectoriesExpanded={allDirectoriesExpanded}
156 resolvedTheme={resolvedTheme}
157 onOpenTurnDiff={onOpenTurnDiff}
158 />
159 ) : compactPreviewVisible ? (
160 <div className="px-2 pb-1.5 pt-1">
161 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground">
162 {scopeSummary.map((scope, index) => (
163 <span key={scope.label} className="inline-flex items-center gap-1">
164 {index > 0 ? <span aria-hidden="true">·</span> : null}
165 <span className="font-mono text-foreground/75">{scope.label}</span>
166 <span>
167 {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"}
168 </span>
169 </span>
170 ))}
171 </p>
172 <div className="mt-2 flex flex-wrap items-center gap-1.5">
173 {previewFiles.map((file) => (
174 <button
175 key={file.path}
176 type="button"
177 title={file.path}
178 className="inline-flex max-w-48 items-center gap-1 rounded-md border border-border/70 bg-background/45 px-1.5 py-1 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
179 onClick={() => onOpenTurnDiff(turnId, file.path)}
180 >
181 <PierreEntryIcon
182 pathValue={file.path}
183 kind="file"
184 theme={resolvedTheme}
185 className="size-3 shrink-0 text-muted-foreground/70"
186 />
187 <span className="truncate">{changedFileName(file.path)}</span>
188 </button>
189 ))}
190 <button
191 type="button"
192 className="rounded-md px-1.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
193 onClick={() => onExpandedChange(true)}
194 >
195 Show all {files.length} files
196 </button>
197 </div>
198 </div>
199 ) : null}
111200 </div>
112201 );
113202});
apps/web/src/components/chat/DiffStatLabel.tsx+8-2
......@@ -31,6 +31,8 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: {
3131 <>
3232 {showParentheses && <span className="text-muted-foreground/70">(</span>}
3333 <span
34 role="group"
35 aria-label={`${additions} additions, ${deletions} deletions`}
3436 className={cn(
3537 layout === "inline"
3638 ? "inline-flex items-center gap-1 tabular-nums align-middle"
......@@ -38,8 +40,12 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: {
3840 className,
3941 )}
4042 >
41 <span className="font-mono text-success">+{formatCompactDiffCount(additions)}</span>
42 <span className="font-mono text-destructive">-{formatCompactDiffCount(deletions)}</span>
43 <span aria-hidden="true" className="font-mono text-success">
44 +{formatCompactDiffCount(additions)}
45 </span>
46 <span aria-hidden="true" className="font-mono text-destructive">
47 -{formatCompactDiffCount(deletions)}
48 </span>
4349 </span>
4450 {showParentheses && <span className="text-muted-foreground/70">)</span>}
4551 </>
apps/web/src/components/chat/MessagesTimeline.test.tsx+9-3
......@@ -245,6 +245,12 @@ describe("MessagesTimeline", () => {
245245 const markup = renderToStaticMarkup(
246246 <MessagesTimeline
247247 {...buildProps()}
248 latestTurn={{
249 turnId,
250 state: "completed",
251 startedAt: MESSAGE_CREATED_AT,
252 completedAt: MESSAGE_CREATED_AT,
253 }}
248254 timelineEntries={[
249255 {
250256 id: "entry-assistant-with-files",
......@@ -280,13 +286,13 @@ describe("MessagesTimeline", () => {
280286 />,
281287 );
282288
283 expect(markup).toContain('class="sticky top-2 z-10');
289 expect(markup).toContain("sticky top-2 z-10");
284290 expect(markup).not.toContain("self-start");
285291 expect(markup).toContain("whitespace-nowrap");
286292 expect(markup).toContain("!size-[22px]");
287293 expect(markup).toContain("size-3");
288 expect(markup).toContain('aria-label="Collapse all"');
289 expect(markup).toContain('aria-label="View diff"');
294 expect(markup).toContain('aria-label="Collapse all folders"');
295 expect(markup).toContain('aria-label="Open diff"');
290296 expect(markup).toContain("1 changed file");
291297 });
292298
apps/web/src/components/chat/MessagesTimeline.tsx+27-80
......@@ -32,7 +32,6 @@ import {
3232 workLogEntryIsToolLike,
3333} from "../../session-logic";
3434import { type TurnDiffSummary } from "../../types";
35import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
3635import {
3736 getRenderablePatch,
3837 resolveDiffThemeName,
......@@ -44,11 +43,8 @@ import {
4443 CheckIcon,
4544 ChevronDownIcon,
4645 ChevronRightIcon,
47 ChevronsDownUpIcon,
48 ChevronsUpDownIcon,
4946 CircleAlertIcon,
5047 EyeIcon,
51 FileDiffIcon,
5248 GlobeIcon,
5349 HammerIcon,
5450 MessageCircleIcon,
......@@ -65,8 +61,8 @@ import {
6561import { Button } from "../ui/button";
6662import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
6763import { ProposedPlanCard } from "./ProposedPlanCard";
68import { ChangedFilesTree } from "./ChangedFilesTree";
69import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
64import { ChangedFilesCard } from "./ChangedFilesTree";
65import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
7066import { MessageCopyButton } from "./MessageCopyButton";
7167import {
7268 computeStableMessagesTimelineRows,
......@@ -145,6 +141,7 @@ interface TimelineRowActivityState {
145141 isWorking: boolean;
146142 isRevertingCheckpoint: boolean;
147143 activeTurnInProgress: boolean;
144 latestTurnId: TurnId | null;
148145}
149146
150147const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
......@@ -454,8 +451,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
454451 isWorking,
455452 isRevertingCheckpoint,
456453 activeTurnInProgress,
454 latestTurnId: latestTurn?.turnId ?? null,
457455 }),
458 [activeTurnInProgress, isRevertingCheckpoint, isWorking],
456 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId],
459457 );
460458
461459 // Stable renderItem — no closure deps. Row components read shared state
......@@ -1278,83 +1276,32 @@ function AssistantChangedFilesSectionInner({
12781276 resolvedTheme: "light" | "dark";
12791277 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
12801278}) {
1281 const allDirectoriesExpanded = useUiStateStore(
1282 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true,
1279 const activity = use(TimelineRowActivityCtx);
1280 const isLatestTurn = activity.latestTurnId === turnSummary.turnId;
1281 const persistedExpanded = useUiStateStore(
1282 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId],
12831283 );
12841284 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
1285 const summaryStat = summarizeTurnDiffStats(checkpointFiles);
1285 const [autoExpanded] = useState(() =>
1286 shouldAutoExpandChangedFiles(checkpointFiles, isLatestTurn),
1287 );
1288 const [allDirectoriesExpanded, setAllDirectoriesExpanded] = useState(autoExpanded);
1289 const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded);
12861290
12871291 return (
1288 <div className="mt-4 rounded-2xl border border-border/70 bg-secondary p-2 pt-4 dark:border-transparent dark:bg-input/32">
1289 <div className="sticky top-2 z-10 mb-3 flex items-center justify-between gap-2 bg-secondary px-2 before:absolute before:inset-x-0 before:-top-4 before:h-4 before:bg-secondary before:content-[''] dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))] dark:before:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]">
1290 <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">
1291 <span>
1292 {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"}
1293 </span>
1294 {hasNonZeroStat(summaryStat) && (
1295 <DiffStatLabel
1296 additions={summaryStat.additions}
1297 className="text-xs leading-4"
1298 deletions={summaryStat.deletions}
1299 layout="inline"
1300 />
1301 )}
1302 </p>
1303 <div className="flex items-center gap-1.5">
1304 <Tooltip>
1305 <TooltipTrigger
1306 render={
1307 <Button
1308 type="button"
1309 size="icon-xs"
1310 variant="outline"
1311 className="!size-[22px]"
1312 aria-label={allDirectoriesExpanded ? "Collapse all" : "Expand all"}
1313 data-scroll-anchor-ignore
1314 onClick={() =>
1315 setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
1316 }
1317 />
1318 }
1319 >
1320 {allDirectoriesExpanded ? (
1321 <ChevronsDownUpIcon className="size-3" />
1322 ) : (
1323 <ChevronsUpDownIcon className="size-3" />
1324 )}
1325 </TooltipTrigger>
1326 <TooltipPopup side="top">
1327 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
1328 </TooltipPopup>
1329 </Tooltip>
1330 <Tooltip>
1331 <TooltipTrigger
1332 render={
1333 <Button
1334 type="button"
1335 size="icon-xs"
1336 variant="outline"
1337 className="!size-[22px]"
1338 aria-label="View diff"
1339 onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}
1340 />
1341 }
1342 >
1343 <FileDiffIcon className="size-3" />
1344 </TooltipTrigger>
1345 <TooltipPopup side="top">View diff</TooltipPopup>
1346 </Tooltip>
1347 </div>
1348 </div>
1349 <ChangedFilesTree
1350 key={`changed-files-tree:${turnSummary.turnId}`}
1351 turnId={turnSummary.turnId}
1352 files={checkpointFiles}
1353 allDirectoriesExpanded={allDirectoriesExpanded}
1354 resolvedTheme={resolvedTheme}
1355 onOpenTurnDiff={onOpenTurnDiff}
1356 />
1357 </div>
1292 <ChangedFilesCard
1293 turnId={turnSummary.turnId}
1294 files={checkpointFiles}
1295 expanded={expanded}
1296 showCompactPreview={isLatestTurn}
1297 allDirectoriesExpanded={allDirectoriesExpanded}
1298 resolvedTheme={resolvedTheme}
1299 onExpandedChange={(nextExpanded) =>
1300 setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded)
1301 }
1302 onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)}
1303 onOpenTurnDiff={onOpenTurnDiff}
1304 />
13581305 );
13591306}
13601307
apps/web/src/components/chat/changedFilesPresentation.test.ts created+69
......@@ -0,0 +1,69 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 changedFileName,
5 selectChangedFilePreview,
6 shouldAutoExpandChangedFiles,
7 summarizeChangedFileScopes,
8} from "./changedFilesPresentation";
9
10describe("changed-files presentation", () => {
11 it("auto-expands only small, low-churn latest changes", () => {
12 const smallFiles = [
13 { path: "src/a.ts", kind: "modified", additions: 80, deletions: 20 },
14 { path: "src/b.ts", kind: "modified", additions: 60, deletions: 20 },
15 ];
16
17 expect(shouldAutoExpandChangedFiles(smallFiles, true)).toBe(true);
18 expect(shouldAutoExpandChangedFiles(smallFiles, false)).toBe(false);
19 expect(
20 shouldAutoExpandChangedFiles(
21 [{ path: "src/a.ts", kind: "modified", additions: 201, deletions: 0 }],
22 true,
23 ),
24 ).toBe(false);
25 expect(
26 shouldAutoExpandChangedFiles(
27 Array.from({ length: 6 }, (_, index) => ({
28 path: `src/${index}.ts`,
29 kind: "modified",
30 additions: 1,
31 deletions: 0,
32 })),
33 true,
34 ),
35 ).toBe(false);
36 });
37
38 it("summarizes the most prominent top-level scopes", () => {
39 const files = [
40 { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 },
41 { path: "README.md", kind: "modified", additions: 1, deletions: 0 },
42 { path: "apps/server/src/index.ts", kind: "modified", additions: 1, deletions: 0 },
43 { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 },
44 { path: "apps\\mobile\\App.tsx", kind: "modified", additions: 1, deletions: 0 },
45 ];
46
47 expect(summarizeChangedFileScopes(files)).toEqual([
48 { label: "apps", fileCount: 3 },
49 { label: "root", fileCount: 1 },
50 { label: "packages", fileCount: 1 },
51 ]);
52 });
53
54 it("previews files across different scopes before filling from one scope", () => {
55 const files = [
56 { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 },
57 { path: "apps/web/src/App.test.tsx", kind: "modified", additions: 1, deletions: 0 },
58 { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 },
59 { path: "README.md", kind: "modified", additions: 1, deletions: 0 },
60 ];
61
62 expect(selectChangedFilePreview(files).map((file) => file.path)).toEqual([
63 "apps/web/src/App.tsx",
64 "packages/shared/src/git.ts",
65 "README.md",
66 ]);
67 expect(changedFileName("apps\\web\\src\\App.tsx")).toBe("App.tsx");
68 });
69});
apps/web/src/components/chat/changedFilesPresentation.ts created+102
......@@ -0,0 +1,102 @@
1import { type TurnDiffFileChange } from "../../types";
2import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
3
4export const CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT = 5;
5export const CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT = 200;
6export const CHANGED_FILES_PREVIEW_FILE_LIMIT = 3;
7export const CHANGED_FILES_PREVIEW_SCOPE_LIMIT = 4;
8
9export interface ChangedFilesScopeSummary {
10 readonly label: string;
11 readonly fileCount: number;
12}
13
14function pathSegments(pathValue: string): string[] {
15 return pathValue
16 .replaceAll("\\", "/")
17 .split("/")
18 .filter((segment) => segment.length > 0);
19}
20
21export function changedFileName(pathValue: string): string {
22 return pathSegments(pathValue).at(-1) ?? pathValue;
23}
24
25function changedFileScope(pathValue: string): string {
26 const segments = pathSegments(pathValue);
27 return segments.length > 1 ? (segments[0] ?? "root") : "root";
28}
29
30export function shouldAutoExpandChangedFiles(
31 files: ReadonlyArray<TurnDiffFileChange>,
32 isLatestTurn: boolean,
33): boolean {
34 if (!isLatestTurn || files.length > CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT) {
35 return false;
36 }
37 const stat = summarizeTurnDiffStats(files);
38 return stat.additions + stat.deletions <= CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT;
39}
40
41export function summarizeChangedFileScopes(
42 files: ReadonlyArray<TurnDiffFileChange>,
43 limit = CHANGED_FILES_PREVIEW_SCOPE_LIMIT,
44): ChangedFilesScopeSummary[] {
45 const scopes = new Map<string, { fileCount: number; firstIndex: number }>();
46 files.forEach((file, index) => {
47 const label = changedFileScope(file.path);
48 const current = scopes.get(label);
49 scopes.set(label, {
50 fileCount: (current?.fileCount ?? 0) + 1,
51 firstIndex: current?.firstIndex ?? index,
52 });
53 });
54
55 return Array.from(scopes, ([label, scope]) => ({
56 label,
57 fileCount: scope.fileCount,
58 firstIndex: scope.firstIndex,
59 }))
60 .toSorted(
61 (left, right) =>
62 right.fileCount - left.fileCount ||
63 left.firstIndex - right.firstIndex ||
64 left.label.localeCompare(right.label),
65 )
66 .slice(0, limit)
67 .map(({ label, fileCount }) => ({ label, fileCount }));
68}
69
70export function selectChangedFilePreview(
71 files: ReadonlyArray<TurnDiffFileChange>,
72 limit = CHANGED_FILES_PREVIEW_FILE_LIMIT,
73): TurnDiffFileChange[] {
74 const selected: TurnDiffFileChange[] = [];
75 const selectedPaths = new Set<string>();
76 const selectedScopes = new Set<string>();
77
78 for (const file of files) {
79 const scope = changedFileScope(file.path);
80 if (selectedScopes.has(scope)) {
81 continue;
82 }
83 selected.push(file);
84 selectedPaths.add(file.path);
85 selectedScopes.add(scope);
86 if (selected.length === limit) {
87 return selected;
88 }
89 }
90
91 for (const file of files) {
92 if (selectedPaths.has(file.path)) {
93 continue;
94 }
95 selected.push(file);
96 if (selected.length === limit) {
97 break;
98 }
99 }
100
101 return selected;
102}
apps/web/src/uiStateStore.test.ts+22-7
......@@ -116,7 +116,7 @@ describe("uiStateStore pure functions", () => {
116116 );
117117 });
118118
119 it("stores only collapsed changed-file turns", () => {
119 it("stores explicit changed-file expansion choices", () => {
120120 const threadId = ThreadId.make("thread-1");
121121 const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false);
122122
......@@ -128,7 +128,11 @@ describe("uiStateStore pure functions", () => {
128128 expect(
129129 setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true)
130130 .threadChangedFilesExpandedById,
131 ).toEqual({});
131 ).toEqual({
132 [threadId]: {
133 "turn-1": true,
134 },
135 });
132136 });
133137
134138 it("stores the endpoint preference by stable key", () => {
......@@ -155,6 +159,7 @@ describe("parsePersistedState", () => {
155159 invalid: "not-a-date",
156160 },
157161 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
162 threadChangedFilesExpansionVersion: 1,
158163 threadChangedFilesExpandedById: {
159164 "environment:thread-1": {
160165 "turn-1": false,
......@@ -172,12 +177,25 @@ describe("parsePersistedState", () => {
172177 "environment:thread-1": "2026-02-25T12:35:00.000Z",
173178 },
174179 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
180 threadChangedFilesExpandedById: {
181 "environment:thread-1": {
182 "turn-1": false,
183 "turn-2": true,
184 },
185 },
186 });
187 });
188
189 it("ignores changed-file expansion values saved with legacy folder semantics", () => {
190 const parsed = parsePersistedState({
175191 threadChangedFilesExpandedById: {
176192 "environment:thread-1": {
177193 "turn-1": false,
178194 },
179195 },
180196 });
197
198 expect(parsed.threadChangedFilesExpandedById).toEqual({});
181199 });
182200
183201 it("migrates legacy CWD project preferences into local alias keys", () => {
......@@ -278,19 +296,16 @@ describe("uiStateStore persistence", () => {
278296 "environment:thread-1": "2026-02-25T12:35:00.000Z",
279297 },
280298 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
299 threadChangedFilesExpansionVersion: 1,
281300 threadChangedFilesExpandedById: {
282301 "environment:thread-1": {
283302 "turn-1": false,
303 "turn-2": true,
284304 },
285305 },
286306 });
287307 expect(parsePersistedState(persisted)).toEqual({
288308 ...state,
289 threadChangedFilesExpandedById: {
290 "environment:thread-1": {
291 "turn-1": false,
292 },
293 },
294309 });
295310 });
296311
apps/web/src/uiStateStore.ts+12-42
......@@ -3,6 +3,7 @@ import { create } from "zustand";
33import { normalizeProjectPathForComparison } from "./lib/projectPaths";
44
55export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
6const THREAD_CHANGED_FILES_EXPANSION_VERSION = 1;
67const LEGACY_PERSISTED_STATE_KEYS = [
78 "t3code:renderer-state:v8",
89 "t3code:renderer-state:v7",
......@@ -24,6 +25,7 @@ export interface PersistedUiState {
2425 expandedProjectCwds?: string[];
2526 projectOrderCwds?: string[];
2627 defaultAdvertisedEndpointKey?: string | null;
28 threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION;
2729 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
2830}
2931
......@@ -124,9 +126,10 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {
124126 projectExpandedById,
125127 projectOrder,
126128 threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById),
127 threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded(
128 parsed.threadChangedFilesExpandedById,
129 ),
129 threadChangedFilesExpandedById:
130 parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION
131 ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById)
132 : {},
130133 defaultAdvertisedEndpointKey:
131134 typeof parsed.defaultAdvertisedEndpointKey === "string" &&
132135 parsed.defaultAdvertisedEndpointKey.length > 0
......@@ -172,8 +175,8 @@ function sanitizePersistedThreadChangedFilesExpanded(
172175
173176 const nextTurns: Record<string, boolean> = {};
174177 for (const [turnId, expanded] of Object.entries(turns)) {
175 if (turnId && typeof expanded === "boolean" && expanded === false) {
176 nextTurns[turnId] = false;
178 if (turnId && typeof expanded === "boolean") {
179 nextTurns[turnId] = expanded;
177180 }
178181 }
179182
......@@ -195,14 +198,6 @@ export function persistState(state: UiState): void {
195198 ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY,
196199 ),
197200 );
198 const threadChangedFilesExpandedById = Object.fromEntries(
199 Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => {
200 const nextTurns = Object.fromEntries(
201 Object.entries(turns).filter(([, expanded]) => expanded === false),
202 );
203 return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : [];
204 }),
205 );
206201 window.localStorage.setItem(
207202 PERSISTED_STATE_KEY,
208203 JSON.stringify({
......@@ -210,7 +205,8 @@ export function persistState(state: UiState): void {
210205 projectOrder: state.projectOrder,
211206 threadLastVisitedAtById: state.threadLastVisitedAtById,
212207 defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
213 threadChangedFilesExpandedById,
208 threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,
209 threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,
214210 } satisfies PersistedUiState),
215211 );
216212 if (!legacyKeysCleanedUp) {
......@@ -281,43 +277,17 @@ export function setThreadChangedFilesExpanded(
281277 expanded: boolean,
282278): UiState {
283279 const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {};
284 const currentExpanded = currentThreadState[turnId] ?? true;
285 if (currentExpanded === expanded) {
280 if (currentThreadState[turnId] === expanded) {
286281 return state;
287282 }
288283
289 if (expanded) {
290 if (!(turnId in currentThreadState)) {
291 return state;
292 }
293
294 const nextThreadState = { ...currentThreadState };
295 delete nextThreadState[turnId];
296 if (Object.keys(nextThreadState).length === 0) {
297 const nextState = { ...state.threadChangedFilesExpandedById };
298 delete nextState[threadId];
299 return {
300 ...state,
301 threadChangedFilesExpandedById: nextState,
302 };
303 }
304
305 return {
306 ...state,
307 threadChangedFilesExpandedById: {
308 ...state.threadChangedFilesExpandedById,
309 [threadId]: nextThreadState,
310 },
311 };
312 }
313
314284 return {
315285 ...state,
316286 threadChangedFilesExpandedById: {
317287 ...state.threadChangedFilesExpandedById,
318288 [threadId]: {
319289 ...currentThreadState,
320 [turnId]: false,
290 [turnId]: expanded,
321291 },
322292 },
323293 };