newpr 1.0.22 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { ChevronRight, GitBranch, ExternalLink, Plus, Minus, GitMerge } from "lucide-react";
2
- import { useState } from "react";
2
+ import { useState, useRef, useLayoutEffect } from "react";
3
3
  import type { StackGroupStats } from "../../../stack/types.ts";
4
4
 
5
5
  const TYPE_COLORS: Record<string, { dot: string; badge: string; text: string }> = {
@@ -17,6 +17,14 @@ function formatStat(n: number): string {
17
17
  return String(n);
18
18
  }
19
19
 
20
+ function normalizeFilePath(path: string): string {
21
+ return path
22
+ .replace(/\\/g, "/")
23
+ .replace(/^\.\//, "")
24
+ .replace(/^a\//, "")
25
+ .replace(/^b\//, "");
26
+ }
27
+
20
28
  export interface DagGroup {
21
29
  id: string;
22
30
  name: string;
@@ -24,8 +32,10 @@ export interface DagGroup {
24
32
  description: string;
25
33
  files: string[];
26
34
  deps: string[];
35
+ explicit_deps?: string[];
27
36
  order: number;
28
37
  stats?: StackGroupStats;
38
+ file_stats?: Record<string, { additions: number; deletions: number }>;
29
39
  pr_title?: string;
30
40
  }
31
41
 
@@ -44,227 +54,232 @@ interface DagPr {
44
54
 
45
55
  interface DagNode {
46
56
  group: DagGroup;
47
- level: number;
48
- isLastAtLevel: boolean;
49
- parentIds: string[];
57
+ indent: number;
58
+ isLastChild: boolean;
59
+ parentId: string | null;
50
60
  childIds: string[];
51
61
  }
52
62
 
63
+ const INDENT = 18;
64
+ const DOT_CX = 8;
65
+ const DOT_RADIUS = 2.5;
66
+ const ROW_HEIGHT = 36;
67
+
53
68
  function buildDagNodes(groups: DagGroup[]): DagNode[] {
54
69
  const byId = new Map(groups.map((g) => [g.id, g]));
55
70
 
56
- const levels = new Map<string, number>();
57
- const inDegree = new Map(groups.map((g) => [g.id, 0]));
71
+ const childrenOf = new Map<string, string[]>();
58
72
  for (const g of groups) {
59
- for (const dep of (g.deps ?? [])) {
60
- if (byId.has(dep)) inDegree.set(g.id, (inDegree.get(g.id) ?? 0) + 1);
73
+ for (const dep of (g.explicit_deps ?? g.deps ?? [])) {
74
+ if (!byId.has(dep)) continue;
75
+ const arr = childrenOf.get(dep) ?? [];
76
+ arr.push(g.id);
77
+ childrenOf.set(dep, arr);
61
78
  }
62
79
  }
63
80
 
64
- const queue = groups.filter((g) => (inDegree.get(g.id) ?? 0) === 0).map((g) => g.id);
65
- for (const id of queue) levels.set(id, 0);
66
-
67
- while (queue.length > 0) {
68
- const id = queue.shift()!;
69
- const level = levels.get(id) ?? 0;
70
- for (const g of groups) {
71
- if ((g.deps ?? []).includes(id)) {
72
- const newLevel = Math.max(levels.get(g.id) ?? 0, level + 1);
73
- levels.set(g.id, newLevel);
74
- const remaining = (inDegree.get(g.id) ?? 1) - 1;
75
- inDegree.set(g.id, remaining);
76
- if (remaining === 0) queue.push(g.id);
77
- }
81
+ const hasIncomingEdge = new Set<string>();
82
+ for (const g of groups) {
83
+ for (const dep of (g.explicit_deps ?? g.deps ?? [])) {
84
+ if (byId.has(dep)) hasIncomingEdge.add(g.id);
78
85
  }
79
86
  }
80
87
 
81
- const levelCount = new Map<number, number>();
82
- const levelSeen = new Map<number, number>();
83
- for (const [, l] of levels) levelCount.set(l, (levelCount.get(l) ?? 0) + 1);
88
+ const roots = groups.filter((g) => !hasIncomingEdge.has(g.id)).sort((a, b) => a.order - b.order);
89
+ const result: DagNode[] = [];
90
+ const visited = new Set<string>();
91
+
92
+ const dfs = (id: string, indent: number, parentId: string | null, siblingIndex: number, siblingCount: number) => {
93
+ if (visited.has(id)) return;
94
+ visited.add(id);
95
+
96
+ const g = byId.get(id)!;
97
+ const children = (childrenOf.get(id) ?? []).sort((a, b) => {
98
+ return (byId.get(a)?.order ?? 0) - (byId.get(b)?.order ?? 0);
99
+ });
100
+
101
+ result.push({
102
+ group: g,
103
+ indent,
104
+ isLastChild: siblingIndex === siblingCount - 1,
105
+ parentId,
106
+ childIds: children,
107
+ });
108
+
109
+ children.forEach((childId, i) => dfs(childId, indent + 1, id, i, children.length));
110
+ };
111
+
112
+ roots.forEach((root, i) => dfs(root.id, 0, null, i, roots.length));
84
113
 
85
- const childrenOf = new Map<string, string[]>();
86
114
  for (const g of groups) {
87
- for (const dep of (g.deps ?? [])) {
88
- const arr = childrenOf.get(dep) ?? [];
89
- arr.push(g.id);
90
- childrenOf.set(dep, arr);
115
+ if (!visited.has(g.id)) {
116
+ result.push({ group: g, indent: 0, isLastChild: true, parentId: null, childIds: childrenOf.get(g.id) ?? [] });
91
117
  }
92
118
  }
93
119
 
94
- const sorted = [...groups].sort((a, b) => {
95
- const la = levels.get(a.id) ?? 0;
96
- const lb = levels.get(b.id) ?? 0;
97
- if (la !== lb) return la - lb;
98
- return a.order - b.order;
99
- });
120
+ return result;
121
+ }
100
122
 
101
- return sorted.map((g) => {
102
- const level = levels.get(g.id) ?? 0;
103
- const seenAtLevel = levelSeen.get(level) ?? 0;
104
- const countAtLevel = levelCount.get(level) ?? 1;
105
- levelSeen.set(level, seenAtLevel + 1);
106
- return {
107
- group: g,
108
- level,
109
- isLastAtLevel: seenAtLevel === countAtLevel - 1,
110
- parentIds: (g.deps ?? []).filter((d) => byId.has(d)),
111
- childIds: childrenOf.get(g.id) ?? [],
112
- };
123
+ const BUTTON_HEIGHT = 36;
124
+ const DOT_TOP_OFFSET = BUTTON_HEIGHT / 2;
125
+
126
+ function buildPaths(nodes: DagNode[], rowHeights: number[]): string[] {
127
+ let cumulativeY = 0;
128
+ const dotY = nodes.map((_, i) => {
129
+ const y = cumulativeY + DOT_TOP_OFFSET;
130
+ cumulativeY += rowHeights[i] ?? ROW_HEIGHT;
131
+ return y;
113
132
  });
114
- }
115
133
 
134
+ const idToIndex = new Map(nodes.map((n, i) => [n.group.id, i]));
135
+ const paths: string[] = [];
136
+
137
+ for (let i = 0; i < nodes.length; i++) {
138
+ const node = nodes[i]!;
139
+ const parentIdx = node.parentId !== null ? idToIndex.get(node.parentId) : undefined;
140
+ if (parentIdx === undefined) continue;
141
+
142
+ const parentX = (node.indent - 1) * INDENT + DOT_CX;
143
+ const childX = node.indent * INDENT + DOT_CX;
144
+ const parentY = dotY[parentIdx]!;
145
+ const childY = dotY[i]!;
146
+
147
+ paths.push(`M ${parentX} ${parentY + DOT_RADIUS} L ${parentX} ${childY} L ${childX - DOT_RADIUS} ${childY}`);
148
+ }
149
+
150
+ return paths;
151
+ }
116
152
 
117
153
  function DagNodeCard({
118
154
  node,
119
155
  commit,
120
156
  pr,
121
- allGroups,
157
+ fileStatsByPath,
158
+ rowRef,
122
159
  }: {
123
160
  node: DagNode;
124
161
  commit?: DagCommit;
125
162
  pr?: DagPr;
126
- allGroups: DagGroup[];
163
+ fileStatsByPath?: Record<string, { additions: number; deletions: number }>;
164
+ allGroups?: DagGroup[];
165
+ rowRef: (el: HTMLDivElement | null) => void;
127
166
  }) {
128
167
  const [expanded, setExpanded] = useState(false);
129
- const { group, level } = node;
130
- const stats = group.stats;
168
+ const { group } = node;
169
+ const resolveFileStats = (file: string): { additions: number; deletions: number } => {
170
+ const normalizedPath = normalizeFilePath(file);
171
+ const fallbackStats = fileStatsByPath?.[file]
172
+ ?? fileStatsByPath?.[normalizedPath]
173
+ ?? { additions: 0, deletions: 0 };
174
+ const fromGroup = group.file_stats?.[file]
175
+ ?? group.file_stats?.[normalizedPath];
176
+ if (fromGroup && (fromGroup.additions > 0 || fromGroup.deletions > 0)) return fromGroup;
177
+ if (fallbackStats.additions > 0 || fallbackStats.deletions > 0) return fallbackStats;
178
+ return fromGroup ?? fallbackStats;
179
+ };
180
+
181
+ const fileRows = group.files.map((file) => ({
182
+ path: file,
183
+ stats: resolveFileStats(file),
184
+ }));
185
+ const fileTotals = fileRows.reduce(
186
+ (acc, row) => ({ additions: acc.additions + row.stats.additions, deletions: acc.deletions + row.stats.deletions }),
187
+ { additions: 0, deletions: 0 },
188
+ );
189
+ const hasFileTotals = fileRows.some((row) => row.stats.additions > 0 || row.stats.deletions > 0);
190
+ const stats = hasFileTotals
191
+ ? {
192
+ additions: fileTotals.additions,
193
+ deletions: fileTotals.deletions,
194
+ files_added: group.stats?.files_added ?? 0,
195
+ files_modified: group.stats?.files_modified ?? 0,
196
+ files_deleted: group.stats?.files_deleted ?? 0,
197
+ }
198
+ : group.stats;
131
199
  const colors = TYPE_COLORS[group.type] ?? TYPE_COLORS.chore!;
132
-
133
- const depNames = (group.deps ?? []).map((depId) => {
134
- const found = allGroups.find((g) => g.id === depId);
135
- return found?.pr_title ?? found?.name ?? depId;
136
- });
137
-
138
- const isParallel = node.parentIds.length === 0
139
- ? false
140
- : allGroups.filter((g) => {
141
- const gDeps = g.deps ?? [];
142
- return node.parentIds.every((p) => gDeps.includes(p)) && g.id !== group.id;
143
- }).length > 0;
200
+ const leftPad = node.indent * INDENT + DOT_CX * 2 + 4;
144
201
 
145
202
  return (
146
- <div className="relative" style={{ marginLeft: `${level * 20}px` }}>
147
- {level > 0 && (
148
- <div
149
- className="absolute top-[18px] h-px bg-border/30"
150
- style={{ left: `-${20 - 9}px`, width: `${20 - 9}px` }}
151
- />
152
- )}
153
-
154
- <div className="group/card relative">
155
- <button
156
- type="button"
157
- onClick={() => setExpanded(!expanded)}
158
- className="w-full flex items-start gap-2 px-2.5 py-2 text-left hover:bg-accent/20 transition-colors rounded-md"
159
- >
160
- <div className="flex-shrink-0 mt-[5px] flex flex-col items-center gap-[3px]">
161
- <div className={`h-2 w-2 rounded-full ${colors.dot} ring-2 ring-background`} />
162
- {node.childIds.length > 1 && (
163
- <GitMerge className="h-2.5 w-2.5 text-muted-foreground/25 mt-0.5" />
164
- )}
165
- </div>
166
-
167
- <div className="flex-1 min-w-0">
168
- <div className="flex items-center gap-1.5 flex-wrap">
169
- <span className={`text-[9px] font-medium px-1.5 py-px rounded ${colors.badge} ${colors.text} shrink-0`}>
170
- {group.type}
203
+ <div ref={rowRef}>
204
+ <button
205
+ type="button"
206
+ onClick={() => setExpanded(!expanded)}
207
+ className="w-full flex items-center gap-2 py-1.5 text-left hover:bg-accent/20 transition-colors rounded-md pr-2"
208
+ style={{ paddingLeft: leftPad }}
209
+ >
210
+ <span className={`text-[9px] font-medium px-1.5 py-px rounded ${colors.badge} ${colors.text} shrink-0 leading-none`}>
211
+ {group.type}
212
+ </span>
213
+ <span className="text-[11.5px] font-medium text-foreground/90 flex-1 min-w-0 truncate">
214
+ {group.pr_title ?? group.name}
215
+ </span>
216
+ <span className="shrink-0 flex items-center gap-1.5">
217
+ {stats ? (
218
+ <>
219
+ <span className="text-[10px] text-green-600/60 dark:text-green-400/60 tabular-nums">+{formatStat(stats.additions)}</span>
220
+ <span className="text-[10px] text-red-500/60 tabular-nums">−{formatStat(stats.deletions)}</span>
221
+ </>
222
+ ) : (
223
+ <span className="text-[9px] text-muted-foreground/20 tabular-nums">{group.files.length}f</span>
224
+ )}
225
+ <ChevronRight className={`h-3 w-3 text-muted-foreground/20 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
226
+ </span>
227
+ </button>
228
+
229
+ {expanded && (
230
+ <div className="pb-2 space-y-2" style={{ paddingLeft: leftPad }}>
231
+ {group.description && (
232
+ <p className="text-[10.5px] text-muted-foreground/45 leading-[1.55]">{group.description}</p>
233
+ )}
234
+ {stats && (
235
+ <div className="flex items-center gap-3 text-[9.5px] text-muted-foreground/30 tabular-nums">
236
+ <span>{group.files.length} files</span>
237
+ <span className="flex items-center gap-0.5">
238
+ <Plus className="h-2 w-2 text-green-600/50 dark:text-green-400/50" />
239
+ {stats.additions.toLocaleString()}
171
240
  </span>
172
- {isParallel && (
173
- <span className="text-[9px] text-muted-foreground/25 shrink-0">∥</span>
174
- )}
175
- <span className="text-[11.5px] font-medium text-foreground/90 truncate">
176
- {group.pr_title ?? group.name}
241
+ <span className="flex items-center gap-0.5">
242
+ <Minus className="h-2 w-2 text-red-500/50" />
243
+ {stats.deletions.toLocaleString()}
177
244
  </span>
178
- </div>
179
-
180
- {depNames.length > 0 && (
181
- <div className="flex items-center gap-1 mt-0.5 flex-wrap">
182
- <span className="text-[9px] text-muted-foreground/20">after</span>
183
- {depNames.map((name, i) => (
184
- <span key={i} className="text-[9px] text-muted-foreground/25 font-mono truncate max-w-[120px]">
185
- {name}
186
- </span>
187
- ))}
188
- </div>
189
- )}
190
- </div>
191
-
192
- <div className="flex-shrink-0 flex items-center gap-2 mt-[3px]">
193
- {stats ? (
194
- <span className="flex items-center gap-1.5">
195
- <span className="text-[10px] text-green-600/60 dark:text-green-400/60 tabular-nums">
196
- +{formatStat(stats.additions)}
197
- </span>
198
- <span className="text-[10px] text-red-500/60 tabular-nums">
199
- −{formatStat(stats.deletions)}
200
- </span>
201
- </span>
202
- ) : (
203
- <span className="text-[9px] text-muted-foreground/20 tabular-nums">{group.files.length}f</span>
204
- )}
205
- <ChevronRight className={`h-3 w-3 text-muted-foreground/20 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`} />
206
- </div>
207
- </button>
208
-
209
- {expanded && (
210
- <div className="ml-4 pl-3 pb-3 pt-1 space-y-2 border-l border-border/30">
211
- {group.description && (
212
- <p className="text-[10.5px] text-muted-foreground/45 leading-[1.55]">{group.description}</p>
213
- )}
214
-
215
- {stats && (
216
- <div className="flex items-center gap-3 text-[9.5px] text-muted-foreground/30 tabular-nums">
217
- <span>{group.files.length} files</span>
218
- <span className="flex items-center gap-0.5">
219
- <Plus className="h-2 w-2 text-green-600/50 dark:text-green-400/50" />
220
- {stats.additions.toLocaleString()}
221
- </span>
222
- <span className="flex items-center gap-0.5">
223
- <Minus className="h-2 w-2 text-red-500/50" />
224
- {stats.deletions.toLocaleString()}
245
+ {(stats.files_added > 0 || stats.files_modified > 0 || stats.files_deleted > 0) && (
246
+ <span>
247
+ {stats.files_added > 0 && `${stats.files_added}A `}
248
+ {stats.files_modified > 0 && `${stats.files_modified}M `}
249
+ {stats.files_deleted > 0 && `${stats.files_deleted}D`}
225
250
  </span>
226
- {(stats.files_added > 0 || stats.files_modified > 0 || stats.files_deleted > 0) && (
227
- <span>
228
- {stats.files_added > 0 && `${stats.files_added}A `}
229
- {stats.files_modified > 0 && `${stats.files_modified}M `}
230
- {stats.files_deleted > 0 && `${stats.files_deleted}D`}
231
- </span>
232
- )}
233
- </div>
234
- )}
235
-
236
- {commit && (
237
- <div className="flex items-center gap-1.5 text-[9.5px] text-muted-foreground/25">
238
- <GitBranch className="h-2.5 w-2.5 shrink-0" />
239
- <span className="font-mono truncate">{commit.branch_name}</span>
240
- <span className="text-muted-foreground/15">·</span>
241
- <span className="font-mono shrink-0">{commit.commit_sha.slice(0, 7)}</span>
242
- </div>
243
- )}
244
-
245
- {pr && (
246
- <a
247
- href={pr.url}
248
- target="_blank"
249
- rel="noopener noreferrer"
250
- className="inline-flex items-center gap-1 text-[9.5px] text-foreground/40 hover:text-foreground/70 transition-colors"
251
- >
252
- <ExternalLink className="h-2.5 w-2.5 shrink-0" />
253
- <span className="tabular-nums">#{pr.number}</span>
254
- <span className="truncate max-w-[180px]">{pr.title}</span>
255
- </a>
256
- )}
257
-
258
- <div className="space-y-0 pt-0.5">
259
- {group.files.map((file) => (
260
- <div key={file} className="text-[9px] font-mono text-muted-foreground/20 py-[1px] truncate">
261
- {file}
262
- </div>
263
- ))}
251
+ )}
264
252
  </div>
253
+ )}
254
+ {commit && (
255
+ <div className="flex items-center gap-1.5 text-[9.5px] text-muted-foreground/25">
256
+ <GitBranch className="h-2.5 w-2.5 shrink-0" />
257
+ <span className="font-mono truncate">{commit.branch_name}</span>
258
+ <span className="text-muted-foreground/15">·</span>
259
+ <span className="font-mono shrink-0">{commit.commit_sha.slice(0, 7)}</span>
260
+ </div>
261
+ )}
262
+ {pr && (
263
+ <a href={pr.url} target="_blank" rel="noopener noreferrer"
264
+ className="inline-flex items-center gap-1 text-[9.5px] text-foreground/40 hover:text-foreground/70 transition-colors">
265
+ <ExternalLink className="h-2.5 w-2.5 shrink-0" />
266
+ <span className="tabular-nums">#{pr.number}</span>
267
+ <span className="truncate max-w-[180px]">{pr.title}</span>
268
+ </a>
269
+ )}
270
+ <div className="space-y-0 pt-0.5">
271
+ {fileRows.map(({ path, stats: fileStats }) => {
272
+ return (
273
+ <div key={path} className="flex items-center gap-2 text-[9px] font-mono text-muted-foreground/20 py-[1px]">
274
+ <span className="truncate flex-1 min-w-0">{path}</span>
275
+ <span className="tabular-nums text-green-600/60 dark:text-green-400/60 shrink-0">+{fileStats.additions}</span>
276
+ <span className="tabular-nums text-red-500/60 shrink-0">-{fileStats.deletions}</span>
277
+ </div>
278
+ );
279
+ })}
265
280
  </div>
266
- )}
267
- </div>
281
+ </div>
282
+ )}
268
283
  </div>
269
284
  );
270
285
  }
@@ -273,25 +288,85 @@ export function StackDagView({
273
288
  groups,
274
289
  groupCommits,
275
290
  publishedPrs,
291
+ fileStatsByPath,
276
292
  }: {
277
293
  groups: DagGroup[];
278
294
  groupCommits?: DagCommit[];
279
295
  publishedPrs?: DagPr[];
296
+ fileStatsByPath?: Record<string, { additions: number; deletions: number }>;
280
297
  }) {
281
298
  const nodes = buildDagNodes(groups);
282
- const isLinear = nodes.every((n) => n.level === n.group.order);
299
+ const isLinear = nodes.every((n) => n.indent === 0);
300
+ const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
301
+ const [rowHeights, setRowHeights] = useState<number[]>([]);
302
+ const containerRef = useRef<HTMLDivElement>(null);
303
+ const [svgHeight, setSvgHeight] = useState(0);
304
+ const maxIndent = Math.max(...nodes.map((n) => n.indent), 0);
305
+ const svgWidth = (maxIndent + 1) * INDENT + DOT_CX * 2;
306
+
307
+ useLayoutEffect(() => {
308
+ const measure = () => {
309
+ const heights = rowRefs.current.map((el) => el?.getBoundingClientRect().height ?? ROW_HEIGHT);
310
+ const total = heights.reduce((a, b) => a + b, 0);
311
+ setRowHeights((prev) => {
312
+ if (prev.length === heights.length && prev.every((h, i) => h === heights[i])) return prev;
313
+ return heights;
314
+ });
315
+ setSvgHeight((prev) => prev === total ? prev : total);
316
+ };
317
+
318
+ measure();
319
+
320
+ const ro = new ResizeObserver(measure);
321
+ const container = containerRef.current;
322
+ if (container) ro.observe(container);
323
+ return () => ro.disconnect();
324
+ }, [nodes.length]);
325
+
326
+ const paths = rowHeights.length === nodes.length ? buildPaths(nodes, rowHeights) : [];
327
+
328
+ let cumulativeY = 0;
329
+ const dotPositions = nodes.map((node, i) => {
330
+ const h = rowHeights[i] ?? ROW_HEIGHT;
331
+ const y = cumulativeY + DOT_TOP_OFFSET;
332
+ cumulativeY += h;
333
+ return { y, cx: node.indent * INDENT + DOT_CX, node };
334
+ });
283
335
 
284
336
  return (
285
- <div className="relative">
337
+ <div className="relative" ref={containerRef}>
286
338
  {!isLinear && (
287
- <div className="flex items-center gap-1.5 mb-2 px-1">
339
+ <div className="flex items-center gap-1.5 mb-1 px-1">
288
340
  <GitMerge className="h-3 w-3 text-muted-foreground/25" />
289
341
  <span className="text-[9px] text-muted-foreground/25 uppercase tracking-wider">DAG</span>
290
342
  </div>
291
343
  )}
292
344
 
293
- <div className="relative space-y-0.5">
294
- {nodes.map((node) => {
345
+ <div className="relative">
346
+ {svgHeight > 0 && (
347
+ <svg
348
+ className="absolute top-0 left-0 pointer-events-none overflow-visible"
349
+ width={svgWidth}
350
+ height={svgHeight}
351
+ >
352
+ {paths.map((d, i) => (
353
+ <path key={i} d={d}
354
+ stroke="currentColor" strokeOpacity="0.35" strokeWidth="1.5"
355
+ fill="none" strokeLinejoin="round"
356
+ className="text-border" />
357
+ ))}
358
+ {dotPositions.map(({ y, cx, node }) => {
359
+ const colors = TYPE_COLORS[node.group.type] ?? TYPE_COLORS.chore!;
360
+ const colorClass = colors.dot;
361
+ return (
362
+ <circle key={node.group.id} cx={cx} cy={y} r={DOT_RADIUS}
363
+ className={colorClass} fill="currentColor" fillOpacity="0.7" />
364
+ );
365
+ })}
366
+ </svg>
367
+ )}
368
+
369
+ {nodes.map((node, i) => {
295
370
  const commit = groupCommits?.find((c) => c.group_id === node.group.id);
296
371
  const pr = publishedPrs?.find((p) => p.group_id === node.group.id);
297
372
  return (
@@ -300,17 +375,12 @@ export function StackDagView({
300
375
  node={node}
301
376
  commit={commit}
302
377
  pr={pr}
378
+ fileStatsByPath={fileStatsByPath}
303
379
  allGroups={groups}
380
+ rowRef={(el) => { rowRefs.current[i] = el; }}
304
381
  />
305
382
  );
306
383
  })}
307
-
308
- {nodes.length > 0 && (
309
- <div
310
- className="absolute top-[14px] bottom-[14px] w-px bg-border/15 pointer-events-none"
311
- style={{ left: "8px" }}
312
- />
313
- )}
314
384
  </div>
315
385
  </div>
316
386
  );
@@ -43,11 +43,22 @@ interface PlanData {
43
43
  deps: string[];
44
44
  order: number;
45
45
  stats?: StackGroupStats;
46
+ file_stats?: Record<string, { additions: number; deletions: number }>;
46
47
  pr_title?: string;
47
48
  }>;
48
49
  expected_trees: Record<string, string>;
49
50
  }
50
51
 
52
+ interface SessionFileStat {
53
+ path: string;
54
+ additions: number;
55
+ deletions: number;
56
+ }
57
+
58
+ interface SessionDataForStats {
59
+ files?: SessionFileStat[];
60
+ }
61
+
51
62
  interface ExecResultData {
52
63
  run_id: string;
53
64
  source_copy_branch: string;
@@ -142,6 +153,26 @@ export interface StackState {
142
153
  publishCleanupLoading: boolean;
143
154
  publishCleanupError: string | null;
144
155
  progressMessage: string | null;
156
+ analysisFileStatsByPath: Record<string, { additions: number; deletions: number }>;
157
+ }
158
+
159
+ function normalizeFilePath(path: string): string {
160
+ return path
161
+ .replace(/\\/g, "/")
162
+ .replace(/^\.\//, "")
163
+ .replace(/^a\//, "")
164
+ .replace(/^b\//, "");
165
+ }
166
+
167
+ function buildAnalysisFileStatsByPath(files: SessionFileStat[] | undefined): Record<string, { additions: number; deletions: number }> {
168
+ const map: Record<string, { additions: number; deletions: number }> = {};
169
+ for (const file of files ?? []) {
170
+ const stats = { additions: file.additions, deletions: file.deletions };
171
+ map[file.path] = stats;
172
+ const normalized = normalizeFilePath(file.path);
173
+ if (!map[normalized]) map[normalized] = stats;
174
+ }
175
+ return map;
145
176
  }
146
177
 
147
178
  function serverPhaseToClient(status: string, phase: string | null): StackPhase {
@@ -191,6 +222,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
191
222
  publishCleanupLoading: false,
192
223
  publishCleanupError: null,
193
224
  progressMessage: null,
225
+ analysisFileStatsByPath: {},
194
226
  });
195
227
 
196
228
  const eventSourceRef = useRef<EventSource | null>(null);
@@ -205,6 +237,16 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
205
237
  setState((s) => ({ ...s, ...applyServerState(data.state!) }));
206
238
  })
207
239
  .catch(() => {});
240
+
241
+ fetch(`/api/sessions/${sessionId}`)
242
+ .then((res) => res.json())
243
+ .then((data: SessionDataForStats) => {
244
+ setState((s) => ({
245
+ ...s,
246
+ analysisFileStatsByPath: buildAnalysisFileStatsByPath(data.files),
247
+ }));
248
+ })
249
+ .catch(() => {});
208
250
  }, [sessionId]);
209
251
 
210
252
  const setMaxGroups = useCallback((n: number | null) => {
@@ -479,6 +521,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
479
521
  publishCleanupLoading: false,
480
522
  publishCleanupError: null,
481
523
  progressMessage: null,
524
+ analysisFileStatsByPath: s.analysisFileStatsByPath,
482
525
  }));
483
526
  }, []);
484
527
 
@@ -232,6 +232,7 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
232
232
  groups={stack.plan.groups}
233
233
  groupCommits={stack.execResult?.group_commits}
234
234
  publishedPrs={stack.publishResult?.prs}
235
+ fileStatsByPath={stack.analysisFileStatsByPath}
235
236
  />
236
237
  </div>
237
238
  )}
@@ -17,7 +17,7 @@ import { createResilientLlmClient } from "../../llm/resilient-client.ts";
17
17
  import { detectAgents, runAgent } from "../../workspace/agent.ts";
18
18
  import { randomBytes } from "node:crypto";
19
19
  import { publishStack, buildStackPublishPreview } from "../../stack/publish.ts";
20
- import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks, setStackPublishResult, setStackPublishPreview, setStackPublishCleanupResult } from "./stack-manager.ts";
20
+ import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks, setStackPublishResult, setStackPublishPreview, setStackPublishCleanupResult, recomputeStackPlanStatsIfNeeded } from "./stack-manager.ts";
21
21
  import { getTelemetryConsent, setTelemetryConsent, telemetry } from "../../telemetry/index.ts";
22
22
 
23
23
  function json(data: unknown, status = 200): Response {
@@ -1820,6 +1820,10 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
1820
1820
  await restoreCompletedStacks([id]);
1821
1821
  state = getStackState(id);
1822
1822
  }
1823
+ if (state?.plan && state.context) {
1824
+ await recomputeStackPlanStatsIfNeeded(id);
1825
+ state = getStackState(id);
1826
+ }
1823
1827
  if (!state) return json({ state: null });
1824
1828
  return json({ state });
1825
1829
  },