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", () => {...@@ -10,26 +10,21 @@ 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}
15 allDirectoriesExpanded13 allDirectoriesExpanded
16 resolvedTheme="light"14 resolvedTheme="light"
17 onExpandedChange={() => {}}
18 onToggleAllDirectories={() => {}}15 onToggleAllDirectories={() => {}}
19 onOpenTurnDiff={() => {}}16 onOpenTurnDiff={() => {}}
20 />,17 />,
21 );18 );
2219
23 expect(markup).toContain('data-changed-files-state="expanded"');20 expect(markup).toContain('data-changed-files-state="tree"');
24 expect(markup).toContain('aria-expanded="true"');
25 expect(markup).toContain('aria-label="Collapse all folders"');
26 expect(markup).toContain('aria-label="Open diff"');21 expect(markup).toContain('aria-label="Open diff"');
27 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');22 expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');
28 expect(markup).toContain("1 changed file");23 expect(markup).toContain("1 changed file");
29 expect(markup).not.toContain("1 changed files");24 expect(markup).not.toContain("1 changed files");
30 });25 });
3126
32 it("renders a scope and representative-file preview for a large latest change", () => {27 it("shows collapsed folders and root files together", () => {
33 const markup = renderToStaticMarkup(28 const markup = renderToStaticMarkup(
34 <ChangedFilesCard29 <ChangedFilesCard
35 turnId={TurnId.make("turn-1")}30 turnId={TurnId.make("turn-1")}
...@@ -44,46 +39,39 @@ describe("ChangedFilesCard", () => {...@@ -44,46 +39,39 @@ describe("ChangedFilesCard", () => {
44 },39 },
45 { path: "README.md", kind: "modified", additions: 3, deletions: 0 },40 { path: "README.md", kind: "modified", additions: 3, deletions: 0 },
46 ]}41 ]}
47 expanded={false}
48 showCompactPreview
49 allDirectoriesExpanded={false}42 allDirectoriesExpanded={false}
50 resolvedTheme="light"43 resolvedTheme="light"
51 onExpandedChange={() => {}}
52 onToggleAllDirectories={() => {}}44 onToggleAllDirectories={() => {}}
53 onOpenTurnDiff={() => {}}45 onOpenTurnDiff={() => {}}
54 />,46 />,
55 );47 );
5648
57 expect(markup).toContain('data-changed-files-state="preview"');49 expect(markup).toContain('data-changed-files-state="tree"');
58 expect(markup).toContain('aria-expanded="false"');50 expect(markup).toContain('aria-expanded="false"');
59 expect(markup).toContain("apps");51 expect(markup).toContain("apps/web/src");
60 expect(markup).toContain("2 files");52 expect(markup).not.toContain("App.tsx");
61 expect(markup).toContain("packages");53 expect(markup).toContain("packages/shared/src");
62 expect(markup).toContain("root");54 expect(markup).not.toContain("git.ts");
63 expect(markup).toContain("App.tsx");
64 expect(markup).toContain("git.ts");
65 expect(markup).toContain("README.md");55 expect(markup).toContain("README.md");
66 expect(markup).toContain("Show all 4 files");56 expect(markup).not.toContain("Show all");
67 expect(markup).not.toContain("App.test.tsx");57 expect(markup).not.toContain("App.test.tsx");
68 });58 });
6959
70 it("keeps older collapsed changes to a one-line receipt", () => {60 it("keeps the folder tree visible when folders are collapsed", () => {
71 const markup = renderToStaticMarkup(61 const markup = renderToStaticMarkup(
72 <ChangedFilesCard62 <ChangedFilesCard
73 turnId={TurnId.make("turn-1")}63 turnId={TurnId.make("turn-1")}
74 files={[{ path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 }]}64 files={[{ path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 }]}
75 expanded={false}
76 showCompactPreview={false}
77 allDirectoriesExpanded={false}65 allDirectoriesExpanded={false}
78 resolvedTheme="light"66 resolvedTheme="light"
79 onExpandedChange={() => {}}
80 onToggleAllDirectories={() => {}}67 onToggleAllDirectories={() => {}}
81 onOpenTurnDiff={() => {}}68 onOpenTurnDiff={() => {}}
82 />,69 />,
83 );70 );
8471
85 expect(markup).toContain('data-changed-files-state="collapsed"');72 expect(markup).toContain('data-changed-files-state="tree"');
86 expect(markup).toContain("1 changed file");73 expect(markup).toContain("1 changed file");
74 expect(markup).toContain("apps/web/src");
87 expect(markup).not.toContain("Show all");75 expect(markup).not.toContain("Show all");
88 expect(markup).not.toContain("App.tsx");76 expect(markup).not.toContain("App.tsx");
89 });77 });
apps/web/src/components/chat/ChangedFilesTree.tsx+35-123
...@@ -19,99 +19,59 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";...@@ -19,99 +19,59 @@ 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";
2722
28const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};23const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
2924
30export const ChangedFilesCard = memo(function ChangedFilesCard(props: {25export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
31 turnId: TurnId;26 turnId: TurnId;
32 files: ReadonlyArray<TurnDiffFileChange>;27 files: ReadonlyArray<TurnDiffFileChange>;
33 expanded: boolean;
34 showCompactPreview: boolean;
35 allDirectoriesExpanded: boolean;28 allDirectoriesExpanded: boolean;
36 resolvedTheme: "light" | "dark";29 resolvedTheme: "light" | "dark";
37 onExpandedChange: (expanded: boolean) => void;
38 onToggleAllDirectories: () => void;30 onToggleAllDirectories: () => void;
39 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;31 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
40}) {32}) {
41 const {33 const {
42 turnId,34 turnId,
43 files,35 files,
44 expanded,
45 showCompactPreview,
46 allDirectoriesExpanded,36 allDirectoriesExpanded,
47 resolvedTheme,37 resolvedTheme,
48 onExpandedChange,
49 onToggleAllDirectories,38 onToggleAllDirectories,
50 onOpenTurnDiff,39 onOpenTurnDiff,
51 } = props;40 } = props;
52 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);41 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
53 const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]);42 const hasDirectories = files.some((file) => /[/\\]/.test(file.path));
54 const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]);
55 const compactPreviewVisible = showCompactPreview && !expanded;
5643
57 return (44 return (
58 <div45 <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"46 className="@container/changed-files mt-4 rounded-lg bg-secondary dark:bg-input/20"
60 data-changed-files-state={47 data-changed-files-state="tree"
61 expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed"
62 }
63 >48 >
64 <div49 <div
65 data-changed-files-header=""50 data-changed-files-header=""
66 className={cn(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))]"
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 )}
71 >52 >
72 <button53 <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-foreground">
73 type="button"54 <span>
74 aria-expanded={expanded}55 {files.length} changed file{files.length === 1 ? "" : "s"}
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>
103 </span>56 </span>
104 </button>57 {hasNonZeroStat(summaryStat) && (
105 <div className="flex shrink-0 items-center gap-1.5 pr-1">58 <DiffStatLabel
106 {expanded ? (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 && (
107 <Tooltip>68 <Tooltip>
108 <TooltipTrigger69 <TooltipTrigger
109 render={70 render={
110 <Button71 <Button
111 type="button"72 type="button"
112 size="icon-xs"73 size="icon-xs"
113 variant="outline"74 variant="ghost-muted"
114 className="!size-[22px]"
115 aria-label={75 aria-label={
116 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"76 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
117 }77 }
...@@ -130,14 +90,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -130,14 +90,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
130 {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}90 {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
131 </TooltipPopup>91 </TooltipPopup>
132 </Tooltip>92 </Tooltip>
133 ) : null}93 )}
134 <Tooltip>94 <Tooltip>
135 <TooltipTrigger95 <TooltipTrigger
136 render={96 render={
137 <Button97 <Button
138 type="button"98 type="button"
139 size="xs"99 size="xs"
140 variant="outline"100 variant="ghost-muted"
141 aria-label="Open diff"101 aria-label="Open diff"
142 onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}102 onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}
143 />103 />
...@@ -150,61 +110,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -150,61 +110,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
150 </Tooltip>110 </Tooltip>
151 </div>111 </div>
152 </div>112 </div>
153 {expanded ? (113 <ChangedFilesTree
154 <ChangedFilesTree114 key={`${turnId}:${allDirectoriesExpanded}`}
155 key={`changed-files-tree:${turnId}`}115 turnId={turnId}
156 turnId={turnId}116 files={files}
157 files={files}117 allDirectoriesExpanded={allDirectoriesExpanded}
158 allDirectoriesExpanded={allDirectoriesExpanded}118 resolvedTheme={resolvedTheme}
159 resolvedTheme={resolvedTheme}119 onOpenTurnDiff={onOpenTurnDiff}
160 onOpenTurnDiff={onOpenTurnDiff}120 />
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}
208 </div>121 </div>
209 );122 );
210});123});
...@@ -261,7 +174,8 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -261,7 +174,8 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
261 <button174 <button
262 type="button"175 type="button"
263 data-scroll-anchor-ignore176 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"
265 style={{ paddingLeft: `${leftPadding}px` }}179 style={{ paddingLeft: `${leftPadding}px` }}
266 onClick={() => toggleDirectory(node.path)}180 onClick={() => toggleDirectory(node.path)}
267 >181 >
...@@ -287,9 +201,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -287,9 +201,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
287 )}201 )}
288 </button>202 </button>
289 {isExpanded && (203 {isExpanded && (
290 <div className="space-y-0.5">204 <div>{node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}</div>
291 {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}
292 </div>
293 )}205 )}
294 </div>206 </div>
295 );207 );
...@@ -299,7 +211,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -299,7 +211,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
299 <button211 <button
300 key={`file:${node.path}`}212 key={`file:${node.path}`}
301 type="button"213 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"
303 style={{ paddingLeft: `${leftPadding}px` }}215 style={{ paddingLeft: `${leftPadding}px` }}
304 onClick={() => onOpenTurnDiff(turnId, node.path)}216 onClick={() => onOpenTurnDiff(turnId, node.path)}
305 >217 >
...@@ -312,7 +224,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -312,7 +224,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
312 theme={resolvedTheme}224 theme={resolvedTheme}
313 className="size-3.5 text-muted-foreground/70"225 className="size-3.5 text-muted-foreground/70"
314 />226 />
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">
316 {node.name}228 {node.name}
317 </span>229 </span>
318 {node.stat && (230 {node.stat && (
...@@ -324,7 +236,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -324,7 +236,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
324 );236 );
325 };237 };
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>;
328});240});
329241
330function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] {242function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] {
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-2
...@@ -379,9 +379,8 @@ describe("MessagesTimeline", () => {...@@ -379,9 +379,8 @@ describe("MessagesTimeline", () => {
379 expect(markup).toContain("sticky top-2 z-10");379 expect(markup).toContain("sticky top-2 z-10");
380 expect(markup).not.toContain("self-start");380 expect(markup).not.toContain("self-start");
381 expect(markup).toContain("whitespace-nowrap");381 expect(markup).toContain("whitespace-nowrap");
382 expect(markup).toContain("!size-[22px]");
383 expect(markup).toContain("size-3");382 expect(markup).toContain("size-3");
384 expect(markup).toContain('aria-label="Collapse all folders"');383 expect(markup).not.toContain('aria-label="Collapse all folders"');
385 expect(markup).toContain('aria-label="Open diff"');384 expect(markup).toContain('aria-label="Open diff"');
386 expect(markup).toContain("1 changed file");385 expect(markup).toContain("1 changed file");
387 });386 });
apps/web/src/components/chat/MessagesTimeline.tsx+3-13
...@@ -111,7 +111,6 @@ import {...@@ -111,7 +111,6 @@ import {
111} from "./ExpandedImagePreview";111} from "./ExpandedImagePreview";
112import { ProposedPlanCard } from "./ProposedPlanCard";112import { ProposedPlanCard } from "./ProposedPlanCard";
113import { ChangedFilesCard } from "./ChangedFilesTree";113import { ChangedFilesCard } from "./ChangedFilesTree";
114import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
115import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring";114import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring";
116import { MessageCopyButton } from "./MessageCopyButton";115import { MessageCopyButton } from "./MessageCopyButton";
117import { PierreEntryIcon } from "./PierreEntryIcon";116import { PierreEntryIcon } from "./PierreEntryIcon";
...@@ -2117,30 +2116,21 @@ function AssistantChangedFilesSectionInner({...@@ -2117,30 +2116,21 @@ function AssistantChangedFilesSectionInner({
2117 resolvedTheme: "light" | "dark";2116 resolvedTheme: "light" | "dark";
2118 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;2117 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
2119}) {2118}) {
2120 const activity = use(TimelineRowActivityCtx);
2121 const isLatestTurn = activity.latestTurnId === turnSummary.turnId;
2122 const persistedExpanded = useUiStateStore(2119 const persistedExpanded = useUiStateStore(
2123 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId],2120 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId],
2124 );2121 );
2125 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);2122 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
2126 const [autoExpanded] = useState(() =>2123 const allDirectoriesExpanded = persistedExpanded ?? false;
2127 shouldAutoExpandChangedFiles(checkpointFiles, isLatestTurn),
2128 );
2129 const [allDirectoriesExpanded, setAllDirectoriesExpanded] = useState(autoExpanded);
2130 const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded);
21312124
2132 return (2125 return (
2133 <ChangedFilesCard2126 <ChangedFilesCard
2134 turnId={turnSummary.turnId}2127 turnId={turnSummary.turnId}
2135 files={checkpointFiles}2128 files={checkpointFiles}
2136 expanded={expanded}
2137 showCompactPreview={isLatestTurn}
2138 allDirectoriesExpanded={allDirectoriesExpanded}2129 allDirectoriesExpanded={allDirectoriesExpanded}
2139 resolvedTheme={resolvedTheme}2130 resolvedTheme={resolvedTheme}
2140 onExpandedChange={(nextExpanded) =>2131 onToggleAllDirectories={() =>
2141 setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded)2132 setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
2142 }2133 }
2143 onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)}
2144 onOpenTurnDiff={onOpenTurnDiff}2134 onOpenTurnDiff={onOpenTurnDiff}
2145 />2135 />
2146 );2136 );
apps/web/src/uiStateStore.test.ts+4-3
...@@ -159,7 +159,7 @@ describe("parsePersistedState", () => {...@@ -159,7 +159,7 @@ describe("parsePersistedState", () => {
159 invalid: "not-a-date",159 invalid: "not-a-date",
160 },160 },
161 defaultAdvertisedEndpointKey: "desktop-core:lan:http",161 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
162 threadChangedFilesExpansionVersion: 1,162 threadChangedFilesExpansionVersion: 2,
163 threadChangedFilesExpandedById: {163 threadChangedFilesExpandedById: {
164 "environment:thread-1": {164 "environment:thread-1": {
165 "turn-1": false,165 "turn-1": false,
...@@ -186,8 +186,9 @@ describe("parsePersistedState", () => {...@@ -186,8 +186,9 @@ describe("parsePersistedState", () => {
186 });186 });
187 });187 });
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) => {
190 const parsed = parsePersistedState({190 const parsed = parsePersistedState({
191 ...(version === undefined ? {} : { threadChangedFilesExpansionVersion: version }),
191 threadChangedFilesExpandedById: {192 threadChangedFilesExpandedById: {
192 "environment:thread-1": {193 "environment:thread-1": {
193 "turn-1": false,194 "turn-1": false,
...@@ -296,7 +297,7 @@ describe("uiStateStore persistence", () => {...@@ -296,7 +297,7 @@ describe("uiStateStore persistence", () => {
296 "environment:thread-1": "2026-02-25T12:35:00.000Z",297 "environment:thread-1": "2026-02-25T12:35:00.000Z",
297 },298 },
298 defaultAdvertisedEndpointKey: "desktop-core:lan:http",299 defaultAdvertisedEndpointKey: "desktop-core:lan:http",
299 threadChangedFilesExpansionVersion: 1,300 threadChangedFilesExpansionVersion: 2,
300 threadChangedFilesExpandedById: {301 threadChangedFilesExpandedById: {
301 "environment:thread-1": {302 "environment:thread-1": {
302 "turn-1": false,303 "turn-1": false,
apps/web/src/uiStateStore.ts+3-2
...@@ -3,7 +3,8 @@ import { create } from "zustand";...@@ -3,7 +3,8 @@ 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;6// Version 1 stored card visibility, not folder expansion.
7const THREAD_CHANGED_FILES_EXPANSION_VERSION = 2;
7const LEGACY_PERSISTED_STATE_KEYS = [8const LEGACY_PERSISTED_STATE_KEYS = [
8 "t3code:renderer-state:v8",9 "t3code:renderer-state:v8",
9 "t3code:renderer-state:v7",10 "t3code:renderer-state:v7",
...@@ -25,7 +26,7 @@ export interface PersistedUiState {...@@ -25,7 +26,7 @@ export interface PersistedUiState {
25 expandedProjectCwds?: string[];26 expandedProjectCwds?: string[];
26 projectOrderCwds?: string[];27 projectOrderCwds?: string[];
27 defaultAdvertisedEndpointKey?: string | null;28 defaultAdvertisedEndpointKey?: string | null;
28 threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION;29 threadChangedFilesExpansionVersion?: number;
29 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;30 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
30}31}
3132