@fungi.computer/bonsai 0.0.0-stage → 0.1.0

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 (133) hide show
  1. package/CHANGELOG.md +43 -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 +848 -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/present.test.ts +27 -0
  90. package/test/read-copy-calls.test.ts +691 -0
  91. package/test/read-copy-composer.test.ts +388 -0
  92. package/test/read-copy-lifetimes.test.ts +214 -0
  93. package/test/read-copy-rpc.config.jsonc +12 -0
  94. package/test/read-copy-rpc.native.mjs +47 -0
  95. package/test/read-copy-rpc.worker.ts +92 -0
  96. package/test/recent-activity.test.ts +62 -0
  97. package/test/repository-deletion.test.ts +500 -0
  98. package/test/seed-resume.test.mjs +91 -0
  99. package/test/storyboard-save-https-git.fixture.mjs +119 -0
  100. package/test/storyboard-save-template.test.mjs +545 -0
  101. package/test/ui-errors.test.ts +976 -0
  102. package/test/workflow-store.test.ts +115 -0
  103. package/tsconfig.json +36 -0
  104. package/turbo.jsonc +25 -0
  105. package/vite.config.ts +19 -0
  106. package/vitest.config.ts +47 -0
  107. package/web/api.ts +224 -0
  108. package/web/app-build-panel.tsx +171 -0
  109. package/web/board.tsx +259 -0
  110. package/web/bonsai.css +642 -0
  111. package/web/change-page.tsx +719 -0
  112. package/web/change-rows.tsx +135 -0
  113. package/web/code-tab.tsx +267 -0
  114. package/web/commit-tree.tsx +152 -0
  115. package/web/format.ts +54 -0
  116. package/web/home-page.tsx +274 -0
  117. package/web/index.html +17 -0
  118. package/web/insight-data.ts +76 -0
  119. package/web/insight-tiles.tsx +534 -0
  120. package/web/insights.tsx +250 -0
  121. package/web/issues-panel.tsx +305 -0
  122. package/web/main.tsx +72 -0
  123. package/web/network-graph.tsx +647 -0
  124. package/web/overview.tsx +339 -0
  125. package/web/pages.tsx +469 -0
  126. package/web/parts.tsx +381 -0
  127. package/web/public/favicon.svg +1 -0
  128. package/web/repo-navigation.tsx +256 -0
  129. package/web/repo-settings.tsx +145 -0
  130. package/web/router.tsx +151 -0
  131. package/web/transport.ts +91 -0
  132. package/web/views.tsx +151 -0
  133. package/web/working-now.tsx +98 -0
@@ -0,0 +1,719 @@
1
+ import {
2
+ Tooltip,
3
+ TooltipTrigger,
4
+ TooltipContent,
5
+ } from "@fungi.computer/caps/components/tooltip";
6
+ import { useEffect, useMemo, useRef, useState } from "react";
7
+ import { useQueryClient } from "@tanstack/react-query";
8
+ import { Alert } from "@fungi.computer/caps/components/alert";
9
+ import { Button } from "@fungi.computer/caps/components/button";
10
+ import {
11
+ DataList,
12
+ DataListHeading,
13
+ DataRow,
14
+ } from "@fungi.computer/caps/components/data-list";
15
+ import { InlineCode } from "@fungi.computer/caps/components/inline-code";
16
+ import { Status } from "@fungi.computer/caps/components/status";
17
+ import type { MergeResult, PolicyDecision } from "../shared/contract.js";
18
+ import {
19
+ useAgents,
20
+ useChange,
21
+ useChanges,
22
+ useEvents,
23
+ useGraph,
24
+ useRepo,
25
+ mergeChange,
26
+ approveChange,
27
+ closeChange,
28
+ } from "./api.js";
29
+ import { BonsaiBoard, type BoardTile } from "./board.js";
30
+ import { NetworkGraph } from "./network-graph.js";
31
+ import {
32
+ AgentMark,
33
+ AgentName,
34
+ agentColors,
35
+ Failure,
36
+ Loading,
37
+ } from "./parts.js";
38
+ import { firstLine, relativeTime, shortHash } from "./format.js";
39
+ import { href, Link, useRouter } from "./router.js";
40
+ import {
41
+ changeStatusLabel,
42
+ changeStatusTone,
43
+ needsAttention,
44
+ } from "./change-rows.js";
45
+ import { RepoNavigation } from "./repo-navigation.js";
46
+ import { useWorkingNow } from "./working-now.js";
47
+ import { DiffView, Markdown } from "./views.js";
48
+
49
+ function policyMessage(
50
+ reason: Extract<PolicyDecision, { allowed: false }>["reason"],
51
+ head: string,
52
+ ): string {
53
+ const messages = {
54
+ "not-open": "This Change is no longer open.",
55
+ merging: "A merge is already in progress.",
56
+ moved: "The proposed head moved. Review the latest commit.",
57
+ conflict: "Resolve the conflicts and push the source branch again.",
58
+ "needs-approval":
59
+ "Needs one approval from someone who did not write this work.",
60
+ "self-approval": "Someone who did not write this work must approve it.",
61
+ "checks-failed": `Checks failed on ${shortHash(head)}.`,
62
+ "checks-running": `Checks are still running on ${shortHash(head)}.`,
63
+ "not-maintainer": "Only a repository maintainer can do this.",
64
+ "agents-cannot-merge": "A person must merge this Change.",
65
+ "agents-cannot-approve":
66
+ "A person must approve this Change. Agent reviews are checks.",
67
+ "not-source-writer": "You need write access to the source repository.",
68
+ "not-authorized": "You do not have permission to do this.",
69
+ } satisfies Record<
70
+ Extract<PolicyDecision, { allowed: false }>["reason"],
71
+ string
72
+ >;
73
+ return messages[reason];
74
+ }
75
+
76
+ function mergeMessage(result: MergeResult, head: string): string {
77
+ switch (result.kind) {
78
+ case "merging":
79
+ return "Merging…";
80
+ case "merged":
81
+ return `Merged as ${shortHash(result.after)}.`;
82
+ case "moved":
83
+ // Covers both a moved source head and an exhausted stale-CAS retry
84
+ // against a moved target: `result.head` is only meaningful for the
85
+ // former, so the wording names neither specifically.
86
+ return "The merge did not complete because the branch moved. Review the refreshed Change and merge again.";
87
+ case "conflict":
88
+ return `Conflicts in ${result.paths.join(", ")}. Update the source branch, resolve and push again.`;
89
+ case "refused":
90
+ return `Merge refused: ${policyMessage(result.reason, head)}`;
91
+ }
92
+ }
93
+
94
+ export function ChangePage({ repo, number }: { repo: string; number: number }) {
95
+ const detail = useChange(repo, number);
96
+ const list = useChanges(repo);
97
+ const graph = useGraph(repo);
98
+ const repoDetail = useRepo(repo);
99
+ const events = useEvents(repo);
100
+ const recentPushes = useWorkingNow(events.data?.events ?? []);
101
+ const agents = useAgents();
102
+ const colors = useMemo(() => agentColors(agents.data?.agents), [agents.data]);
103
+ const client = useQueryClient();
104
+ const { go } = useRouter();
105
+ const [pending, setPending] = useState<"merge" | "approve" | "close" | null>(
106
+ null,
107
+ );
108
+ const merging =
109
+ pending === "merge" || detail.data?.change.state === "merging";
110
+ const waitingForMerge = detail.data?.change.state === "merging";
111
+ const refreshChange = detail.refetch;
112
+ useEffect(() => {
113
+ if (!waitingForMerge) return;
114
+ const timer = window.setInterval(() => {
115
+ void refreshChange();
116
+ }, 1000);
117
+ return () => {
118
+ window.clearInterval(timer);
119
+ };
120
+ }, [waitingForMerge, refreshChange]);
121
+ // Tracks whether polling has actually observed this Change as "merging",
122
+ // so a settled background merge can be told apart from the brief window
123
+ // right after the click, before the first refetch lands.
124
+ const sawMerging = useRef(false);
125
+ useEffect(() => {
126
+ if (waitingForMerge) sawMerging.current = true;
127
+ }, [waitingForMerge]);
128
+ useEffect(() => {
129
+ sawMerging.current = false;
130
+ }, [repo, number]);
131
+ const [wide, setWide] = useState(false);
132
+ useEffect(() => {
133
+ const media = matchMedia("(min-width: 1024px)");
134
+ const update = () => {
135
+ setWide(media.matches);
136
+ };
137
+ update();
138
+ media.addEventListener("change", update);
139
+ return () => {
140
+ media.removeEventListener("change", update);
141
+ };
142
+ }, []);
143
+ const [outcome, setOutcome] = useState<string | null>(null);
144
+ const [actionError, setActionError] = useState<string | null>(null);
145
+ // RepoNavigation only shows this target while its own route.tab is
146
+ // "overview" (never true here, the Change page always passes "changes"),
147
+ // so the slot stays unused; same `controlsTarget` plumbing as pages.tsx.
148
+ const [, setControlsTarget] = useState<HTMLDivElement | null>(null);
149
+ const changes = list.data?.changes ?? [];
150
+ const index = changes.findIndex(
151
+ (change) => change.repo === repo && change.number === number,
152
+ );
153
+ const previous = index > 0 ? changes[index - 1] : undefined;
154
+ const next = index >= 0 ? changes[index + 1] : undefined;
155
+ const openChangeCount = changes.filter(
156
+ (item) => item.state === "open" || item.state === "merging",
157
+ ).length;
158
+ const changesNeedAttention = changes.some(needsAttention);
159
+ useEffect(() => {
160
+ const keydown = (event: KeyboardEvent) => {
161
+ if (
162
+ event.defaultPrevented ||
163
+ event.repeat ||
164
+ event.metaKey ||
165
+ event.ctrlKey ||
166
+ event.altKey ||
167
+ pending !== null
168
+ )
169
+ return;
170
+ if (
171
+ event.target instanceof HTMLElement &&
172
+ event.target.closest(
173
+ "input,textarea,select,[contenteditable=true],[role=textbox]",
174
+ )
175
+ )
176
+ return;
177
+ const target =
178
+ event.key === "[" ? previous : event.key === "]" ? next : undefined;
179
+ if (!target) return;
180
+ event.preventDefault();
181
+ go(href({ page: "change", repo: target.repo, number: target.number }));
182
+ };
183
+ document.addEventListener("keydown", keydown);
184
+ return () => {
185
+ document.removeEventListener("keydown", keydown);
186
+ };
187
+ }, [previous, next, go, pending]);
188
+ // A Change review is never blocked by the repo/activity reads behind the
189
+ // nav header: those only decorate RepoNavigation, which degrades to
190
+ // nothing on its own (below) if repos.get or activity.list is pending or
191
+ // failed. Only the Change's own query gates this page.
192
+ if (detail.isPending) return <Loading label="Loading change" />;
193
+ if (detail.isError)
194
+ return (
195
+ <Failure
196
+ error={detail.error}
197
+ retry={() => {
198
+ void detail.refetch();
199
+ }}
200
+ />
201
+ );
202
+ const { change, commits, diff, review, actions } = detail.data;
203
+ const conflict = change.conflict;
204
+ // A settled background merge always leaves a typed mergeFailure (caught
205
+ // below -- access_changed, sync_failed, or head_moved) or a conflict
206
+ // matching the current head; the last branch below is defensive, so a
207
+ // stuck "Merging…" never has nothing to show once polling has actually
208
+ // observed this Change as "merging" and its state has since left that.
209
+ // (Before that observation, the row may still be showing the pre-click
210
+ // snapshot; `sawMerging` tells the two apart.)
211
+ const displayedOutcome =
212
+ change.mergeFailure === "access_changed"
213
+ ? "Merge failed: access changed"
214
+ : change.mergeFailure === "sync_failed"
215
+ ? "Merge failed · Retry"
216
+ : change.mergeFailure === "head_moved"
217
+ ? change.mergedCommit !== null
218
+ ? `Merged as ${shortHash(change.mergedCommit)}. Review the new head before merging again.`
219
+ : "The source moved. Review the refreshed Change and merge again."
220
+ : outcome === "Merging…" && change.state !== "merging"
221
+ ? change.state === "merged" && change.mergedCommit !== null
222
+ ? `Merged as ${shortHash(change.mergedCommit)}.`
223
+ : conflict !== null && conflict.head === change.head
224
+ ? mergeMessage(
225
+ {
226
+ kind: "conflict",
227
+ paths: conflict.paths,
228
+ head: change.head,
229
+ },
230
+ change.head,
231
+ )
232
+ : sawMerging.current
233
+ ? "The merge did not complete. Review the Change and try again."
234
+ : outcome
235
+ : outcome;
236
+ const perform = async (action: "merge" | "approve" | "close") => {
237
+ setPending(action);
238
+ setOutcome(null);
239
+ setActionError(null);
240
+ try {
241
+ if (action === "merge") {
242
+ // A second merge click starts a fresh observation window: without
243
+ // this, a prior merge's "seen merging" would stick, and the
244
+ // defensive fallback above could flash before this click's own
245
+ // first poll confirms "merging" again.
246
+ sawMerging.current = false;
247
+ const result = await mergeChange(
248
+ change.repo,
249
+ change.number,
250
+ change.head,
251
+ );
252
+ setOutcome(mergeMessage(result, change.head));
253
+ } else if (action === "approve") {
254
+ const result = await approveChange(
255
+ change.repo,
256
+ change.number,
257
+ change.head,
258
+ );
259
+ setOutcome(
260
+ result.kind === "approved"
261
+ ? `Approved ${shortHash(change.head)}.`
262
+ : result.kind === "moved"
263
+ ? `The reviewed head moved to ${shortHash(result.head)}. Review the refreshed Change before approving.`
264
+ : `Approval refused: ${policyMessage(result.reason, change.head)}`,
265
+ );
266
+ } else {
267
+ const result = await closeChange(change.repo, change.number);
268
+ setOutcome(
269
+ result.allowed
270
+ ? "Change closed."
271
+ : `Close refused: ${policyMessage(result.reason, change.head)}`,
272
+ );
273
+ }
274
+ await Promise.all(
275
+ ["change", "changes", "graph", "events", "repo"].map((key) =>
276
+ client.invalidateQueries({ queryKey: [key, repo] }),
277
+ ),
278
+ );
279
+ } catch (error) {
280
+ setActionError(
281
+ error instanceof Error ? error.message : "The action failed.",
282
+ );
283
+ } finally {
284
+ setPending(null);
285
+ }
286
+ };
287
+ const laneIds = new Set(
288
+ graph.data?.lanes
289
+ .filter(
290
+ (lane) =>
291
+ (lane.repo === change.repo && lane.ref === change.targetRef) ||
292
+ (lane.repo === change.sourceRepo && lane.ref === change.sourceRef),
293
+ )
294
+ .map((lane) => lane.id),
295
+ );
296
+ const laneGraph = graph.data
297
+ ? {
298
+ ...graph.data,
299
+ lanes: graph.data.lanes.filter((lane) => laneIds.has(lane.id)),
300
+ nodes: graph.data.nodes.filter((node) =>
301
+ node.lanes.some((id) => laneIds.has(id)),
302
+ ),
303
+ }
304
+ : null;
305
+ // Nothing real to draw: an empty lane graph (0 lanes and nodes) is as
306
+ // good as none for this tile, same as `laneGraph === null`.
307
+ const hasLaneGraph =
308
+ laneGraph !== null &&
309
+ (laneGraph.lanes.length > 0 || laneGraph.nodes.length > 0);
310
+ const fileCount = diff.files.length + diff.omittedFiles;
311
+ const sourceOwner =
312
+ laneGraph?.lanes.find(
313
+ (lane) =>
314
+ lane.repo === change.sourceRepo && lane.ref === change.sourceRef,
315
+ )?.owner ?? change.sourceRepo;
316
+ const mergeReason = change.mergeable.allowed
317
+ ? undefined
318
+ : policyMessage(change.mergeable.reason, change.head);
319
+ const mergeButton = (
320
+ <Button
321
+ variant="primary"
322
+ loading={merging}
323
+ disabled={!change.mergeable.allowed || pending !== null}
324
+ onClick={() => void perform("merge")}
325
+ >
326
+ {merging ? "Merging…" : `Merge into ${change.targetRef}`}
327
+ </Button>
328
+ );
329
+ const currentChecks = review.checks.filter(
330
+ (check) => check.head === change.head,
331
+ );
332
+ const tiles: BoardTile[] = [
333
+ {
334
+ id: "head",
335
+ title: "Change",
336
+ autoHeight: true,
337
+ status: (
338
+ <Status
339
+ label={changeStatusLabel(change.status)}
340
+ tone={changeStatusTone[change.status]}
341
+ size="compact"
342
+ />
343
+ ),
344
+ body: (
345
+ <div className="space-y-3">
346
+ <div className="flex flex-wrap items-center justify-between gap-3">
347
+ <div className="flex min-w-0 items-center gap-3">
348
+ <AgentMark name={change.openedBy} colors={colors} size="lg" />
349
+ <div className="min-w-0">
350
+ <h1 className="caps-card-title">
351
+ #{change.number} {change.title}
352
+ </h1>
353
+ <p className="text-sm">
354
+ <AgentName name={change.openedBy} colors={colors} /> wants to
355
+ merge{" "}
356
+ <InlineCode>
357
+ {change.sourceRepo}/{change.sourceRef}
358
+ </InlineCode>{" "}
359
+ into{" "}
360
+ <InlineCode>
361
+ {change.repo}/{change.targetRef}
362
+ </InlineCode>
363
+ </p>
364
+ </div>
365
+ </div>
366
+ <div className="flex flex-wrap items-center gap-2">
367
+ {actions.approve.allowed ? (
368
+ <Button
369
+ variant="outline"
370
+ disabled={pending !== null}
371
+ onClick={() => void perform("approve")}
372
+ >
373
+ {pending === "approve" ? "Approving…" : "Approve"}
374
+ </Button>
375
+ ) : null}
376
+ {actions.close.allowed ? (
377
+ <Button
378
+ variant="outline"
379
+ disabled={pending !== null}
380
+ onClick={() => void perform("close")}
381
+ >
382
+ {pending === "close" ? "Closing…" : "Close"}
383
+ </Button>
384
+ ) : null}
385
+ {change.mergeable.allowed ? (
386
+ mergeButton
387
+ ) : (
388
+ <Tooltip>
389
+ <TooltipTrigger asChild>
390
+ <span tabIndex={0} aria-label={mergeReason}>
391
+ {mergeButton}
392
+ </span>
393
+ </TooltipTrigger>
394
+ <TooltipContent>{mergeReason}</TooltipContent>
395
+ </Tooltip>
396
+ )}
397
+ </div>
398
+ </div>
399
+ <p className="text-sm text-[var(--stipe-fg-muted)]">
400
+ {change.ahead} ahead · {change.behind} behind · {fileCount}{" "}
401
+ {fileCount === 1 ? "file" : "files"} · +{diff.additions} / −
402
+ {diff.deletions} · updated {relativeTime(change.updatedAt)} · head{" "}
403
+ <InlineCode>{shortHash(change.head)}</InlineCode>
404
+ </p>
405
+ {change.body ? <Markdown text={change.body} /> : null}
406
+ {change.conflict ? (
407
+ <Alert
408
+ tone="warning"
409
+ title="Resolve conflicts before merging"
410
+ description={`Bonsai recorded conflicts in ${change.conflict.paths.join(", ")}. Update ${sourceOwner}/${change.sourceRef} from ${change.targetRef}, resolve the files and push again.`}
411
+ />
412
+ ) : !change.mergeable.allowed ? (
413
+ <p className="text-sm text-[var(--stipe-fg-muted)]">
414
+ {mergeReason}
415
+ </p>
416
+ ) : null}
417
+ {displayedOutcome ? <p role="status">{displayedOutcome}</p> : null}
418
+ {actionError ? (
419
+ <Alert tone="error" description={actionError} />
420
+ ) : null}
421
+ </div>
422
+ ),
423
+ },
424
+ {
425
+ id: "lane",
426
+ title: "Lane",
427
+ autoHeight: true,
428
+ body: (
429
+ <>
430
+ {graph.isError ? (
431
+ <Failure
432
+ error={graph.error}
433
+ retry={() => {
434
+ void graph.refetch();
435
+ }}
436
+ />
437
+ ) : graph.isPending ? (
438
+ <Loading label="Loading lane" />
439
+ ) : hasLaneGraph ? (
440
+ <NetworkGraph
441
+ shortLabels
442
+ graph={laneGraph}
443
+ colors={colors}
444
+ fresh={new Set()}
445
+ onSelect={(node) => {
446
+ go(
447
+ href({
448
+ page: "commit",
449
+ repo: node.origin ?? change.sourceRepo,
450
+ hash: node.hash,
451
+ }),
452
+ );
453
+ }}
454
+ />
455
+ ) : null}
456
+ <p className="text-xs text-[var(--stipe-fg-muted)]">
457
+ Reviewed head <InlineCode>{shortHash(change.head)}</InlineCode> ·
458
+ target <InlineCode>{shortHash(change.target)}</InlineCode>
459
+ {change.base
460
+ ? ` · base ${shortHash(change.base)}`
461
+ : " · no shared base"}
462
+ </p>
463
+ </>
464
+ ),
465
+ },
466
+ {
467
+ id: "commits",
468
+ title: "Commits",
469
+ autoHeight: true,
470
+ body: (
471
+ <DataList>
472
+ {commits.map(({ commit, agent }) => (
473
+ <DataRow key={commit.hash}>
474
+ <div className="min-w-0">
475
+ <Link
476
+ to={href({
477
+ page: "commit",
478
+ repo: change.sourceRepo,
479
+ hash: commit.hash,
480
+ })}
481
+ >
482
+ {firstLine(commit.message)}
483
+ </Link>
484
+ <p className="text-xs text-[var(--stipe-fg-muted)]">
485
+ {agent ? (
486
+ <AgentName name={agent} colors={colors} />
487
+ ) : (
488
+ commit.author.name
489
+ )}{" "}
490
+ · {shortHash(commit.hash)} ·{" "}
491
+ {relativeTime(commit.committedAt * 1000)}
492
+ </p>
493
+ </div>
494
+ </DataRow>
495
+ ))}
496
+ </DataList>
497
+ ),
498
+ },
499
+ {
500
+ id: "files",
501
+ title: "Files",
502
+ autoHeight: true,
503
+ body: (
504
+ <>
505
+ <DataList>
506
+ {diff.files.map((file) => (
507
+ <DataRow key={file.path}>
508
+ <Button
509
+ variant="link"
510
+ size="sm"
511
+ className="min-w-0 max-w-full justify-start px-0"
512
+ onClick={() =>
513
+ document
514
+ .getElementById(`change-file-${file.path}`)
515
+ ?.scrollIntoView({ block: "start" })
516
+ }
517
+ >
518
+ <span className="truncate">{file.path}</span>
519
+ </Button>
520
+ <span className="text-xs text-[var(--stipe-fg-muted)]">
521
+ +{file.additions} / −{file.deletions}
522
+ </span>
523
+ </DataRow>
524
+ ))}
525
+ </DataList>
526
+ {diff.omittedFiles > 0 ? (
527
+ <p>{diff.omittedFiles} files omitted from this bounded diff.</p>
528
+ ) : null}
529
+ </>
530
+ ),
531
+ },
532
+ {
533
+ id: "diff",
534
+ title: "Diff",
535
+ autoHeight: true,
536
+ body: diff.files.length ? (
537
+ <div className="space-y-4">
538
+ {diff.files.map((file) => (
539
+ <section key={file.path} id={`change-file-${file.path}`}>
540
+ <DiffView file={file} />
541
+ </section>
542
+ ))}
543
+ </div>
544
+ ) : (
545
+ <p>No changed files in this comparison.</p>
546
+ ),
547
+ },
548
+ {
549
+ id: "review",
550
+ title: "Review",
551
+ autoHeight: true,
552
+ status:
553
+ currentChecks.length > 0 ? (
554
+ <span className="text-xs text-[var(--stipe-fg-muted)]">
555
+ {currentChecks.filter((check) => check.status === "passed").length}{" "}
556
+ of {currentChecks.length} passed
557
+ </span>
558
+ ) : undefined,
559
+ body: (
560
+ <div className="space-y-3">
561
+ {review.checks.length > 0 ? (
562
+ <section>
563
+ <DataListHeading level={3}>Checks</DataListHeading>
564
+ <DataList>
565
+ {review.checks.map((check) => (
566
+ <DataRow
567
+ key={`${check.head}:${check.name}:${check.attemptId}`}
568
+ >
569
+ <div className="min-w-0">
570
+ <div className="flex flex-wrap items-center gap-2">
571
+ <Status
572
+ size="compact"
573
+ tone={
574
+ check.status === "passed"
575
+ ? "success"
576
+ : check.status === "failed"
577
+ ? "error"
578
+ : check.status === "running"
579
+ ? "info"
580
+ : "neutral"
581
+ }
582
+ motion={
583
+ check.status === "running" ? "bounce" : "none"
584
+ }
585
+ label={check.status}
586
+ />
587
+ <span>{check.name}</span>
588
+ </div>
589
+ <p className="text-xs text-[var(--stipe-fg-muted)]">
590
+ <AgentName name={check.reporter} colors={colors} /> ·{" "}
591
+ {relativeTime(check.updatedAt)}
592
+ {check.head !== change.head
593
+ ? ` · ${shortHash(check.head)} · before the latest push`
594
+ : ""}
595
+ </p>
596
+ {check.summary ? (
597
+ <p className="text-xs text-[var(--stipe-fg-muted)]">
598
+ {check.summary}
599
+ </p>
600
+ ) : null}
601
+ {check.runUrl ? (
602
+ <a
603
+ className="text-xs"
604
+ href={check.runUrl}
605
+ target="_blank"
606
+ rel="noreferrer"
607
+ >
608
+ Run ›
609
+ </a>
610
+ ) : null}
611
+ </div>
612
+ </DataRow>
613
+ ))}
614
+ </DataList>
615
+ </section>
616
+ ) : null}
617
+ {review.approvals.length > 0 ? (
618
+ <section>
619
+ <DataListHeading level={3}>Approvals</DataListHeading>
620
+ <DataList>
621
+ {review.approvals.map((approval) => (
622
+ <DataRow key={`${approval.actor}:${approval.head}`}>
623
+ {approval.head === change.head ? (
624
+ <span>
625
+ <AgentName name={approval.actor} colors={colors} /> ·{" "}
626
+ {approval.kind === "self" ? "self-approved · " : null}
627
+ {relativeTime(approval.at)}
628
+ </span>
629
+ ) : (
630
+ <span className="text-xs text-[var(--stipe-fg-muted)]">
631
+ {approval.actor}{" "}
632
+ {approval.kind === "self"
633
+ ? "self-approved"
634
+ : "approved"}{" "}
635
+ {shortHash(approval.head)} · before the latest push
636
+ </span>
637
+ )}
638
+ </DataRow>
639
+ ))}
640
+ </DataList>
641
+ </section>
642
+ ) : null}
643
+ </div>
644
+ ),
645
+ },
646
+ ];
647
+ const shown = tiles.filter(
648
+ (tile) =>
649
+ (tile.id !== "review" ||
650
+ review.checks.length > 0 ||
651
+ review.approvals.length > 0) &&
652
+ // Nothing real to show once settled: hide the whole tile (heading,
653
+ // zoom controls included), not just the body's inner spinner.
654
+ (tile.id !== "lane" || graph.isPending || graph.isError || hasLaneGraph),
655
+ );
656
+ const board = (ids: readonly string[]) => (
657
+ <BonsaiBoard
658
+ placements={ids
659
+ .filter((id) => shown.some((tile) => tile.id === id))
660
+ .map((id, row) => ({ id, col: 0, row, w: 1, h: 1 }))}
661
+ tiles={shown.filter((tile) => ids.includes(tile.id))}
662
+ columns={1}
663
+ rowHeight={0}
664
+ />
665
+ );
666
+ return (
667
+ <div className="space-y-3">
668
+ {repoDetail.data ? (
669
+ <RepoNavigation
670
+ detail={repoDetail.data}
671
+ route={{
672
+ page: "repo",
673
+ repo,
674
+ tab: "changes",
675
+ ref: null,
676
+ path: "",
677
+ file: null,
678
+ }}
679
+ working={recentPushes.length}
680
+ changes={openChangeCount}
681
+ needsAttention={changesNeedAttention}
682
+ controlsTarget={setControlsTarget}
683
+ />
684
+ ) : null}
685
+ <nav
686
+ aria-label="Change neighbours"
687
+ className="flex flex-wrap gap-4 text-sm"
688
+ >
689
+ {previous ? (
690
+ <Link
691
+ to={href({
692
+ page: "change",
693
+ repo: previous.repo,
694
+ number: previous.number,
695
+ })}
696
+ >
697
+ Previous change #{previous.number}
698
+ </Link>
699
+ ) : null}
700
+ {next ? (
701
+ <Link
702
+ to={href({ page: "change", repo: next.repo, number: next.number })}
703
+ >
704
+ Next change #{next.number}
705
+ </Link>
706
+ ) : null}
707
+ </nav>
708
+ {board(["head"])}
709
+ {wide ? (
710
+ <div className="grid items-start gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,3fr)]">
711
+ <div className="min-w-0">{board(["lane", "commits", "files"])}</div>
712
+ <div className="min-w-0">{board(["diff", "review"])}</div>
713
+ </div>
714
+ ) : (
715
+ board(["review", "diff", "lane", "commits", "files"])
716
+ )}
717
+ </div>
718
+ );
719
+ }