newpr 1.0.21 → 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.
@@ -6,6 +6,7 @@ export interface VerifyInput {
6
6
  head_sha: string;
7
7
  exec_result: StackExecResult;
8
8
  ownership: Map<string, string>;
9
+ group_deps?: Map<string, string[]>;
9
10
  }
10
11
 
11
12
  export interface VerifyResult {
@@ -16,7 +17,7 @@ export interface VerifyResult {
16
17
  }
17
18
 
18
19
  export async function verifyStack(input: VerifyInput): Promise<VerifyResult> {
19
- const { repo_path, base_sha, head_sha, exec_result, ownership } = input;
20
+ const { repo_path, base_sha, head_sha, exec_result, ownership, group_deps } = input;
20
21
  const errors: string[] = [];
21
22
  const warnings: string[] = [];
22
23
  const structuredWarnings: StackWarning[] = [];
@@ -25,7 +26,7 @@ export async function verifyStack(input: VerifyInput): Promise<VerifyResult> {
25
26
  const unionMissing: string[] = [];
26
27
  const unionExtra: string[] = [];
27
28
 
28
- await verifyPerGroupDiffScope(repo_path, base_sha, exec_result, ownership, warnings, scopeLeaks);
29
+ await verifyPerGroupDiffScope(repo_path, base_sha, exec_result, ownership, group_deps ?? new Map(), warnings, scopeLeaks);
29
30
  await verifyUnionCompleteness(repo_path, base_sha, head_sha, exec_result, warnings, unionMissing, unionExtra);
30
31
  await verifyFinalTreeEquivalence(repo_path, head_sha, exec_result, errors);
31
32
 
@@ -70,17 +71,26 @@ async function verifyPerGroupDiffScope(
70
71
  baseSha: string,
71
72
  execResult: StackExecResult,
72
73
  ownership: Map<string, string>,
74
+ groupDeps: Map<string, string[]>,
73
75
  warnings: string[],
74
76
  scopeLeaks: string[],
75
77
  ): Promise<void> {
76
- let prevCommitSha = baseSha;
78
+ const commitByGroupId = new Map<string, string>();
79
+ for (const gc of execResult.group_commits) {
80
+ commitByGroupId.set(gc.group_id, gc.commit_sha);
81
+ }
77
82
 
78
83
  for (const gc of execResult.group_commits) {
79
- const diffResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${prevCommitSha} ${gc.commit_sha}`.quiet().nothrow();
84
+ const parentTree = await resolveScopeParentTree(repoPath, baseSha, gc.group_id, groupDeps, commitByGroupId);
85
+ if (!parentTree) {
86
+ warnings.push(`Failed to resolve parent tree for group "${gc.group_id}"`);
87
+ continue;
88
+ }
89
+
90
+ const diffResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${parentTree} ${gc.tree_sha}`.quiet().nothrow();
80
91
 
81
92
  if (diffResult.exitCode !== 0) {
82
93
  warnings.push(`Failed to diff group "${gc.group_id}": ${diffResult.stderr.toString().trim()}`);
83
- prevCommitSha = gc.commit_sha;
84
94
  continue;
85
95
  }
86
96
 
@@ -89,16 +99,62 @@ async function verifyPerGroupDiffScope(
89
99
  for (const path of changedPaths) {
90
100
  const fileOwner = ownership.get(path);
91
101
  if (fileOwner !== gc.group_id) {
92
- const detail = `"${path}" in "${gc.group_id}" diff, owned by "${fileOwner ?? "unassigned"}"`;
93
102
  warnings.push(
94
103
  `Group "${gc.group_id}" diff contains file "${path}" owned by "${fileOwner ?? "unassigned"}"`,
95
104
  );
96
- scopeLeaks.push(detail);
105
+ scopeLeaks.push(`"${path}" in "${gc.group_id}" diff, owned by "${fileOwner ?? "unassigned"}"`);
97
106
  }
98
107
  }
108
+ }
109
+ }
110
+
111
+ async function resolveScopeParentTree(
112
+ repoPath: string,
113
+ baseSha: string,
114
+ groupId: string,
115
+ groupDeps: Map<string, string[]>,
116
+ commitByGroupId: Map<string, string>,
117
+ ): Promise<string | null> {
118
+ const deps = groupDeps.get(groupId) ?? [];
119
+ if (deps.length === 0) {
120
+ return resolveTreeForRef(repoPath, baseSha);
121
+ }
122
+
123
+ const parentCommits: string[] = [];
124
+ for (const dep of deps) {
125
+ const depCommit = commitByGroupId.get(dep);
126
+ if (!depCommit) continue;
127
+ parentCommits.push(depCommit);
128
+ }
129
+
130
+ if (parentCommits.length === 0) return resolveTreeForRef(repoPath, baseSha);
131
+ if (parentCommits.length === 1) return resolveTreeForRef(repoPath, parentCommits[0]!);
132
+
133
+ let mergedCommit = parentCommits[0]!;
134
+ let mergedTree: string | null = null;
135
+ for (let i = 1; i < parentCommits.length; i++) {
136
+ const nextParentCommit = parentCommits[i]!;
137
+ const mergeResult = await Bun.$`git -C ${repoPath} merge-tree --write-tree --allow-unrelated-histories ${mergedCommit} ${nextParentCommit}`.quiet().nothrow();
138
+ if (mergeResult.exitCode !== 0) return null;
139
+ const nextTree = mergeResult.stdout.toString().trim().split("\n")[0]?.trim();
140
+ if (!nextTree) return null;
141
+ mergedTree = nextTree;
99
142
 
100
- prevCommitSha = gc.commit_sha;
143
+ const mergedCommitResult = await Bun.$`git -C ${repoPath} commit-tree ${mergedTree} -p ${mergedCommit} -p ${nextParentCommit} -m "newpr synthetic verify merged parent"`.quiet().nothrow();
144
+ if (mergedCommitResult.exitCode !== 0) return null;
145
+ mergedCommit = mergedCommitResult.stdout.toString().trim();
146
+ if (!mergedCommit) return null;
101
147
  }
148
+
149
+ if (!mergedTree) return resolveTreeForRef(repoPath, mergedCommit);
150
+ return mergedTree;
151
+ }
152
+
153
+ async function resolveTreeForRef(repoPath: string, ref: string): Promise<string | null> {
154
+ const result = await Bun.$`git -C ${repoPath} rev-parse ${ref}^{tree}`.quiet().nothrow();
155
+ if (result.exitCode !== 0) return null;
156
+ const tree = result.stdout.toString().trim();
157
+ return tree.length > 0 ? tree : null;
102
158
  }
103
159
 
104
160
  async function verifyUnionCompleteness(
@@ -119,18 +175,19 @@ async function verifyUnionCompleteness(
119
175
 
120
176
  const expectedPaths = new Set(extractPathsFromRawDiff(expectedResult.stdout));
121
177
 
122
- const actualPaths = new Set<string>();
123
- let prevSha = baseSha;
124
- for (const gc of execResult.group_commits) {
125
- const diffResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z --no-commit-id ${prevSha} ${gc.commit_sha}`.quiet().nothrow();
126
-
127
- if (diffResult.exitCode === 0) {
128
- for (const path of extractPathsFromRawDiff(diffResult.stdout)) {
129
- actualPaths.add(path);
130
- }
131
- }
132
- prevSha = gc.commit_sha;
178
+ const baseTreeResult = await Bun.$`git -C ${repoPath} rev-parse ${baseSha}^{tree}`.quiet().nothrow();
179
+ if (baseTreeResult.exitCode !== 0) {
180
+ warnings.push(`Failed to get base tree: ${baseTreeResult.stderr.toString().trim()}`);
181
+ return;
133
182
  }
183
+ const baseTree = baseTreeResult.stdout.toString().trim();
184
+ const finalTree = execResult.final_tree_sha;
185
+ if (!finalTree) return;
186
+
187
+ const actualResult = await Bun.$`git -C ${repoPath} diff-tree -r --raw -z ${baseTree} ${finalTree}`.quiet().nothrow();
188
+ const actualPaths = actualResult.exitCode === 0
189
+ ? new Set(extractPathsFromRawDiff(actualResult.stdout))
190
+ : new Set<string>();
134
191
 
135
192
  for (const path of expectedPaths) {
136
193
  if (!actualPaths.has(path)) {
@@ -14,8 +14,7 @@ import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
14
14
  import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
15
15
  import type { AnchorItem } from "./components/TipTapEditor.tsx";
16
16
  import { requestNotificationPermission } from "./lib/notify.ts";
17
- import { analytics, initAnalytics, getConsent } from "./lib/analytics.ts";
18
- import { AnalyticsConsent } from "./components/AnalyticsConsent.tsx";
17
+ import { analytics, initAnalytics } from "./lib/analytics.ts";
19
18
 
20
19
  function getUrlParam(key: string): string | null {
21
20
  return new URLSearchParams(window.location.search).get(key);
@@ -41,7 +40,7 @@ export function App() {
41
40
  const features = useFeatures();
42
41
  const bgAnalyses = useBackgroundAnalyses();
43
42
  const initialLoadDone = useRef(false);
44
- const [showConsent, setShowConsent] = useState(() => getConsent() === "pending");
43
+
45
44
 
46
45
  useEffect(() => {
47
46
  requestNotificationPermission();
@@ -160,7 +159,6 @@ export function App() {
160
159
 
161
160
  return (
162
161
  <ChatProvider state={chatState} anchorItems={anchorItems} analyzedAt={analysis.result?.meta.analyzed_at}>
163
- {showConsent && <AnalyticsConsent onDone={() => setShowConsent(false)} />}
164
162
  <AppShell
165
163
  theme={themeCtx.theme}
166
164
  onThemeChange={themeCtx.setTheme}
@@ -1,98 +1 @@
1
- import { useState } from "react";
2
- import { BarChart3, Shield } from "lucide-react";
3
- import { getConsent, setConsent, type ConsentState } from "../lib/analytics.ts";
4
-
5
- export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
6
- const [state] = useState<ConsentState>(() => getConsent());
7
-
8
- if (state !== "pending") return null;
9
-
10
- const syncServer = (consent: "granted" | "denied") => {
11
- fetch("/api/config", {
12
- method: "PUT",
13
- headers: { "Content-Type": "application/json" },
14
- body: JSON.stringify({ telemetry_consent: consent }),
15
- }).catch(() => {});
16
- };
17
-
18
- const handleAccept = () => {
19
- setConsent("granted");
20
- syncServer("granted");
21
- onDone();
22
- };
23
-
24
- const handleDecline = () => {
25
- setConsent("denied");
26
- syncServer("denied");
27
- onDone();
28
- };
29
-
30
- return (
31
- <div className="fixed inset-0 z-[100] flex items-center justify-center">
32
- <div className="fixed inset-0 bg-background/70 backdrop-blur-sm" />
33
- <div className="relative z-10 w-full max-w-md mx-4 rounded-2xl border bg-background shadow-2xl overflow-hidden">
34
- <div className="px-6 pt-6 pb-4">
35
- <div className="flex items-center gap-3 mb-4">
36
- <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/10">
37
- <BarChart3 className="h-5 w-5 text-blue-500" />
38
- </div>
39
- <div>
40
- <h2 className="text-base font-semibold">Help improve newpr</h2>
41
- <p className="text-xs text-muted-foreground">Anonymous usage analytics</p>
42
- </div>
43
- </div>
44
-
45
- <p className="text-sm text-muted-foreground leading-relaxed mb-3">
46
- We'd like to collect anonymous usage data to understand how newpr is used and improve the experience.
47
- </p>
48
-
49
- <div className="rounded-lg bg-muted/40 px-3.5 py-2.5 space-y-1.5 mb-4">
50
- <div className="flex items-start gap-2">
51
- <Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
52
- <div className="text-xs text-muted-foreground leading-relaxed">
53
- <p className="font-medium text-foreground/80 mb-1">What we collect:</p>
54
- <ul className="space-y-0.5 list-disc list-inside text-xs">
55
- <li>Feature usage (which tabs, buttons, and actions you use)</li>
56
- <li>Performance metrics (analysis duration, error rates)</li>
57
- <li>Basic device info (browser, screen size)</li>
58
- </ul>
59
- </div>
60
- </div>
61
- <div className="flex items-start gap-2 pt-1">
62
- <Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
63
- <div className="text-xs text-muted-foreground leading-relaxed">
64
- <p className="font-medium text-foreground/80 mb-1">What we never collect:</p>
65
- <ul className="space-y-0.5 list-disc list-inside text-xs">
66
- <li>PR content, code, or commit messages</li>
67
- <li>Chat messages or review comments</li>
68
- <li>API keys, tokens, or personal data</li>
69
- </ul>
70
- </div>
71
- </div>
72
- </div>
73
-
74
- <p className="text-xs text-muted-foreground/50 mb-4">
75
- Powered by Google Analytics. You can change this anytime in Settings.
76
- </p>
77
- </div>
78
-
79
- <div className="flex border-t">
80
- <button
81
- type="button"
82
- onClick={handleDecline}
83
- className="flex-1 px-4 py-3 text-sm text-muted-foreground hover:bg-muted/50 transition-colors"
84
- >
85
- Decline
86
- </button>
87
- <button
88
- type="button"
89
- onClick={handleAccept}
90
- className="flex-1 px-4 py-3 text-sm font-medium bg-foreground text-background hover:opacity-90 transition-opacity"
91
- >
92
- Accept
93
- </button>
94
- </div>
95
- </div>
96
- </div>
97
- );
98
- }
1
+ export {};
@@ -389,11 +389,11 @@ export function AppShell({
389
389
  <ResizeHandle onResize={handleLeftResize} side="right" />
390
390
 
391
391
  <div className="flex-1 flex flex-col overflow-hidden relative" style={{ minWidth: 400 }}>
392
- <main ref={mainRef} className="flex-1 overflow-y-auto">
393
- <div className="mx-auto max-w-5xl px-10 py-10">
394
- {children}
395
- </div>
396
- </main>
392
+ <main ref={mainRef} className="flex-1 overflow-y-auto">
393
+ <div className="mx-auto max-w-5xl px-10 pt-10 pb-24">
394
+ {children}
395
+ </div>
396
+ </main>
397
397
  {bottomBar}
398
398
  {showScrollTop && (
399
399
  <button
@@ -0,0 +1,387 @@
1
+ import { ChevronRight, GitBranch, ExternalLink, Plus, Minus, GitMerge } from "lucide-react";
2
+ import { useState, useRef, useLayoutEffect } from "react";
3
+ import type { StackGroupStats } from "../../../stack/types.ts";
4
+
5
+ const TYPE_COLORS: Record<string, { dot: string; badge: string; text: string }> = {
6
+ feature: { dot: "bg-blue-500", badge: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
7
+ refactor: { dot: "bg-purple-500", badge: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
8
+ bugfix: { dot: "bg-red-500", badge: "bg-red-500/10", text: "text-red-600 dark:text-red-400" },
9
+ chore: { dot: "bg-neutral-400", badge: "bg-neutral-500/10", text: "text-neutral-500" },
10
+ docs: { dot: "bg-teal-500", badge: "bg-teal-500/10", text: "text-teal-600 dark:text-teal-400" },
11
+ test: { dot: "bg-yellow-500", badge: "bg-yellow-500/10", text: "text-yellow-600 dark:text-yellow-400" },
12
+ config: { dot: "bg-orange-500", badge: "bg-orange-500/10", text: "text-orange-600 dark:text-orange-400" },
13
+ };
14
+
15
+ function formatStat(n: number): string {
16
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
17
+ return String(n);
18
+ }
19
+
20
+ function normalizeFilePath(path: string): string {
21
+ return path
22
+ .replace(/\\/g, "/")
23
+ .replace(/^\.\//, "")
24
+ .replace(/^a\//, "")
25
+ .replace(/^b\//, "");
26
+ }
27
+
28
+ export interface DagGroup {
29
+ id: string;
30
+ name: string;
31
+ type: string;
32
+ description: string;
33
+ files: string[];
34
+ deps: string[];
35
+ explicit_deps?: string[];
36
+ order: number;
37
+ stats?: StackGroupStats;
38
+ file_stats?: Record<string, { additions: number; deletions: number }>;
39
+ pr_title?: string;
40
+ }
41
+
42
+ interface DagCommit {
43
+ group_id: string;
44
+ commit_sha: string;
45
+ branch_name: string;
46
+ }
47
+
48
+ interface DagPr {
49
+ group_id: string;
50
+ number: number;
51
+ url: string;
52
+ title: string;
53
+ }
54
+
55
+ interface DagNode {
56
+ group: DagGroup;
57
+ indent: number;
58
+ isLastChild: boolean;
59
+ parentId: string | null;
60
+ childIds: string[];
61
+ }
62
+
63
+ const INDENT = 18;
64
+ const DOT_CX = 8;
65
+ const DOT_RADIUS = 2.5;
66
+ const ROW_HEIGHT = 36;
67
+
68
+ function buildDagNodes(groups: DagGroup[]): DagNode[] {
69
+ const byId = new Map(groups.map((g) => [g.id, g]));
70
+
71
+ const childrenOf = new Map<string, string[]>();
72
+ for (const g of groups) {
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);
78
+ }
79
+ }
80
+
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);
85
+ }
86
+ }
87
+
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));
113
+
114
+ for (const g of groups) {
115
+ if (!visited.has(g.id)) {
116
+ result.push({ group: g, indent: 0, isLastChild: true, parentId: null, childIds: childrenOf.get(g.id) ?? [] });
117
+ }
118
+ }
119
+
120
+ return result;
121
+ }
122
+
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;
132
+ });
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
+ }
152
+
153
+ function DagNodeCard({
154
+ node,
155
+ commit,
156
+ pr,
157
+ fileStatsByPath,
158
+ rowRef,
159
+ }: {
160
+ node: DagNode;
161
+ commit?: DagCommit;
162
+ pr?: DagPr;
163
+ fileStatsByPath?: Record<string, { additions: number; deletions: number }>;
164
+ allGroups?: DagGroup[];
165
+ rowRef: (el: HTMLDivElement | null) => void;
166
+ }) {
167
+ const [expanded, setExpanded] = useState(false);
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;
199
+ const colors = TYPE_COLORS[group.type] ?? TYPE_COLORS.chore!;
200
+ const leftPad = node.indent * INDENT + DOT_CX * 2 + 4;
201
+
202
+ return (
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()}
240
+ </span>
241
+ <span className="flex items-center gap-0.5">
242
+ <Minus className="h-2 w-2 text-red-500/50" />
243
+ {stats.deletions.toLocaleString()}
244
+ </span>
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`}
250
+ </span>
251
+ )}
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
+ })}
280
+ </div>
281
+ </div>
282
+ )}
283
+ </div>
284
+ );
285
+ }
286
+
287
+ export function StackDagView({
288
+ groups,
289
+ groupCommits,
290
+ publishedPrs,
291
+ fileStatsByPath,
292
+ }: {
293
+ groups: DagGroup[];
294
+ groupCommits?: DagCommit[];
295
+ publishedPrs?: DagPr[];
296
+ fileStatsByPath?: Record<string, { additions: number; deletions: number }>;
297
+ }) {
298
+ const nodes = buildDagNodes(groups);
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
+ });
335
+
336
+ return (
337
+ <div className="relative" ref={containerRef}>
338
+ {!isLinear && (
339
+ <div className="flex items-center gap-1.5 mb-1 px-1">
340
+ <GitMerge className="h-3 w-3 text-muted-foreground/25" />
341
+ <span className="text-[9px] text-muted-foreground/25 uppercase tracking-wider">DAG</span>
342
+ </div>
343
+ )}
344
+
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) => {
370
+ const commit = groupCommits?.find((c) => c.group_id === node.group.id);
371
+ const pr = publishedPrs?.find((p) => p.group_id === node.group.id);
372
+ return (
373
+ <DagNodeCard
374
+ key={node.group.id}
375
+ node={node}
376
+ commit={commit}
377
+ pr={pr}
378
+ fileStatsByPath={fileStatsByPath}
379
+ allGroups={groups}
380
+ rowRef={(el) => { rowRefs.current[i] = el; }}
381
+ />
382
+ );
383
+ })}
384
+ </div>
385
+ </div>
386
+ );
387
+ }