authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-04 19:01:45-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 19:01:45-04:00
logdd7bc147f799f290eb58578a7f81643ecf9ad52e
tree28839c9cfd222208d17daf3f607edf30f7f52536
parentb906ce2d73d025e801877f42a35b7a2f5629806f
signature Signed by PGP key B5690EEEBB952194

fix(web): simplify changed files into a persistent folder tree (#9821)


6 files changed, 57 insertions(+), 166 deletions(-)

apps/web/src/components/chat/ChangedFilesTree.test.tsx+11-23
......@@ -10,26 +10,21 @@ 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}
1513 allDirectoriesExpanded
1614 resolvedTheme="light"
17 onExpandedChange={() => {}}
1815 onToggleAllDirectories={() => {}}
1916 onOpenTurnDiff={() => {}}
2017 />,
2118 );
2219
23 expect(markup).toContain('data-changed-files-state="expanded"');
24 expect(markup).toContain('aria-expanded="true"');
25 expect(markup).toContain('aria-label="Collapse all folders"');
20 expect(markup).toContain('data-changed-files-state="tree"');
2621 expect(markup).toContain('aria-label="Open diff"');
2722 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');
2823 expect(markup).toContain("1 changed file");
2924 expect(markup).not.toContain("1 changed files");
3025 });
3126
32 it("renders a scope and representative-file preview for a large latest change", () => {
27 it("shows collapsed folders and root files together", () => {
3328 const markup = renderToStaticMarkup(
3429 <ChangedFilesCard
3530 turnId={TurnId.make("turn-1")}
......@@ -44,46 +39,39 @@ describe("ChangedFilesCard", () => {
4439 },
4540 { path: "README.md", kind: "modified", additions: 3, deletions: 0 },
4641 ]}
47 expanded={false}
48 showCompactPreview
4942 allDirectoriesExpanded={false}
5043 resolvedTheme="light"
51 onExpandedChange={() => {}}
5244 onToggleAllDirectories={() => {}}
5345 onOpenTurnDiff={() => {}}
5446 />,
5547 );
5648
57 expect(markup).toContain('data-changed-files-state="preview"');
49 expect(markup).toContain('data-changed-files-state="tree"');
5850 expect(markup).toContain('aria-expanded="false"');
59 expect(markup).toContain("apps");
60 expect(markup).toContain("2 files");
61 expect(markup).toContain("packages");
62 expect(markup).toContain("root");
63 expect(markup).toContain("App.tsx");
64 expect(markup).toContain("git.ts");
51 expect(markup).toContain("apps/web/src");
52 expect(markup).not.toContain("App.tsx");
53 expect(markup).toContain("packages/shared/src");
54 expect(markup).not.toContain("git.ts");
6555 expect(markup).toContain("README.md");
66 expect(markup).toContain("Show all 4 files");
56 expect(markup).not.toContain("Show all");
6757 expect(markup).not.toContain("App.test.tsx");
6858 });
6959
70 it("keeps older collapsed changes to a one-line receipt", () => {
60 it("keeps the folder tree visible when folders are collapsed", () => {
7161 const markup = renderToStaticMarkup(
7262 <ChangedFilesCard
7363 turnId={TurnId.make("turn-1")}
7464 files={[{ path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 }]}
75 expanded={false}
76 showCompactPreview={false}
7765 allDirectoriesExpanded={false}
7866 resolvedTheme="light"
79 onExpandedChange={() => {}}
8067 onToggleAllDirectories={() => {}}
8168 onOpenTurnDiff={() => {}}
8269 />,
8370 );
8471
85 expect(markup).toContain('data-changed-files-state="collapsed"');
72 expect(markup).toContain('data-changed-files-state="tree"');
8673 expect(markup).toContain("1 changed file");
74 expect(markup).toContain("apps/web/src");
8775 expect(markup).not.toContain("Show all");
8876 expect(markup).not.toContain("App.tsx");
8977 });
apps/web/src/components/chat/ChangedFilesTree.tsx+35-123
......@@ -19,99 +19,59 @@ 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";
2722
2823const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2924
3025export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
3126 turnId: TurnId;
3227 files: ReadonlyArray<TurnDiffFileChange>;
33 expanded: boolean;
34 showCompactPreview: boolean;
3528 allDirectoriesExpanded: boolean;
3629 resolvedTheme: "light" | "dark";
37 onExpandedChange: (expanded: boolean) => void;
3830 onToggleAllDirectories: () => void;
3931 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
4032}) {
4133 const {
4234 turnId,
4335 files,
44 expanded,
45 showCompactPreview,
4636 allDirectoriesExpanded,
4737 resolvedTheme,
48 onExpandedChange,
4938 onToggleAllDirectories,
5039 onOpenTurnDiff,
5140 } = props;
5241 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;
42 const hasDirectories = files.some((file) => /[/\\]/.test(file.path));
5643
5744 return (
5845 <div
59 className="@container/changed-files 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 }
46 className="@container/changed-files mt-4 rounded-lg bg-secondary dark:bg-input/20"
47 data-changed-files-state="tree"
6348 >
6449 <div
6550 data-changed-files-header=""
66 className={cn(
67 "flex items-center justify-between gap-2 rounded-xl",
68 expanded &&
69 "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--contrast-foreground)_2.5%,var(--background))]",
70 )}
51 className="sticky top-2 z-10 flex items-center justify-between gap-2 rounded-t-lg bg-secondary px-3 py-2 dark:bg-[color-mix(in_srgb,var(--input)_20%,var(--background))]"
7152 >
72 <button
73 type="button"
74 aria-expanded={expanded}
75 data-scroll-anchor-ignore
76 className="group flex min-w-0 flex-1 items-center rounded-xl px-2 py-1.5 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
77 onClick={() => onExpandedChange(!expanded)}
78 >
79 <span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
80 <ChevronRightIcon
81 aria-hidden="true"
82 className={cn(
83 "size-3.5 shrink-0 text-muted-foreground transition-transform",
84 expanded && "rotate-90",
85 )}
86 />
87 <span className="flex shrink-0 items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">
88 <span>
89 {files.length} changed file{files.length === 1 ? "" : "s"}
90 </span>
91 {hasNonZeroStat(summaryStat) && (
92 <DiffStatLabel
93 additions={summaryStat.additions}
94 className="text-xs leading-4"
95 deletions={summaryStat.deletions}
96 layout="inline"
97 />
98 )}
99 </span>
100 <span className="ml-1 hidden min-w-0 flex-1 truncate text-[11px] text-muted-foreground group-hover:text-foreground/80 @[24rem]/changed-files:inline">
101 {expanded ? "Hide files" : "Show files"}
102 </span>
53 <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-foreground">
54 <span>
55 {files.length} changed file{files.length === 1 ? "" : "s"}
10356 </span>
104 </button>
105 <div className="flex shrink-0 items-center gap-1.5 pr-1">
106 {expanded ? (
57 {hasNonZeroStat(summaryStat) && (
58 <DiffStatLabel
59 additions={summaryStat.additions}
60 deletions={summaryStat.deletions}
61 layout="inline"
62 className="text-xs leading-4"
63 />
64 )}
65 </div>
66 <div className="flex shrink-0 items-center gap-1">
67 {hasDirectories && (
10768 <Tooltip>
10869 <TooltipTrigger
10970 render={
11071 <Button
11172 type="button"
11273 size="icon-xs"
113 variant="outline"
114 className="!size-[22px]"
74 variant="ghost-muted"
11575 aria-label={
11676 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
11777 }
......@@ -130,14 +90,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
13090 {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
13191 </TooltipPopup>
13292 </Tooltip>
133 ) : null}
93 )}
13494 <Tooltip>
13595 <TooltipTrigger
13696 render={
13797 <Button
13898 type="button"
13999 size="xs"
140 variant="outline"
100 variant="ghost-muted"
141101 aria-label="Open diff"
142102 onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}
143103 />
......@@ -150,61 +110,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
150110 </Tooltip>
151111 </div>
152112 </div>
153 {expanded ? (
154 <ChangedFilesTree
155 key={`changed-files-tree:${turnId}`}
156 turnId={turnId}
157 files={files}
158 allDirectoriesExpanded={allDirectoriesExpanded}
159 resolvedTheme={resolvedTheme}
160 onOpenTurnDiff={onOpenTurnDiff}
161 />
162 ) : compactPreviewVisible ? (
163 <div className="px-2 pb-1.5 pt-1">
164 <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground">
165 {scopeSummary.map((scope, index) => (
166 <span key={scope.label} className="inline-flex items-center gap-1">
167 {index > 0 ? <span aria-hidden="true">·</span> : null}
168 <span className="font-mono text-foreground/75">{scope.label}</span>
169 <span>
170 {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"}
171 </span>
172 </span>
173 ))}
174 </p>
175 <div className="mt-2 flex flex-wrap items-center gap-1.5">
176 {previewFiles.map((file) => (
177 <Tooltip key={file.path}>
178 <TooltipTrigger
179 render={
180 <button
181 type="button"
182 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"
183 onClick={() => onOpenTurnDiff(turnId, file.path)}
184 />
185 }
186 >
187 <PierreEntryIcon
188 pathValue={file.path}
189 kind="file"
190 theme={resolvedTheme}
191 className="size-3 shrink-0 text-muted-foreground/70"
192 />
193 <span className="truncate">{changedFileName(file.path)}</span>
194 </TooltipTrigger>
195 <TooltipPopup side="top">{file.path}</TooltipPopup>
196 </Tooltip>
197 ))}
198 <button
199 type="button"
200 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"
201 onClick={() => onExpandedChange(true)}
202 >
203 Show all {files.length} files
204 </button>
205 </div>
206 </div>
207 ) : null}
113 <ChangedFilesTree
114 key={`${turnId}:${allDirectoriesExpanded}`}
115 turnId={turnId}
116 files={files}
117 allDirectoriesExpanded={allDirectoriesExpanded}
118 resolvedTheme={resolvedTheme}
119 onOpenTurnDiff={onOpenTurnDiff}
120 />
208121 </div>
209122 );
210123});
......@@ -261,7 +174,8 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
261174 <button
262175 type="button"
263176 data-scroll-anchor-ignore
264 className="group flex w-full items-center gap-1.5 rounded-xl py-1 pr-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
177 aria-expanded={isExpanded}
178 className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
265179 style={{ paddingLeft: `${leftPadding}px` }}
266180 onClick={() => toggleDirectory(node.path)}
267181 >
......@@ -287,9 +201,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
287201 )}
288202 </button>
289203 {isExpanded && (
290 <div className="space-y-0.5">
291 {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}
292 </div>
204 <div>{node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}</div>
293205 )}
294206 </div>
295207 );
......@@ -299,7 +211,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
299211 <button
300212 key={`file:${node.path}`}
301213 type="button"
302 className="group flex w-full items-center gap-1.5 rounded-xl py-1 pr-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
214 className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
303215 style={{ paddingLeft: `${leftPadding}px` }}
304216 onClick={() => onOpenTurnDiff(turnId, node.path)}
305217 >
......@@ -312,7 +224,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
312224 theme={resolvedTheme}
313225 className="size-3.5 text-muted-foreground/70"
314226 />
315 <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">
227 <span className="truncate font-mono text-xs text-foreground/85 group-hover:text-foreground">
316228 {node.name}
317229 </span>
318230 {node.stat && (
......@@ -324,7 +236,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
324236 );
325237 };
326238
327 return <div className="space-y-0.5">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>;
239 return <div className="p-2">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>;
328240});
329241
330242function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] {
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-2
......@@ -379,9 +379,8 @@ describe("MessagesTimeline", () => {
379379 expect(markup).toContain("sticky top-2 z-10");
380380 expect(markup).not.toContain("self-start");
381381 expect(markup).toContain("whitespace-nowrap");
382 expect(markup).toContain("!size-[22px]");
383382 expect(markup).toContain("size-3");
384 expect(markup).toContain('aria-label="Collapse all folders"');
383 expect(markup).not.toContain('aria-label="Collapse all folders"');
385384 expect(markup).toContain('aria-label="Open diff"');
386385 expect(markup).toContain("1 changed file");
387386 });
apps/web/src/components/chat/MessagesTimeline.tsx+3-13
......@@ -111,7 +111,6 @@ import {
111111} from "./ExpandedImagePreview";
112112import { ProposedPlanCard } from "./ProposedPlanCard";
113113import { ChangedFilesCard } from "./ChangedFilesTree";
114import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
115114import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring";
116115import { MessageCopyButton } from "./MessageCopyButton";
117116import { PierreEntryIcon } from "./PierreEntryIcon";
......@@ -2117,30 +2116,21 @@ function AssistantChangedFilesSectionInner({
21172116 resolvedTheme: "light" | "dark";
21182117 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
21192118}) {
2120 const activity = use(TimelineRowActivityCtx);
2121 const isLatestTurn = activity.latestTurnId === turnSummary.turnId;
21222119 const persistedExpanded = useUiStateStore(
21232120 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId],
21242121 );
21252122 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
2126 const [autoExpanded] = useState(() =>
2127 shouldAutoExpandChangedFiles(checkpointFiles, isLatestTurn),
2128 );
2129 const [allDirectoriesExpanded, setAllDirectoriesExpanded] = useState(autoExpanded);
2130 const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded);
2123 const allDirectoriesExpanded = persistedExpanded ?? false;
21312124
21322125 return (
21332126 <ChangedFilesCard
21342127 turnId={turnSummary.turnId}
21352128 files={checkpointFiles}
2136 expanded={expanded}
2137 showCompactPreview={isLatestTurn}
21382129 allDirectoriesExpanded={allDirectoriesExpanded}
21392130 resolvedTheme={resolvedTheme}
2140 onExpandedChange={(nextExpanded) =>
2141 setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded)
2131 onToggleAllDirectories={() =>
2132 setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
21422133 }
2143 onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)}
21442134 onOpenTurnDiff={onOpenTurnDiff}
21452135 />
21462136 );
apps/web/src/uiStateStore.test.ts+4-3
......@@ -159,7 +159,7 @@ describe("parsePersistedState", () => {
159159 invalid: "not-a-date",
160160 },
161161 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
162 threadChangedFilesExpansionVersion: 1,
162 threadChangedFilesExpansionVersion: 2,
163163 threadChangedFilesExpandedById: {
164164 "environment:thread-1": {
165165 "turn-1": false,
......@@ -186,8 +186,9 @@ describe("parsePersistedState", () => {
186186 });
187187 });
188188
189 it("ignores changed-file expansion values saved with legacy folder semantics", () => {
189 it.each([undefined, 1])("ignores changed-file expansion version %s", (version) => {
190190 const parsed = parsePersistedState({
191 ...(version === undefined ? {} : { threadChangedFilesExpansionVersion: version }),
191192 threadChangedFilesExpandedById: {
192193 "environment:thread-1": {
193194 "turn-1": false,
......@@ -296,7 +297,7 @@ describe("uiStateStore persistence", () => {
296297 "environment:thread-1": "2026-02-25T12:35:00.000Z",
297298 },
298299 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
299 threadChangedFilesExpansionVersion: 1,
300 threadChangedFilesExpansionVersion: 2,
300301 threadChangedFilesExpandedById: {
301302 "environment:thread-1": {
302303 "turn-1": false,
apps/web/src/uiStateStore.ts+3-2
......@@ -3,7 +3,8 @@ 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;
6// Version 1 stored card visibility, not folder expansion.
7const THREAD_CHANGED_FILES_EXPANSION_VERSION = 2;
78const LEGACY_PERSISTED_STATE_KEYS = [
89 "t3code:renderer-state:v8",
910 "t3code:renderer-state:v7",
......@@ -25,7 +26,7 @@ export interface PersistedUiState {
2526 expandedProjectCwds?: string[];
2627 projectOrderCwds?: string[];
2728 defaultAdvertisedEndpointKey?: string | null;
28 threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION;
29 threadChangedFilesExpansionVersion?: number;
2930 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
3031}
3132