authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 13:44:35-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 15:56:40-08:00
logd6ed393255c206008897529e8c006a40a9199a32
treebdd30c8831879c748d13528442027342329dd9a2
parent2d9139767a131d7e0165e36facf2c71d01f13e63

Render changed files as expandable tree with aggregated diff stats

- Replace flat changed-files chips in ChatView with a nested, expandable tree - Extract diff tree/stat logic into turnDiffTree utilities - Add unit tests for stat aggregation, nesting, and Windows path normalization

3 files changed, 415 insertions(+), 53 deletions(-)

apps/web/src/components/ChatView.tsx+138-53
......@@ -63,11 +63,17 @@ import {
6363 MAX_THREAD_TERMINAL_COUNT,
6464 type ChatMessage,
6565 type Thread,
66 type TurnDiffFileChange,
6667 type TurnDiffSummary,
6768} from "../types";
6869import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
6970import { useTheme } from "../hooks/useTheme";
7071import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
72import {
73 buildTurnDiffTree,
74 summarizeTurnDiffStats,
75 type TurnDiffTreeNode,
76} from "../lib/turnDiffTree";
7177import BranchToolbar from "./BranchToolbar";
7278import GitActionsControl from "./GitActionsControl";
7379import {
......@@ -3127,6 +3133,126 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri
31273133 );
31283134});
31293135
3136function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean {
3137 return stat.additions > 0 || stat.deletions > 0;
3138}
3139
3140const DiffStatLabel = memo(function DiffStatLabel(props: {
3141 additions: number;
3142 deletions: number;
3143 showParentheses?: boolean;
3144}) {
3145 const { additions, deletions, showParentheses = false } = props;
3146 return (
3147 <>
3148 {showParentheses && <span className="text-muted-foreground/70">(</span>}
3149 <span className="text-success">+{additions}</span>
3150 <span className="mx-0.5 text-muted-foreground/70">/</span>
3151 <span className="text-destructive">-{deletions}</span>
3152 {showParentheses && <span className="text-muted-foreground/70">)</span>}
3153 </>
3154 );
3155});
3156
3157function buildInitiallyExpandedDirectoryState(
3158 nodes: ReadonlyArray<TurnDiffTreeNode>,
3159): Record<string, boolean> {
3160 const expandedState: Record<string, boolean> = {};
3161 for (const node of nodes) {
3162 if (node.kind === "directory") {
3163 expandedState[node.path] = true;
3164 }
3165 }
3166 return expandedState;
3167}
3168
3169const ChangedFilesTree = memo(function ChangedFilesTree(props: {
3170 turnId: TurnId;
3171 files: ReadonlyArray<TurnDiffFileChange>;
3172 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
3173}) {
3174 const { files, onOpenTurnDiff, turnId } = props;
3175 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);
3176 const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(
3177 () => buildInitiallyExpandedDirectoryState(treeNodes),
3178 );
3179
3180 const toggleDirectory = useCallback((pathValue: string) => {
3181 setExpandedDirectories((current) => ({
3182 ...current,
3183 [pathValue]: !current[pathValue],
3184 }));
3185 }, []);
3186
3187 const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => {
3188 const leftPadding = 8 + depth * 14;
3189 if (node.kind === "directory") {
3190 const isExpanded = expandedDirectories[node.path] ?? depth === 0;
3191 return (
3192 <div key={`dir:${node.path}`}>
3193 <button
3194 type="button"
3195 className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"
3196 style={{ paddingLeft: `${leftPadding}px` }}
3197 onClick={() => toggleDirectory(node.path)}
3198 >
3199 <ChevronRightIcon
3200 aria-hidden="true"
3201 className={cn(
3202 "size-3.5 shrink-0 text-muted-foreground/70 transition-transform group-hover:text-foreground/80",
3203 isExpanded && "rotate-90",
3204 )}
3205 />
3206 {isExpanded ? (
3207 <FolderIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
3208 ) : (
3209 <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
3210 )}
3211 <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">
3212 {node.name}
3213 </span>
3214 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
3215 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
3216 </span>
3217 </button>
3218 {isExpanded && (
3219 <div className="space-y-0.5">
3220 {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}
3221 </div>
3222 )}
3223 </div>
3224 );
3225 }
3226
3227 return (
3228 <button
3229 key={`file:${node.path}`}
3230 type="button"
3231 className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"
3232 style={{ paddingLeft: `${leftPadding}px` }}
3233 onClick={() => onOpenTurnDiff(turnId, node.path)}
3234 >
3235 <span aria-hidden="true" className="size-3.5 shrink-0" />
3236 <FileIcon className="size-3.5 shrink-0 text-muted-foreground/70" />
3237 <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">
3238 {node.name}
3239 </span>
3240 {node.stat && (
3241 <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
3242 <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
3243 </span>
3244 )}
3245 </button>
3246 );
3247 };
3248
3249 return (
3250 <div className="space-y-0.5">
3251 {treeNodes.map((node) => renderTreeNode(node, 0))}
3252 </div>
3253 );
3254});
3255
31303256interface MessagesTimelineProps {
31313257 hasMessages: boolean;
31323258 isWorking: boolean;
......@@ -3531,33 +3657,20 @@ const MessagesTimeline = memo(function MessagesTimeline({
35313657 if (!turnSummary) return null;
35323658 const checkpointFiles = turnSummary.files;
35333659 if (checkpointFiles.length === 0) return null;
3534 const summaryStat = checkpointFiles.reduce(
3535 (acc, file) => {
3536 if (
3537 typeof file.additions !== "number" ||
3538 typeof file.deletions !== "number"
3539 ) {
3540 return acc;
3541 }
3542 return {
3543 additions: acc.additions + file.additions,
3544 deletions: acc.deletions + file.deletions,
3545 };
3546 },
3547 { additions: 0, deletions: 0 },
3548 );
3660 const summaryStat = summarizeTurnDiffStats(checkpointFiles);
35493661 const changedFileCountLabel = String(checkpointFiles.length);
35503662 return (
35513663 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
35523664 <div className="mb-1.5 flex items-center justify-between gap-2">
35533665 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
35543666 <span>Changed files ({changedFileCountLabel})</span>
3555 {(summaryStat.additions > 0 || summaryStat.deletions > 0) && (
3667 {hasNonZeroStat(summaryStat) && (
35563668 <>
35573669 <span className="mx-1">•</span>
3558 <span className="text-success">+{summaryStat.additions}</span>
3559 <span className="mx-0.5 text-muted-foreground/70">/</span>
3560 <span className="text-destructive">-{summaryStat.deletions}</span>
3670 <DiffStatLabel
3671 additions={summaryStat.additions}
3672 deletions={summaryStat.deletions}
3673 />
35613674 </>
35623675 )}
35633676 </p>
......@@ -3572,40 +3685,12 @@ const MessagesTimeline = memo(function MessagesTimeline({
35723685 View diff
35733686 </Button>
35743687 </div>
3575 <div className="flex flex-wrap gap-1.5">
3576 {checkpointFiles.map((file) => (
3577 <button
3578 key={`${turnSummary.turnId}:${file.path}`}
3579 type="button"
3580 className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
3581 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
3582 >
3583 {(() => {
3584 const stat =
3585 typeof file.additions === "number" &&
3586 typeof file.deletions === "number"
3587 ? {
3588 additions: file.additions,
3589 deletions: file.deletions,
3590 }
3591 : null;
3592 if (!stat) {
3593 return file.path;
3594 }
3595 return (
3596 <>
3597 <span>{file.path}</span>
3598 <span className="ml-1 text-muted-foreground/70">(</span>
3599 <span className="text-success">+{stat.additions}</span>
3600 <span className="mx-0.5 text-muted-foreground/70">/</span>
3601 <span className="text-destructive">-{stat.deletions}</span>
3602 <span className="text-muted-foreground/70">)</span>
3603 </>
3604 );
3605 })()}
3606 </button>
3607 ))}
3608 </div>
3688 <ChangedFilesTree
3689 key={`changed-files-tree:${turnSummary.turnId}`}
3690 turnId={turnSummary.turnId}
3691 files={checkpointFiles}
3692 onOpenTurnDiff={onOpenTurnDiff}
3693 />
36093694 </div>
36103695 );
36113696 })()}
apps/web/src/lib/turnDiffTree.test.ts created+129
......@@ -0,0 +1,129 @@
1import { describe, expect, it } from "vitest";
2
3import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree";
4
5describe("summarizeTurnDiffStats", () => {
6 it("sums only files with numeric additions/deletions", () => {
7 const stat = summarizeTurnDiffStats([
8 { path: "README.md", additions: 3, deletions: 1 },
9 { path: "docs/notes.md" },
10 { path: "src/index.ts", additions: 5, deletions: 2 },
11 ]);
12
13 expect(stat).toEqual({ additions: 8, deletions: 3 });
14 });
15});
16
17describe("buildTurnDiffTree", () => {
18 it("builds nested directory nodes with aggregated stats", () => {
19 const tree = buildTurnDiffTree([
20 { path: "src/index.ts", additions: 2, deletions: 1 },
21 { path: "src/components/Button.tsx", additions: 4, deletions: 2 },
22 { path: "README.md", additions: 1, deletions: 0 },
23 ]);
24
25 expect(tree).toEqual([
26 {
27 kind: "directory",
28 name: "src",
29 path: "src",
30 stat: { additions: 6, deletions: 3 },
31 children: [
32 {
33 kind: "directory",
34 name: "components",
35 path: "src/components",
36 stat: { additions: 4, deletions: 2 },
37 children: [
38 {
39 kind: "file",
40 name: "Button.tsx",
41 path: "src/components/Button.tsx",
42 stat: { additions: 4, deletions: 2 },
43 },
44 ],
45 },
46 {
47 kind: "file",
48 name: "index.ts",
49 path: "src/index.ts",
50 stat: { additions: 2, deletions: 1 },
51 },
52 ],
53 },
54 {
55 kind: "file",
56 name: "README.md",
57 path: "README.md",
58 stat: { additions: 1, deletions: 0 },
59 },
60 ]);
61 });
62
63 it("keeps files without stat values and excludes them from directory totals", () => {
64 const tree = buildTurnDiffTree([
65 { path: "docs/notes.md" },
66 { path: "docs/todo.md", additions: 1, deletions: 1 },
67 ]);
68
69 expect(tree).toEqual([
70 {
71 kind: "directory",
72 name: "docs",
73 path: "docs",
74 stat: { additions: 1, deletions: 1 },
75 children: [
76 {
77 kind: "file",
78 name: "notes.md",
79 path: "docs/notes.md",
80 stat: null,
81 },
82 {
83 kind: "file",
84 name: "todo.md",
85 path: "docs/todo.md",
86 stat: { additions: 1, deletions: 1 },
87 },
88 ],
89 },
90 ]);
91 });
92
93 it("normalizes file paths with windows separators", () => {
94 const tree = buildTurnDiffTree([{ path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }]);
95
96 expect(tree).toEqual([
97 {
98 kind: "directory",
99 name: "apps",
100 path: "apps",
101 stat: { additions: 2, deletions: 1 },
102 children: [
103 {
104 kind: "directory",
105 name: "web",
106 path: "apps/web",
107 stat: { additions: 2, deletions: 1 },
108 children: [
109 {
110 kind: "directory",
111 name: "src",
112 path: "apps/web/src",
113 stat: { additions: 2, deletions: 1 },
114 children: [
115 {
116 kind: "file",
117 name: "index.ts",
118 path: "apps/web/src/index.ts",
119 stat: { additions: 2, deletions: 1 },
120 },
121 ],
122 },
123 ],
124 },
125 ],
126 },
127 ]);
128 });
129});
apps/web/src/lib/turnDiffTree.ts created+148
......@@ -0,0 +1,148 @@
1import type { TurnDiffFileChange } from "../types";
2
3export interface TurnDiffStat {
4 additions: number;
5 deletions: number;
6}
7
8export interface TurnDiffTreeDirectoryNode {
9 kind: "directory";
10 name: string;
11 path: string;
12 stat: TurnDiffStat;
13 children: TurnDiffTreeNode[];
14}
15
16export interface TurnDiffTreeFileNode {
17 kind: "file";
18 name: string;
19 path: string;
20 stat: TurnDiffStat | null;
21}
22
23export type TurnDiffTreeNode = TurnDiffTreeDirectoryNode | TurnDiffTreeFileNode;
24
25interface MutableDirectoryNode {
26 name: string;
27 path: string;
28 stat: TurnDiffStat;
29 directories: Map<string, MutableDirectoryNode>;
30 files: TurnDiffTreeFileNode[];
31}
32
33const SORT_LOCALE_OPTIONS: Intl.CollatorOptions = { numeric: true, sensitivity: "base" };
34
35function normalizePathSegments(pathValue: string): string[] {
36 return pathValue
37 .replaceAll("\\", "/")
38 .split("/")
39 .map((segment) => segment.trim())
40 .filter((segment) => segment.length > 0);
41}
42
43function compareByName(a: { name: string }, b: { name: string }): number {
44 return a.name.localeCompare(b.name, undefined, SORT_LOCALE_OPTIONS);
45}
46
47function readStat(file: TurnDiffFileChange): TurnDiffStat | null {
48 if (typeof file.additions !== "number" || typeof file.deletions !== "number") {
49 return null;
50 }
51 return {
52 additions: file.additions,
53 deletions: file.deletions,
54 };
55}
56
57function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] {
58 const subdirectories: TurnDiffTreeDirectoryNode[] = Array.from(directory.directories.values())
59 .toSorted(compareByName)
60 .map((subdirectory) => ({
61 kind: "directory",
62 name: subdirectory.name,
63 path: subdirectory.path,
64 stat: {
65 additions: subdirectory.stat.additions,
66 deletions: subdirectory.stat.deletions,
67 },
68 children: toTreeNodes(subdirectory),
69 }));
70
71 const files = directory.files.toSorted(compareByName);
72 return [...subdirectories, ...files];
73}
74
75export function summarizeTurnDiffStats(files: ReadonlyArray<TurnDiffFileChange>): TurnDiffStat {
76 return files.reduce(
77 (acc, file) => {
78 const stat = readStat(file);
79 if (!stat) return acc;
80 return {
81 additions: acc.additions + stat.additions,
82 deletions: acc.deletions + stat.deletions,
83 };
84 },
85 { additions: 0, deletions: 0 },
86 );
87}
88
89export function buildTurnDiffTree(files: ReadonlyArray<TurnDiffFileChange>): TurnDiffTreeNode[] {
90 const root: MutableDirectoryNode = {
91 name: "",
92 path: "",
93 stat: { additions: 0, deletions: 0 },
94 directories: new Map(),
95 files: [],
96 };
97
98 for (const file of files) {
99 const segments = normalizePathSegments(file.path);
100 if (segments.length === 0) {
101 continue;
102 }
103
104 const filePath = segments.join("/");
105 const fileName = segments.at(-1);
106 if (!fileName) {
107 continue;
108 }
109 const stat = readStat(file);
110 const ancestors: MutableDirectoryNode[] = [root];
111 let currentDirectory = root;
112
113 for (const segment of segments.slice(0, -1)) {
114 const nextPath = currentDirectory.path ? `${currentDirectory.path}/${segment}` : segment;
115 const existing = currentDirectory.directories.get(segment);
116 if (existing) {
117 currentDirectory = existing;
118 } else {
119 const created: MutableDirectoryNode = {
120 name: segment,
121 path: nextPath,
122 stat: { additions: 0, deletions: 0 },
123 directories: new Map(),
124 files: [],
125 };
126 currentDirectory.directories.set(segment, created);
127 currentDirectory = created;
128 }
129 ancestors.push(currentDirectory);
130 }
131
132 currentDirectory.files.push({
133 kind: "file",
134 name: fileName,
135 path: filePath,
136 stat,
137 });
138
139 if (stat) {
140 for (const ancestor of ancestors) {
141 ancestor.stat.additions += stat.additions;
142 ancestor.stat.deletions += stat.deletions;
143 }
144 }
145 }
146
147 return toTreeNodes(root);
148}