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