@fungi.computer/bonsai 0.0.0-stage → 0.0.1

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.
Files changed (132) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -2
  4. package/backend/background.ts +259 -0
  5. package/backend/change-copy.ts +99 -0
  6. package/backend/changes.ts +530 -0
  7. package/backend/diff.ts +228 -0
  8. package/backend/file-edits.ts +850 -0
  9. package/backend/files-copy.ts +361 -0
  10. package/backend/gills.ts +125 -0
  11. package/backend/git.ts +123 -0
  12. package/backend/graph-commit-copies.ts +321 -0
  13. package/backend/history.ts +178 -0
  14. package/backend/index.ts +144 -0
  15. package/backend/insights.ts +256 -0
  16. package/backend/merge.ts +999 -0
  17. package/backend/methods.ts +835 -0
  18. package/backend/policy.ts +160 -0
  19. package/backend/read-copy-page.ts +77 -0
  20. package/backend/repository-copy.ts +70 -0
  21. package/backend/reviews.ts +317 -0
  22. package/backend/service.ts +892 -0
  23. package/backend/state.ts +914 -0
  24. package/demo/koi-pond/README.md +17 -0
  25. package/demo/koi-pond/index.html +11 -0
  26. package/demo/koi-pond/package.json +13 -0
  27. package/demo/koi-pond/pnpm-lock.yaml +450 -0
  28. package/demo/koi-pond/src/fish.ts +32 -0
  29. package/demo/koi-pond/src/main.ts +24 -0
  30. package/demo/koi-pond/src/pond.ts +27 -0
  31. package/docs/media/bonsai-insights.png +0 -0
  32. package/docs/media/overview-phone.png +0 -0
  33. package/docs/media/overview.png +0 -0
  34. package/package.json +152 -3
  35. package/scripts/agent.sh +55 -0
  36. package/scripts/demo.sh +233 -0
  37. package/scripts/rebuild-copies.mjs +34 -0
  38. package/scripts/release.mjs +52 -0
  39. package/scripts/runtime-proof.mjs +299 -0
  40. package/scripts/seed-demo.mjs +724 -0
  41. package/scripts/seed-step.mjs +37 -0
  42. package/scripts/shed-terminal-proof.mjs +774 -0
  43. package/shared/contract.ts +404 -0
  44. package/shared/hooks.ts +208 -0
  45. package/shared/insights.ts +90 -0
  46. package/shared/picker-options.ts +23 -0
  47. package/shared/presentation.ts +21 -0
  48. package/shared/repository-deletion.ts +4 -0
  49. package/test/apps-build-install.test.ts +302 -0
  50. package/test/background-runtime.test.ts +206 -0
  51. package/test/browser/board.browser.mjs +52 -0
  52. package/test/browser/capture.browser.mjs +1264 -0
  53. package/test/browser/edit-in-inkcap.browser.mjs +83 -0
  54. package/test/browser/repository-settings-fixture.tsx +108 -0
  55. package/test/browser/repository-settings.browser.mjs +136 -0
  56. package/test/browser/repository-settings.html +10 -0
  57. package/test/browser/visual-fixture.tsx +667 -0
  58. package/test/browser/visual.html +10 -0
  59. package/test/browser/vite.config.mjs +9 -0
  60. package/test/change-page.test.ts +916 -0
  61. package/test/changes.test.ts +1063 -0
  62. package/test/checks-workflow-template.test.mjs +234 -0
  63. package/test/diff.test.ts +70 -0
  64. package/test/edit-in-inkcap.test.ts +227 -0
  65. package/test/file-edits.test.ts +2714 -0
  66. package/test/files-read-copy-calls.test.ts +374 -0
  67. package/test/fork-adoption.test.ts +111 -0
  68. package/test/gills.test.ts +125 -0
  69. package/test/git-binding.fixture.ts +30 -0
  70. package/test/git-push.config.jsonc +9 -0
  71. package/test/git-push.native.mjs +413 -0
  72. package/test/git-push.worker.ts +280 -0
  73. package/test/graph-commit-read-copy-calls.test.ts +401 -0
  74. package/test/home-page.test.ts +170 -0
  75. package/test/hooks.test.ts +250 -0
  76. package/test/insight-data.test.ts +51 -0
  77. package/test/insight-layout.test.mjs +230 -0
  78. package/test/insights.test.ts +154 -0
  79. package/test/merge-background.test.ts +756 -0
  80. package/test/merge-conflict.test.ts +337 -0
  81. package/test/merge-declaration.test.ts +46 -0
  82. package/test/merge-repro.test.ts +561 -0
  83. package/test/merge-template-build.test.mjs +234 -0
  84. package/test/network-layout.test.ts +200 -0
  85. package/test/outbox.test.ts +424 -0
  86. package/test/parts.test.ts +86 -0
  87. package/test/pkt-line.test.ts +54 -0
  88. package/test/policy.test.ts +172 -0
  89. package/test/read-copy-calls.test.ts +691 -0
  90. package/test/read-copy-composer.test.ts +388 -0
  91. package/test/read-copy-lifetimes.test.ts +214 -0
  92. package/test/read-copy-rpc.config.jsonc +12 -0
  93. package/test/read-copy-rpc.native.mjs +47 -0
  94. package/test/read-copy-rpc.worker.ts +92 -0
  95. package/test/recent-activity.test.ts +62 -0
  96. package/test/repository-deletion.test.ts +500 -0
  97. package/test/seed-resume.test.mjs +91 -0
  98. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  99. package/test/storyboard-save-template.test.mjs +545 -0
  100. package/test/ui-errors.test.ts +976 -0
  101. package/test/workflow-store.test.ts +115 -0
  102. package/tsconfig.json +36 -0
  103. package/turbo.jsonc +25 -0
  104. package/vite.config.ts +19 -0
  105. package/vitest.config.ts +5 -0
  106. package/web/api.ts +224 -0
  107. package/web/app-build-panel.tsx +171 -0
  108. package/web/board.tsx +259 -0
  109. package/web/bonsai.css +642 -0
  110. package/web/change-page.tsx +715 -0
  111. package/web/change-rows.tsx +135 -0
  112. package/web/code-tab.tsx +267 -0
  113. package/web/commit-tree.tsx +152 -0
  114. package/web/format.ts +54 -0
  115. package/web/home-page.tsx +274 -0
  116. package/web/index.html +17 -0
  117. package/web/insight-data.ts +76 -0
  118. package/web/insight-tiles.tsx +534 -0
  119. package/web/insights.tsx +250 -0
  120. package/web/issues-panel.tsx +305 -0
  121. package/web/main.tsx +72 -0
  122. package/web/network-graph.tsx +647 -0
  123. package/web/overview.tsx +339 -0
  124. package/web/pages.tsx +469 -0
  125. package/web/parts.tsx +381 -0
  126. package/web/public/favicon.svg +1 -0
  127. package/web/repo-navigation.tsx +256 -0
  128. package/web/repo-settings.tsx +145 -0
  129. package/web/router.tsx +151 -0
  130. package/web/transport.ts +91 -0
  131. package/web/views.tsx +151 -0
  132. package/web/working-now.tsx +98 -0
@@ -0,0 +1,534 @@
1
+ import {
2
+ ScreenCells,
3
+ ScreenCell,
4
+ } from "@fungi.computer/caps/components/surface";
5
+ import { useLayoutEffect, useMemo, useState } from "react";
6
+ import { useQuery } from "@tanstack/react-query";
7
+ import { AreaStack } from "@visx/shape";
8
+ import { curveMonotoneX } from "@visx/curve";
9
+ import { scaleLinear } from "@visx/scale";
10
+ import { hierarchy, Treemap } from "@visx/hierarchy";
11
+ import type { Agent, Graph } from "../shared/contract.js";
12
+ import {
13
+ codeInsights,
14
+ ageBandLabels,
15
+ type CodeInsights,
16
+ } from "../shared/insights.js";
17
+ import {
18
+ actorSplit,
19
+ contributionDays,
20
+ contributionWeeks,
21
+ } from "./insight-data.js";
22
+ import { agentColor } from "./format.js";
23
+ import { callMethod } from "./transport.js";
24
+ import { Failure, Loading } from "./parts.js";
25
+
26
+ const kindLabel = { agent: "Agent", human: "Person", unknown: "Unattributed" };
27
+ const kinds = ["agent", "human", "unknown"] as const;
28
+
29
+ /** Match the viewBox to the display width so fixed-height charts never letterbox. */
30
+ function useChartWidth() {
31
+ const [element, setElement] = useState<SVGSVGElement | null>(null);
32
+ const [width, setWidth] = useState(440);
33
+ useLayoutEffect(() => {
34
+ if (!element) return;
35
+ setWidth(element.getBoundingClientRect().width);
36
+ const observer = new ResizeObserver(([entry]) => {
37
+ if (entry) setWidth(entry.contentRect.width);
38
+ });
39
+ observer.observe(element);
40
+ return () => {
41
+ observer.disconnect();
42
+ };
43
+ }, [element]);
44
+ return { ref: setElement, width };
45
+ }
46
+
47
+ function Contributors({ graph, actors }: { graph: Graph; actors: Agent[] }) {
48
+ const { ref, width } = useChartWidth();
49
+ const data = useMemo(() => contributionWeeks(graph, actors), [graph, actors]);
50
+ const keys = data.contributors.map((item) => item.id);
51
+ const max = Math.max(
52
+ 1,
53
+ ...data.weeks.map((week) =>
54
+ [...week.counts.values()].reduce((a, b) => a + b, 0),
55
+ ),
56
+ );
57
+ const firstWeek = Math.max(
58
+ 0,
59
+ data.weeks.findIndex((week) => week.counts.size > 0),
60
+ );
61
+ const weeks = data.weeks.slice(firstWeek);
62
+ // A single occupied week is still a full time interval, not a zero-width path.
63
+ const plotted =
64
+ weeks.length === 1 && weeks[0]
65
+ ? [...weeks, { ...weeks[0], index: 12 }]
66
+ : weeks;
67
+ const x = scaleLinear({
68
+ domain: [firstWeek, Math.max(11, firstWeek + 1)],
69
+ range: [0, width],
70
+ });
71
+ const y = scaleLinear({ domain: [-max / 2, max / 2], range: [116, 8] });
72
+ if (keys.length === 0)
73
+ return (
74
+ <p className="text-sm text-[var(--stipe-fg-muted)] bonsai-muted">
75
+ No sampled commits in the past 12 weeks.
76
+ </p>
77
+ );
78
+ return (
79
+ <>
80
+ <p className="text-xs text-[var(--stipe-fg-muted)] bonsai-chart-note">
81
+ Loaded history (up to 12 weeks) · weekly commits by first pushing actor
82
+ </p>
83
+ <svg
84
+ ref={ref}
85
+ className="bonsai-contributor-chart"
86
+ viewBox={`0 0 ${String(width)} 140`}
87
+ role="img"
88
+ aria-label="Contributor streamgraph, weekly sampled commits"
89
+ >
90
+ <AreaStack
91
+ data={plotted}
92
+ keys={keys}
93
+ offset="silhouette"
94
+ curve={curveMonotoneX}
95
+ x={(point) => x(point.data.index)}
96
+ y0={(point) => y(point[0])}
97
+ y1={(point) => y(point[1])}
98
+ value={(week, key) => week.counts.get(key) ?? 0}
99
+ >
100
+ {({ stacks, path }) =>
101
+ stacks.map((series) => {
102
+ const contributor = data.contributors.find(
103
+ (item) => item.id === series.key,
104
+ );
105
+ return (
106
+ <path
107
+ key={series.key}
108
+ d={path(series) ?? ""}
109
+ fill={agentColor(contributor?.color)}
110
+ stroke="var(--color-base-100)"
111
+ strokeWidth={1}
112
+ fillOpacity={contributor?.kind === "human" ? 0.65 : 0.95}
113
+ >
114
+ <title>
115
+ {contributor?.name}: {contributor?.total} commits ·{" "}
116
+ {kindLabel[contributor?.kind ?? "unknown"]}
117
+ </title>
118
+ </path>
119
+ );
120
+ })
121
+ }
122
+ </AreaStack>
123
+ <line
124
+ data-time-axis
125
+ x1={0}
126
+ x2={width}
127
+ y1={120}
128
+ y2={120}
129
+ stroke="var(--stipe-linework)"
130
+ />
131
+ <text x={0} y={137}>
132
+ {firstWeek === 11
133
+ ? "This week"
134
+ : `${String(11 - firstWeek)} weeks ago`}
135
+ </text>
136
+ <text x={width} y={137} textAnchor="end">
137
+ This week
138
+ </text>
139
+ </svg>
140
+ <ul className="text-xs bonsai-chart-legend">
141
+ {data.contributors.map((item) => (
142
+ <li key={item.id}>
143
+ <i style={{ background: agentColor(item.color) }} />
144
+ <span>
145
+ {item.name} <small>{kindLabel[item.kind]}</small>
146
+ </span>
147
+ <strong>{item.total}</strong>
148
+ </li>
149
+ ))}
150
+ </ul>
151
+ </>
152
+ );
153
+ }
154
+
155
+ function ActorSplit({ graph, actors }: { graph: Graph; actors: Agent[] }) {
156
+ const split = actorSplit(graph, actors);
157
+ const total =
158
+ split.commits.agent + split.commits.human + split.commits.unknown;
159
+ const contributors = contributionWeeks(graph, actors).contributors;
160
+ const recorded = contributors.reduce((sum, actor) => sum + actor.total, 0);
161
+ let offset = 0;
162
+ return (
163
+ <>
164
+ <p className="text-2xl font-semibold">
165
+ {total ? Math.round((100 * split.commits.agent) / total) : 0}%{" "}
166
+ <span className="text-sm font-normal">by Agents</span>
167
+ </p>
168
+ <svg
169
+ viewBox="0 0 440 16"
170
+ className="bonsai-actor-bar"
171
+ role="img"
172
+ aria-label="Recent sampled commits by actor"
173
+ >
174
+ {contributors.map((actor) => {
175
+ const width = recorded ? (440 * actor.total) / recorded : 0;
176
+ const x = offset;
177
+ offset += width;
178
+ return (
179
+ <rect
180
+ key={actor.id}
181
+ x={x}
182
+ width={width}
183
+ height={16}
184
+ fill={agentColor(actor.color)}
185
+ >
186
+ <title>
187
+ {actor.name}: {actor.total} sampled commits
188
+ </title>
189
+ </rect>
190
+ );
191
+ })}
192
+ </svg>
193
+ <ScreenCells>
194
+ {kinds.map((kind) => (
195
+ <ScreenCell
196
+ key={kind}
197
+ legend={
198
+ kind === "agent"
199
+ ? "Agents"
200
+ : kind === "human"
201
+ ? "People"
202
+ : "Unknown"
203
+ }
204
+ >
205
+ {split.commits[kind]}
206
+ </ScreenCell>
207
+ ))}
208
+ </ScreenCells>
209
+ <p className="text-xs text-[var(--stipe-fg-muted)]">
210
+ {total} loaded commits ·{" "}
211
+ {split.merges.agent + split.merges.human + split.merges.unknown} merges.
212
+ Review counts are not included.
213
+ </p>
214
+ </>
215
+ );
216
+ }
217
+
218
+ function Calendar({ graph }: { graph: Graph }) {
219
+ const { ref, width } = useChartWidth();
220
+ const days = contributionDays(graph);
221
+ const max = Math.max(1, ...days.map((day) => day.count));
222
+ const startDay = days[0]?.day ?? 0;
223
+ const offset = new Date(startDay * 86400000).getUTCDay();
224
+ const step = (width - 18) / Math.ceil((days.length + offset) / 7);
225
+ return (
226
+ <>
227
+ <p className="text-xs text-[var(--stipe-fg-muted)] bonsai-chart-note">
228
+ Past 26 weeks · sampled commits · UTC
229
+ </p>
230
+ <svg
231
+ ref={ref}
232
+ className="bonsai-calendar-chart"
233
+ viewBox={`0 0 ${String(width)} 130`}
234
+ role="img"
235
+ aria-label={`${String(days.reduce((sum, day) => sum + day.count, 0))} sampled commits in the past 26 weeks`}
236
+ >
237
+ {["S", "M", "T", "W", "T", "F", "S"].map((label, row) => (
238
+ <text key={row} x={0} y={32 + row * 15}>
239
+ {label}
240
+ </text>
241
+ ))}
242
+ {days.map((day, index) => {
243
+ const date = new Date(day.day * 86400000);
244
+ const x = 18 + Math.floor((index + offset) / 7) * step;
245
+ const y = 22 + ((index + offset) % 7) * 15;
246
+ return (
247
+ <g key={day.day}>
248
+ {date.getUTCDate() <= 7 && date.getUTCDay() === 0 ? (
249
+ <text x={x} y={12}>
250
+ {date.toLocaleDateString("en", {
251
+ month: "short",
252
+ timeZone: "UTC",
253
+ })}
254
+ </text>
255
+ ) : null}
256
+ <rect
257
+ x={x}
258
+ y={y}
259
+ width={Math.max(1, step - 3)}
260
+ height={12}
261
+ rx={2}
262
+ fill={
263
+ day.count ? "var(--bonsai-agent-c3)" : "var(--color-base-300)"
264
+ }
265
+ fillOpacity={day.count ? 0.25 + (day.count / max) * 0.75 : 0.5}
266
+ >
267
+ <title>
268
+ {date.toISOString().slice(0, 10)}: {day.count} commits
269
+ </title>
270
+ </rect>
271
+ </g>
272
+ );
273
+ })}
274
+ </svg>
275
+ <p className="text-xs text-[var(--stipe-fg-muted)] bonsai-chart-note">
276
+ Lighter to darker = fewer to more commits. Empty cells mean no commits
277
+ in the loaded history sample.
278
+ </p>
279
+ </>
280
+ );
281
+ }
282
+
283
+ function CodeAge({ data }: { data: CodeInsights }) {
284
+ const { ref, width } = useChartWidth();
285
+ const latest = data.ageHistory.at(-1);
286
+ const total = latest?.lines.reduce((sum, count) => sum + count, 0) ?? 0;
287
+ const max = Math.max(
288
+ 1,
289
+ ...data.ageHistory.map((snapshot) =>
290
+ snapshot.lines.reduce((sum, count) => sum + count, 0),
291
+ ),
292
+ );
293
+ const x = scaleLinear({
294
+ domain: [0, Math.max(1, data.ageHistory.length - 1)],
295
+ range: [0, width],
296
+ });
297
+ const y = scaleLinear({ domain: [0, max], range: [88, 6] });
298
+ const date = (at: number) =>
299
+ new Date(at * 1000).toLocaleDateString("en", {
300
+ month: "short",
301
+ day: "numeric",
302
+ timeZone: "UTC",
303
+ });
304
+ const first = data.ageHistory[0];
305
+ const keys = ageBandLabels.map((_, index) => index);
306
+ return (
307
+ <>
308
+ <p className="text-xs text-[var(--stipe-fg-muted)] bonsai-chart-note">
309
+ {total.toLocaleString()} current lines · sampled paths across
310
+ first-parent snapshots
311
+ </p>
312
+ <svg
313
+ ref={ref}
314
+ className="bonsai-age-chart"
315
+ viewBox={`0 0 ${String(width)} 110`}
316
+ role="img"
317
+ aria-label="Line ages over history, stacked by age band"
318
+ >
319
+ <AreaStack
320
+ data={data.ageHistory}
321
+ keys={keys}
322
+ curve={curveMonotoneX}
323
+ x={(_point, index) => x(index)}
324
+ y0={(point) => y(point[0])}
325
+ y1={(point) => y(point[1])}
326
+ value={(snapshot, key) => snapshot.lines[key] ?? 0}
327
+ >
328
+ {({ stacks, path }) =>
329
+ stacks.map((series) => (
330
+ <path
331
+ key={series.key}
332
+ d={path(series) ?? ""}
333
+ fill={
334
+ series.key === 4
335
+ ? "var(--stipe-fg-muted)"
336
+ : agentColor(`c${String(series.key + 1)}`)
337
+ }
338
+ >
339
+ <title>
340
+ {ageBandLabels[series.key]}: {latest?.lines[series.key] ?? 0}{" "}
341
+ current lines
342
+ </title>
343
+ </path>
344
+ ))
345
+ }
346
+ </AreaStack>
347
+ <line
348
+ data-time-axis
349
+ x1={0}
350
+ x2={width}
351
+ y1={90}
352
+ y2={90}
353
+ stroke="var(--stipe-linework)"
354
+ />
355
+ <text x={0} y={106}>
356
+ {first ? date(first.at) : ""}
357
+ </text>
358
+ <text x={width} y={106} textAnchor="end">
359
+ {latest ? date(latest.at) : ""}
360
+ </text>
361
+ </svg>
362
+ <ul className="text-xs bonsai-chart-legend">
363
+ {ageBandLabels.map((label, index) => (
364
+ <li key={label}>
365
+ <i
366
+ style={{
367
+ background:
368
+ index === 4
369
+ ? "var(--stipe-fg-muted)"
370
+ : agentColor(`c${String(index + 1)}`),
371
+ }}
372
+ />
373
+ <span>{label}</span>
374
+ <strong>{(latest?.lines[index] ?? 0).toLocaleString()}</strong>
375
+ </li>
376
+ ))}
377
+ </ul>
378
+ </>
379
+ );
380
+ }
381
+
382
+ type FileLeaf = {
383
+ path: string;
384
+ lines: number;
385
+ changes: number;
386
+ children?: FileLeaf[];
387
+ };
388
+ function FileChurn({ data }: { data: CodeInsights }) {
389
+ const root = hierarchy<FileLeaf>({
390
+ path: "root",
391
+ lines: 0,
392
+ changes: 0,
393
+ children: data.files.filter((file) => file.lines > 0),
394
+ })
395
+ .sum((file) => file.lines)
396
+ .sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
397
+ const max = Math.max(1, ...data.files.map((file) => file.changes));
398
+ if (!root.children?.length)
399
+ return (
400
+ <p className="text-sm text-[var(--stipe-fg-muted)] bonsai-muted">
401
+ No readable, non-empty text files in this sample.
402
+ </p>
403
+ );
404
+ return (
405
+ <>
406
+ <p className="text-xs text-[var(--stipe-fg-muted)] bonsai-chart-note">
407
+ Area = current lines · darker = more changed revisions
408
+ </p>
409
+ <svg viewBox="0 0 440 175" role="img" aria-label="File churn treemap">
410
+ <Treemap root={root} size={[440, 175]} paddingInner={2}>
411
+ {(tree) =>
412
+ tree.leaves().map((leaf) => (
413
+ <g key={leaf.data.path}>
414
+ <rect
415
+ x={leaf.x0}
416
+ y={leaf.y0}
417
+ width={leaf.x1 - leaf.x0}
418
+ height={leaf.y1 - leaf.y0}
419
+ rx={2}
420
+ fill="var(--bonsai-agent-c1)"
421
+ fillOpacity={0.2 + (leaf.data.changes / max) * 0.8}
422
+ >
423
+ <title>
424
+ {leaf.data.path}: {leaf.data.lines} lines,{" "}
425
+ {leaf.data.changes} changed revisions
426
+ </title>
427
+ </rect>
428
+ {leaf.x1 - leaf.x0 > 70 && leaf.y1 - leaf.y0 > 28 ? (
429
+ <text x={leaf.x0 + 6} y={leaf.y0 + 17}>
430
+ {leaf.data.path
431
+ .split("/")
432
+ .at(-1)
433
+ ?.slice(
434
+ 0,
435
+ Math.max(4, Math.floor((leaf.x1 - leaf.x0 - 12) / 7)),
436
+ )}
437
+ </text>
438
+ ) : null}
439
+ </g>
440
+ ))
441
+ }
442
+ </Treemap>
443
+ </svg>
444
+ <details>
445
+ <summary>File counts</summary>
446
+ <ul className="bonsai-file-counts">
447
+ {data.files.map((file) => (
448
+ <li key={file.path}>
449
+ <code>{file.path}</code>
450
+ <span>
451
+ {file.lines} lines · {file.changes} changes
452
+ </span>
453
+ </li>
454
+ ))}
455
+ </ul>
456
+ </details>
457
+ </>
458
+ );
459
+ }
460
+
461
+ function CodeTile({
462
+ id,
463
+ repo,
464
+ refName,
465
+ }: {
466
+ id: "ages" | "churn";
467
+ repo: string;
468
+ refName: string;
469
+ }) {
470
+ const query = useQuery({
471
+ queryKey: ["code-insights", repo, refName],
472
+ queryFn: () =>
473
+ callMethod("insights.code", { repo, ref: refName }, codeInsights),
474
+ staleTime: 60000,
475
+ });
476
+ if (query.isPending) return <Loading label="Reading line history" />;
477
+ if (query.isError)
478
+ return (
479
+ <Failure
480
+ error={query.error}
481
+ retry={() => {
482
+ void query.refetch();
483
+ }}
484
+ />
485
+ );
486
+ if (query.data.head === null)
487
+ return (
488
+ <p className="text-sm text-[var(--stipe-fg-muted)] bonsai-muted">
489
+ Push a first commit to see code insights.
490
+ </p>
491
+ );
492
+ return (
493
+ <>
494
+ {id === "ages" ? (
495
+ <CodeAge data={query.data} />
496
+ ) : (
497
+ <FileChurn data={query.data} />
498
+ )}
499
+ <p className="text-xs text-[var(--stipe-fg-muted)] bonsai-chart-note">
500
+ {query.data.files.length} text files · {query.data.commits} first-parent
501
+ commits
502
+ {query.data.partial
503
+ ? ` · bounded sample, ${String(query.data.omittedFiles)} files omitted; older lines may be unknown`
504
+ : ""}
505
+ </p>
506
+ </>
507
+ );
508
+ }
509
+
510
+ export function InsightTile({
511
+ id,
512
+ repo,
513
+ refName,
514
+ graph,
515
+ actors,
516
+ }: {
517
+ id: "contributors" | "actors" | "calendar" | "ages" | "churn";
518
+ repo: string;
519
+ refName: string;
520
+ graph: Graph;
521
+ actors: Agent[];
522
+ }) {
523
+ switch (id) {
524
+ case "contributors":
525
+ return <Contributors graph={graph} actors={actors} />;
526
+ case "actors":
527
+ return <ActorSplit graph={graph} actors={actors} />;
528
+ case "calendar":
529
+ return <Calendar graph={graph} />;
530
+ case "ages":
531
+ case "churn":
532
+ return <CodeTile id={id} repo={repo} refName={refName} />;
533
+ }
534
+ }