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", () => {...@@ -10,23 +10,86 @@ describe("ChangedFilesCard", () => {
10 <ChangedFilesCard10 <ChangedFilesCard
11 turnId={TurnId.make("turn-1")}11 turnId={TurnId.make("turn-1")}
12 files={[{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }]}12 files={[{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }]}
13 expanded
14 showCompactPreview={false}
13 allDirectoriesExpanded15 allDirectoriesExpanded
14 resolvedTheme="light"16 resolvedTheme="light"
17 onExpandedChange={() => {}}
15 onToggleAllDirectories={() => {}}18 onToggleAllDirectories={() => {}}
16 onOpenTurnDiff={() => {}}19 onOpenTurnDiff={() => {}}
17 />,20 />,
18 );21 );
1922
20 expect(markup).toContain('class="sticky top-0 z-10');23 expect(markup).toContain('data-changed-files-state="expanded"');
21 expect(markup).not.toContain("self-start");24 expect(markup).toContain('aria-expanded="true"');
22 expect(markup).toContain("whitespace-nowrap");25 expect(markup).toContain("whitespace-nowrap");
23 expect(markup).toContain("!size-[22px]");26 expect(markup).toContain("!size-[22px]");
24 expect(markup).toContain("size-3");27 expect(markup).toContain("size-3");
25 expect(markup).toContain('aria-label="Collapse all"');28 expect(markup).toContain('aria-label="Collapse all folders"');
26 expect(markup).toContain('aria-label="View diff"');29 expect(markup).toContain('aria-label="Open diff"');
30 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');
27 expect(markup).toContain("1 changed file");31 expect(markup).toContain("1 changed file");
28 expect(markup).not.toContain("1 changed files");32 expect(markup).not.toContain("1 changed files");
29 });33 });
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 });
30});93});
3194
32describe("ChangedFilesTree", () => {95describe("ChangedFilesTree", () => {
apps/web/src/components/chat/ChangedFilesTree.tsx+139-50
...@@ -19,95 +19,184 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";...@@ -19,95 +19,184 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
19import { PierreEntryIcon } from "./PierreEntryIcon";19import { PierreEntryIcon } from "./PierreEntryIcon";
20import { Button } from "../ui/button";20import { Button } from "../ui/button";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
22import {
23 changedFileName,
24 selectChangedFilePreview,
25 summarizeChangedFileScopes,
26} from "./changedFilesPresentation";
2227
23const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};28const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2429
25export const ChangedFilesCard = memo(function ChangedFilesCard(props: {30export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
26 turnId: TurnId;31 turnId: TurnId;
27 files: ReadonlyArray<TurnDiffFileChange>;32 files: ReadonlyArray<TurnDiffFileChange>;
33 expanded: boolean;
34 showCompactPreview: boolean;
28 allDirectoriesExpanded: boolean;35 allDirectoriesExpanded: boolean;
29 resolvedTheme: "light" | "dark";36 resolvedTheme: "light" | "dark";
37 onExpandedChange: (expanded: boolean) => void;
30 onToggleAllDirectories: () => void;38 onToggleAllDirectories: () => void;
31 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;39 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
32}) {40}) {
33 const {41 const {
34 turnId,42 turnId,
35 files,43 files,
44 expanded,
45 showCompactPreview,
36 allDirectoriesExpanded,46 allDirectoriesExpanded,
37 resolvedTheme,47 resolvedTheme,
48 onExpandedChange,
38 onToggleAllDirectories,49 onToggleAllDirectories,
39 onOpenTurnDiff,50 onOpenTurnDiff,
40 } = props;51 } = props;
41 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);52 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
43 return (57 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">58 <div
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))]">59 className="mt-4 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32"
46 <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">60 data-changed-files-state={
47 <span>61 expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed"
48 {files.length} changed file{files.length === 1 ? "" : "s"}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 )}
49 </span>97 </span>
50 {hasNonZeroStat(summaryStat) && (98 <span className="ml-1 hidden truncate text-[11px] text-muted-foreground group-hover:text-foreground/80 sm:inline">
51 <DiffStatLabel99 {expanded ? "Hide files" : "Show files"}
52 additions={summaryStat.additions}100 </span>
53 className="text-xs leading-4"101 </button>
54 deletions={summaryStat.deletions}
55 layout="inline"
56 />
57 )}
58 </p>
59 <div className="flex items-center gap-1.5">102 <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}
60 <Tooltip>131 <Tooltip>
61 <TooltipTrigger132 <TooltipTrigger
62 render={133 render={
63 <Button134 <Button
64 type="button"135 type="button"
65 size="icon-xs"136 size="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"
90 variant="outline"137 variant="outline"
91 className="!size-[22px]"138 aria-label="Open diff"
92 aria-label="View diff"
93 onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}139 onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}
94 />140 />
95 }141 }
96 >142 >
97 <FileDiffIcon className="size-3" />143 <FileDiffIcon className="size-3" />
144 <span className="hidden sm:inline">Open diff</span>
98 </TooltipTrigger>145 </TooltipTrigger>
99 <TooltipPopup side="top">View diff</TooltipPopup>146 <TooltipPopup side="top">Open the full diff</TooltipPopup>
100 </Tooltip>147 </Tooltip>
101 </div>148 </div>
102 </div>149 </div>
103 <ChangedFilesTree150 {expanded ? (
104 key={`changed-files-tree:${turnId}`}151 <ChangedFilesTree
105 turnId={turnId}152 key={`changed-files-tree:${turnId}`}
106 files={files}153 turnId={turnId}
107 allDirectoriesExpanded={allDirectoriesExpanded}154 files={files}
108 resolvedTheme={resolvedTheme}155 allDirectoriesExpanded={allDirectoriesExpanded}
109 onOpenTurnDiff={onOpenTurnDiff}156 resolvedTheme={resolvedTheme}
110 />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}
111 </div>200 </div>
112 );201 );
113});202});
apps/web/src/components/chat/DiffStatLabel.tsx+8-2
...@@ -31,6 +31,8 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: {...@@ -31,6 +31,8 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: {
31 <>31 <>
32 {showParentheses && <span className="text-muted-foreground/70">(</span>}32 {showParentheses && <span className="text-muted-foreground/70">(</span>}
33 <span33 <span
34 role="group"
35 aria-label={`${additions} additions, ${deletions} deletions`}
34 className={cn(36 className={cn(
35 layout === "inline"37 layout === "inline"
36 ? "inline-flex items-center gap-1 tabular-nums align-middle"38 ? "inline-flex items-center gap-1 tabular-nums align-middle"
...@@ -38,8 +40,12 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: {...@@ -38,8 +40,12 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: {
38 className,40 className,
39 )}41 )}
40 >42 >
41 <span className="font-mono text-success">+{formatCompactDiffCount(additions)}</span>43 <span aria-hidden="true" className="font-mono text-success">
42 <span className="font-mono text-destructive">-{formatCompactDiffCount(deletions)}</span>44 +{formatCompactDiffCount(additions)}
45 </span>
46 <span aria-hidden="true" className="font-mono text-destructive">
47 -{formatCompactDiffCount(deletions)}
48 </span>
43 </span>49 </span>
44 {showParentheses && <span className="text-muted-foreground/70">)</span>}50 {showParentheses && <span className="text-muted-foreground/70">)</span>}
45 </>51 </>
apps/web/src/components/chat/MessagesTimeline.test.tsx+9-3
...@@ -245,6 +245,12 @@ describe("MessagesTimeline", () => {...@@ -245,6 +245,12 @@ describe("MessagesTimeline", () => {
245 const markup = renderToStaticMarkup(245 const markup = renderToStaticMarkup(
246 <MessagesTimeline246 <MessagesTimeline
247 {...buildProps()}247 {...buildProps()}
248 latestTurn={{
249 turnId,
250 state: "completed",
251 startedAt: MESSAGE_CREATED_AT,
252 completedAt: MESSAGE_CREATED_AT,
253 }}
248 timelineEntries={[254 timelineEntries={[
249 {255 {
250 id: "entry-assistant-with-files",256 id: "entry-assistant-with-files",
...@@ -280,13 +286,13 @@ describe("MessagesTimeline", () => {...@@ -280,13 +286,13 @@ describe("MessagesTimeline", () => {
280 />,286 />,
281 );287 );
282288
283 expect(markup).toContain('class="sticky top-2 z-10');289 expect(markup).toContain("sticky top-2 z-10");
284 expect(markup).not.toContain("self-start");290 expect(markup).not.toContain("self-start");
285 expect(markup).toContain("whitespace-nowrap");291 expect(markup).toContain("whitespace-nowrap");
286 expect(markup).toContain("!size-[22px]");292 expect(markup).toContain("!size-[22px]");
287 expect(markup).toContain("size-3");293 expect(markup).toContain("size-3");
288 expect(markup).toContain('aria-label="Collapse all"');294 expect(markup).toContain('aria-label="Collapse all folders"');
289 expect(markup).toContain('aria-label="View diff"');295 expect(markup).toContain('aria-label="Open diff"');
290 expect(markup).toContain("1 changed file");296 expect(markup).toContain("1 changed file");
291 });297 });
292298
apps/web/src/components/chat/MessagesTimeline.tsx+27-80
...@@ -32,7 +32,6 @@ import {...@@ -32,7 +32,6 @@ import {
32 workLogEntryIsToolLike,32 workLogEntryIsToolLike,
33} from "../../session-logic";33} from "../../session-logic";
34import { type TurnDiffSummary } from "../../types";34import { type TurnDiffSummary } from "../../types";
35import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
36import {35import {
37 getRenderablePatch,36 getRenderablePatch,
38 resolveDiffThemeName,37 resolveDiffThemeName,
...@@ -44,11 +43,8 @@ import {...@@ -44,11 +43,8 @@ import {
44 CheckIcon,43 CheckIcon,
45 ChevronDownIcon,44 ChevronDownIcon,
46 ChevronRightIcon,45 ChevronRightIcon,
47 ChevronsDownUpIcon,
48 ChevronsUpDownIcon,
49 CircleAlertIcon,46 CircleAlertIcon,
50 EyeIcon,47 EyeIcon,
51 FileDiffIcon,
52 GlobeIcon,48 GlobeIcon,
53 HammerIcon,49 HammerIcon,
54 MessageCircleIcon,50 MessageCircleIcon,
...@@ -65,8 +61,8 @@ import {...@@ -65,8 +61,8 @@ import {
65import { Button } from "../ui/button";61import { Button } from "../ui/button";
66import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";62import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
67import { ProposedPlanCard } from "./ProposedPlanCard";63import { ProposedPlanCard } from "./ProposedPlanCard";
68import { ChangedFilesTree } from "./ChangedFilesTree";64import { ChangedFilesCard } from "./ChangedFilesTree";
69import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";65import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
70import { MessageCopyButton } from "./MessageCopyButton";66import { MessageCopyButton } from "./MessageCopyButton";
71import {67import {
72 computeStableMessagesTimelineRows,68 computeStableMessagesTimelineRows,
...@@ -145,6 +141,7 @@ interface TimelineRowActivityState {...@@ -145,6 +141,7 @@ interface TimelineRowActivityState {
145 isWorking: boolean;141 isWorking: boolean;
146 isRevertingCheckpoint: boolean;142 isRevertingCheckpoint: boolean;
147 activeTurnInProgress: boolean;143 activeTurnInProgress: boolean;
144 latestTurnId: TurnId | null;
148}145}
149146
150const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);147const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
...@@ -454,8 +451,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -454,8 +451,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
454 isWorking,451 isWorking,
455 isRevertingCheckpoint,452 isRevertingCheckpoint,
456 activeTurnInProgress,453 activeTurnInProgress,
454 latestTurnId: latestTurn?.turnId ?? null,
457 }),455 }),
458 [activeTurnInProgress, isRevertingCheckpoint, isWorking],456 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId],
459 );457 );
460458
461 // Stable renderItem — no closure deps. Row components read shared state459 // Stable renderItem — no closure deps. Row components read shared state
...@@ -1278,83 +1276,32 @@ function AssistantChangedFilesSectionInner({...@@ -1278,83 +1276,32 @@ function AssistantChangedFilesSectionInner({
1278 resolvedTheme: "light" | "dark";1276 resolvedTheme: "light" | "dark";
1279 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;1277 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
1280}) {1278}) {
1281 const allDirectoriesExpanded = useUiStateStore(1279 const activity = use(TimelineRowActivityCtx);
1282 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true,1280 const isLatestTurn = activity.latestTurnId === turnSummary.turnId;
1281 const persistedExpanded = useUiStateStore(
1282 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId],
1283 );1283 );
1284 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);1284 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
1287 return (1291 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">1292 <ChangedFilesCard
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))]">1293 turnId={turnSummary.turnId}
1290 <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">1294 files={checkpointFiles}
1291 <span>1295 expanded={expanded}
1292 {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"}1296 showCompactPreview={isLatestTurn}
1293 </span>1297 allDirectoriesExpanded={allDirectoriesExpanded}
1294 {hasNonZeroStat(summaryStat) && (1298 resolvedTheme={resolvedTheme}
1295 <DiffStatLabel1299 onExpandedChange={(nextExpanded) =>
1296 additions={summaryStat.additions}1300 setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded)
1297 className="text-xs leading-4"1301 }
1298 deletions={summaryStat.deletions}1302 onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)}
1299 layout="inline"1303 onOpenTurnDiff={onOpenTurnDiff}
1300 />1304 />
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>
1358 );1305 );
1359}1306}
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", () => {...@@ -116,7 +116,7 @@ describe("uiStateStore pure functions", () => {
116 );116 );
117 });117 });
118118
119 it("stores only collapsed changed-file turns", () => {119 it("stores explicit changed-file expansion choices", () => {
120 const threadId = ThreadId.make("thread-1");120 const threadId = ThreadId.make("thread-1");
121 const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false);121 const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false);
122122
...@@ -128,7 +128,11 @@ describe("uiStateStore pure functions", () => {...@@ -128,7 +128,11 @@ describe("uiStateStore pure functions", () => {
128 expect(128 expect(
129 setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true)129 setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true)
130 .threadChangedFilesExpandedById,130 .threadChangedFilesExpandedById,
131 ).toEqual({});131 ).toEqual({
132 [threadId]: {
133 "turn-1": true,
134 },
135 });
132 });136 });
133137
134 it("stores the endpoint preference by stable key", () => {138 it("stores the endpoint preference by stable key", () => {
...@@ -155,6 +159,7 @@ describe("parsePersistedState", () => {...@@ -155,6 +159,7 @@ describe("parsePersistedState", () => {
155 invalid: "not-a-date",159 invalid: "not-a-date",
156 },160 },
157 defaultAdvertisedEndpointKey: "desktop-core:lan:http",161 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
162 threadChangedFilesExpansionVersion: 1,
158 threadChangedFilesExpandedById: {163 threadChangedFilesExpandedById: {
159 "environment:thread-1": {164 "environment:thread-1": {
160 "turn-1": false,165 "turn-1": false,
...@@ -172,12 +177,25 @@ describe("parsePersistedState", () => {...@@ -172,12 +177,25 @@ describe("parsePersistedState", () => {
172 "environment:thread-1": "2026-02-25T12:35:00.000Z",177 "environment:thread-1": "2026-02-25T12:35:00.000Z",
173 },178 },
174 defaultAdvertisedEndpointKey: "desktop-core:lan:http",179 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({
175 threadChangedFilesExpandedById: {191 threadChangedFilesExpandedById: {
176 "environment:thread-1": {192 "environment:thread-1": {
177 "turn-1": false,193 "turn-1": false,
178 },194 },
179 },195 },
180 });196 });
197
198 expect(parsed.threadChangedFilesExpandedById).toEqual({});
181 });199 });
182200
183 it("migrates legacy CWD project preferences into local alias keys", () => {201 it("migrates legacy CWD project preferences into local alias keys", () => {
...@@ -278,19 +296,16 @@ describe("uiStateStore persistence", () => {...@@ -278,19 +296,16 @@ describe("uiStateStore persistence", () => {
278 "environment:thread-1": "2026-02-25T12:35:00.000Z",296 "environment:thread-1": "2026-02-25T12:35:00.000Z",
279 },297 },
280 defaultAdvertisedEndpointKey: "desktop-core:lan:http",298 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
299 threadChangedFilesExpansionVersion: 1,
281 threadChangedFilesExpandedById: {300 threadChangedFilesExpandedById: {
282 "environment:thread-1": {301 "environment:thread-1": {
283 "turn-1": false,302 "turn-1": false,
303 "turn-2": true,
284 },304 },
285 },305 },
286 });306 });
287 expect(parsePersistedState(persisted)).toEqual({307 expect(parsePersistedState(persisted)).toEqual({
288 ...state,308 ...state,
289 threadChangedFilesExpandedById: {
290 "environment:thread-1": {
291 "turn-1": false,
292 },
293 },
294 });309 });
295 });310 });
296311
apps/web/src/uiStateStore.ts+12-42
...@@ -3,6 +3,7 @@ import { create } from "zustand";...@@ -3,6 +3,7 @@ import { create } from "zustand";
3import { normalizeProjectPathForComparison } from "./lib/projectPaths";3import { normalizeProjectPathForComparison } from "./lib/projectPaths";
44
5export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";5export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
6const THREAD_CHANGED_FILES_EXPANSION_VERSION = 1;
6const LEGACY_PERSISTED_STATE_KEYS = [7const LEGACY_PERSISTED_STATE_KEYS = [
7 "t3code:renderer-state:v8",8 "t3code:renderer-state:v8",
8 "t3code:renderer-state:v7",9 "t3code:renderer-state:v7",
...@@ -24,6 +25,7 @@ export interface PersistedUiState {...@@ -24,6 +25,7 @@ export interface PersistedUiState {
24 expandedProjectCwds?: string[];25 expandedProjectCwds?: string[];
25 projectOrderCwds?: string[];26 projectOrderCwds?: string[];
26 defaultAdvertisedEndpointKey?: string | null;27 defaultAdvertisedEndpointKey?: string | null;
28 threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION;
27 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;29 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
28}30}
2931
...@@ -124,9 +126,10 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {...@@ -124,9 +126,10 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {
124 projectExpandedById,126 projectExpandedById,
125 projectOrder,127 projectOrder,
126 threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById),128 threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById),
127 threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded(129 threadChangedFilesExpandedById:
128 parsed.threadChangedFilesExpandedById,130 parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION
129 ),131 ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById)
132 : {},
130 defaultAdvertisedEndpointKey:133 defaultAdvertisedEndpointKey:
131 typeof parsed.defaultAdvertisedEndpointKey === "string" &&134 typeof parsed.defaultAdvertisedEndpointKey === "string" &&
132 parsed.defaultAdvertisedEndpointKey.length > 0135 parsed.defaultAdvertisedEndpointKey.length > 0
...@@ -172,8 +175,8 @@ function sanitizePersistedThreadChangedFilesExpanded(...@@ -172,8 +175,8 @@ function sanitizePersistedThreadChangedFilesExpanded(
172175
173 const nextTurns: Record<string, boolean> = {};176 const nextTurns: Record<string, boolean> = {};
174 for (const [turnId, expanded] of Object.entries(turns)) {177 for (const [turnId, expanded] of Object.entries(turns)) {
175 if (turnId && typeof expanded === "boolean" && expanded === false) {178 if (turnId && typeof expanded === "boolean") {
176 nextTurns[turnId] = false;179 nextTurns[turnId] = expanded;
177 }180 }
178 }181 }
179182
...@@ -195,14 +198,6 @@ export function persistState(state: UiState): void {...@@ -195,14 +198,6 @@ export function persistState(state: UiState): void {
195 ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY,198 ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY,
196 ),199 ),
197 );200 );
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 );
206 window.localStorage.setItem(201 window.localStorage.setItem(
207 PERSISTED_STATE_KEY,202 PERSISTED_STATE_KEY,
208 JSON.stringify({203 JSON.stringify({
...@@ -210,7 +205,8 @@ export function persistState(state: UiState): void {...@@ -210,7 +205,8 @@ export function persistState(state: UiState): void {
210 projectOrder: state.projectOrder,205 projectOrder: state.projectOrder,
211 threadLastVisitedAtById: state.threadLastVisitedAtById,206 threadLastVisitedAtById: state.threadLastVisitedAtById,
212 defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,207 defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
213 threadChangedFilesExpandedById,208 threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,
209 threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,
214 } satisfies PersistedUiState),210 } satisfies PersistedUiState),
215 );211 );
216 if (!legacyKeysCleanedUp) {212 if (!legacyKeysCleanedUp) {
...@@ -281,43 +277,17 @@ export function setThreadChangedFilesExpanded(...@@ -281,43 +277,17 @@ export function setThreadChangedFilesExpanded(
281 expanded: boolean,277 expanded: boolean,
282): UiState {278): UiState {
283 const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {};279 const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {};
284 const currentExpanded = currentThreadState[turnId] ?? true;280 if (currentThreadState[turnId] === expanded) {
285 if (currentExpanded === expanded) {
286 return state;281 return state;
287 }282 }
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
314 return {284 return {
315 ...state,285 ...state,
316 threadChangedFilesExpandedById: {286 threadChangedFilesExpandedById: {
317 ...state.threadChangedFilesExpandedById,287 ...state.threadChangedFilesExpandedById,
318 [threadId]: {288 [threadId]: {
319 ...currentThreadState,289 ...currentThreadState,
320 [turnId]: false,290 [turnId]: expanded,
321 },291 },
322 },292 },
323 };293 };