@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,135 @@
1
+ import {
2
+ DataList,
3
+ DataListHeading,
4
+ DataRow,
5
+ } from "@fungi.computer/caps/components/data-list";
6
+ import { Status } from "@fungi.computer/caps/components/status";
7
+ import type { Change } from "../shared/contract.js";
8
+ import { relativeTime } from "./format.js";
9
+ import { AgentName, type AgentColors } from "./parts.js";
10
+ import { href, Link } from "./router.js";
11
+
12
+ export function changeStatusLabel(status: Change["status"]): string {
13
+ const label = status.replaceAll("-", " ");
14
+ return label.charAt(0).toUpperCase() + label.slice(1);
15
+ }
16
+
17
+ const order = {
18
+ conflict: 0,
19
+ "checks-failed": 1,
20
+ "checks-running": 2,
21
+ "needs-approval": 3,
22
+ ready: 4,
23
+ merging: 5,
24
+ merged: 6,
25
+ closed: 7,
26
+ };
27
+ export const changeStatusTone = {
28
+ conflict: "warning",
29
+ "checks-failed": "error",
30
+ "checks-running": "info",
31
+ "needs-approval": "warning",
32
+ ready: "success",
33
+ merging: "info",
34
+ merged: "success",
35
+ closed: "neutral",
36
+ } as const;
37
+ function ChangeRows({
38
+ changes,
39
+ colors,
40
+ limit,
41
+ }: {
42
+ changes: readonly Change[];
43
+ colors: AgentColors;
44
+ limit?: number;
45
+ }) {
46
+ return (
47
+ <DataList className="bonsai-change-list">
48
+ {[...changes]
49
+ .sort((a, b) => order[a.status] - order[b.status])
50
+ .slice(0, limit)
51
+ .map((change) => {
52
+ return (
53
+ <DataRow key={`${change.repo}:${String(change.number)}`}>
54
+ <div className="min-w-0 flex-1">
55
+ <Link
56
+ to={href({
57
+ page: "change",
58
+ repo: change.repo,
59
+ number: change.number,
60
+ })}
61
+ >
62
+ {`#${String(change.number)} · ${change.title}`}
63
+ </Link>
64
+ <p className="text-xs text-[var(--stipe-fg-muted)]">
65
+ <AgentName name={change.openedBy} colors={colors} /> ·{" "}
66
+ {change.ahead} ahead · {change.behind} behind
67
+ {` · ${relativeTime(change.updatedAt)}`}
68
+ </p>
69
+ </div>
70
+ <Status
71
+ size="compact"
72
+ tone={changeStatusTone[change.status]}
73
+ label={changeStatusLabel(change.status)}
74
+ />
75
+ </DataRow>
76
+ );
77
+ })}
78
+ </DataList>
79
+ );
80
+ }
81
+
82
+ /** The attention group uses the server's derived status, not client policy. */
83
+ export function needsAttention(change: Change): boolean {
84
+ return (
85
+ change.status === "conflict" ||
86
+ change.status === "checks-failed" ||
87
+ change.status === "needs-approval"
88
+ );
89
+ }
90
+
91
+ export function ChangeGroups({
92
+ changes,
93
+ colors,
94
+ limit,
95
+ }: {
96
+ changes: readonly Change[];
97
+ colors: AgentColors;
98
+ limit?: number;
99
+ }) {
100
+ const shown = [...changes]
101
+ .sort((a, b) => order[a.status] - order[b.status])
102
+ .slice(0, limit);
103
+ const groups = [
104
+ { title: "Needs you", items: shown.filter(needsAttention) },
105
+ {
106
+ title: "Ready",
107
+ items: shown.filter((change) => change.status === "ready"),
108
+ },
109
+ {
110
+ title: "In progress",
111
+ items: shown.filter(
112
+ (change) =>
113
+ change.status === "checks-running" || change.status === "merging",
114
+ ),
115
+ },
116
+ {
117
+ title: "Done",
118
+ items: shown.filter(
119
+ (change) => change.state === "merged" || change.state === "closed",
120
+ ),
121
+ },
122
+ ];
123
+ return (
124
+ <>
125
+ {groups
126
+ .filter((group) => group.items.length > 0)
127
+ .map((group) => (
128
+ <section key={group.title}>
129
+ <DataListHeading level={3}>{group.title}</DataListHeading>
130
+ <ChangeRows changes={group.items} colors={colors} />
131
+ </section>
132
+ ))}
133
+ </>
134
+ );
135
+ }
@@ -0,0 +1,267 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Button } from "@fungi.computer/caps/components/button";
3
+ import { CopyField } from "@fungi.computer/caps/components/copy-field";
4
+ import { Disclosure } from "@fungi.computer/caps/components/disclosure";
5
+ import { EmptyState } from "@fungi.computer/caps/blocks/empty-state";
6
+ import { fileSyncResult } from "@fungi.computer/agar/roles/files";
7
+ import { editInFilesEditor, callMethod } from "./transport.js";
8
+ import type { RepoDetail } from "../shared/contract.js";
9
+ import { useFiles, useFile, useFilesEditor } from "./api.js";
10
+ import { BonsaiBoard } from "./board.js";
11
+ import { firstLine, formatBytes, relativeTime, shortHash } from "./format.js";
12
+ import { Loading, Failure } from "./parts.js";
13
+ import { href, Link, useRouter, type Route } from "./router.js";
14
+ import { FileView, Markdown, RepoTree } from "./views.js";
15
+
16
+ export function CodeTab({
17
+ route,
18
+ detail,
19
+ }: {
20
+ route: Extract<Route, { page: "repo" }>;
21
+ detail: RepoDetail;
22
+ }) {
23
+ const { go } = useRouter();
24
+ const [wide, setWide] = useState(false);
25
+ const [treeOpen, setTreeOpen] = useState(false);
26
+ const [raw, setRaw] = useState(false);
27
+ const [actionError, setActionError] = useState<string | null>(null);
28
+ const [syncing, setSyncing] = useState(false);
29
+ const editor = useFilesEditor();
30
+ const editLabel =
31
+ editor.data === undefined || editor.data === null
32
+ ? null
33
+ : editor.data.displayName === null
34
+ ? "Edit"
35
+ : `Edit in ${editor.data.displayName}`;
36
+ useEffect(() => {
37
+ const media = matchMedia("(min-width: 1024px)");
38
+ const update = () => {
39
+ setWide(media.matches);
40
+ };
41
+ update();
42
+ media.addEventListener("change", update);
43
+ return () => {
44
+ media.removeEventListener("change", update);
45
+ };
46
+ }, []);
47
+ const ref = route.ref ?? detail.defaultBranch;
48
+ const files = useFiles(route.repo, ref, !detail.empty);
49
+ const readme =
50
+ files.data?.paths.find((path) => /^readme(\.md)?$/iu.test(path)) ?? null;
51
+ const shown = route.file ?? readme;
52
+ const file = useFile(route.repo, ref, shown);
53
+ const shownText = file.data?.pending?.text ?? file.data?.text ?? null;
54
+ if (detail.empty)
55
+ return (
56
+ <EmptyState
57
+ variant="panel"
58
+ title="Empty repository"
59
+ description={`Push a first commit: git push ${detail.cloneUrl} main`}
60
+ />
61
+ );
62
+ const tree = files.isPending ? (
63
+ <Loading label="Loading files" />
64
+ ) : files.isError ? (
65
+ <Failure
66
+ error={files.error}
67
+ retry={() => {
68
+ void files.refetch();
69
+ }}
70
+ />
71
+ ) : (
72
+ <div
73
+ className="flex"
74
+ style={{
75
+ height: Math.min(480, Math.max(90, files.data.paths.length * 30 + 60)),
76
+ }}
77
+ >
78
+ <RepoTree
79
+ key={`${route.repo}:${files.data.commit.hash}`}
80
+ paths={files.data.paths}
81
+ active={shown}
82
+ onOpen={(path) => {
83
+ setTreeOpen(false);
84
+ setRaw(false);
85
+ go(href({ ...route, path: "", file: path }));
86
+ }}
87
+ />
88
+ </div>
89
+ );
90
+ return (
91
+ <div className="grid items-start gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,3fr)]">
92
+ <div className="min-w-0 lg:sticky lg:top-3">
93
+ {wide ? (
94
+ <BonsaiBoard
95
+ columns={1}
96
+ rowHeight={0}
97
+ placements={[{ id: "files", col: 0, row: 0, w: 1, h: 1 }]}
98
+ tiles={[
99
+ { id: "files", title: "Files", autoHeight: true, body: tree },
100
+ ]}
101
+ />
102
+ ) : (
103
+ <Disclosure
104
+ open={treeOpen}
105
+ onOpenChange={setTreeOpen}
106
+ summary={`Files · ${shown ?? ref}`}
107
+ >
108
+ {tree}
109
+ </Disclosure>
110
+ )}
111
+ </div>
112
+ <BonsaiBoard
113
+ columns={1}
114
+ rowHeight={0}
115
+ placements={[{ id: "file", col: 0, row: 0, w: 1, h: 1 }]}
116
+ tiles={[
117
+ {
118
+ id: "file",
119
+ title: "File",
120
+ autoHeight: true,
121
+ body: (
122
+ <div className="space-y-3">
123
+ {shown ? (
124
+ <div className="flex flex-wrap items-center justify-between gap-2">
125
+ <CopyField value={shown} label="Copy path" />
126
+ <div className="flex flex-wrap items-center gap-2">
127
+ {file.data?.binary === false &&
128
+ file.data.text !== null &&
129
+ editLabel !== null ? (
130
+ <Button
131
+ variant="outline"
132
+ size="sm"
133
+ onClick={() => {
134
+ setActionError(null);
135
+ void editInFilesEditor(
136
+ route.repo,
137
+ ref,
138
+ shown,
139
+ ).catch((error: unknown) => {
140
+ setActionError(
141
+ error instanceof Error
142
+ ? error.message
143
+ : "The editor could not open",
144
+ );
145
+ });
146
+ }}
147
+ >
148
+ {editLabel}
149
+ </Button>
150
+ ) : null}
151
+ {file.data?.text !== null &&
152
+ file.data?.text !== undefined ? (
153
+ <Button
154
+ variant="outline"
155
+ size="sm"
156
+ aria-pressed={raw}
157
+ onClick={() => {
158
+ setRaw(!raw);
159
+ }}
160
+ >
161
+ Raw
162
+ </Button>
163
+ ) : null}
164
+ </div>
165
+ </div>
166
+ ) : null}
167
+ {actionError === null ? null : (
168
+ <p role="alert">{actionError}</p>
169
+ )}
170
+ {file.data?.pending === undefined ? null : (
171
+ <div className="flex flex-wrap items-center gap-2 text-sm">
172
+ <span>
173
+ {file.data.pending.state === "conflict"
174
+ ? editLabel === null
175
+ ? "Sync conflict"
176
+ : `Sync conflict · ${editLabel} to compare`
177
+ : file.data.pending.state === "failed"
178
+ ? "Sync failed · Saved text retained"
179
+ : "Unsynced edit"}
180
+ </span>
181
+ {file.data.pending.state === "pending" ||
182
+ file.data.pending.state === "failed" ? (
183
+ <Button
184
+ variant="outline"
185
+ size="sm"
186
+ disabled={syncing}
187
+ onClick={() => {
188
+ setSyncing(true);
189
+ setActionError(null);
190
+ void callMethod("files.sync", {}, fileSyncResult)
191
+ .then(async () => {
192
+ await files.refetch();
193
+ await file.refetch();
194
+ })
195
+ .catch((error: unknown) => {
196
+ setActionError(
197
+ error instanceof Error
198
+ ? error.message
199
+ : "Sync could not finish",
200
+ );
201
+ })
202
+ .finally(() => {
203
+ setSyncing(false);
204
+ });
205
+ }}
206
+ >
207
+ {syncing ? "Syncing…" : "Sync"}
208
+ </Button>
209
+ ) : null}
210
+ </div>
211
+ )}
212
+ {file.data ? (
213
+ <p className="text-xs text-[var(--stipe-fg-muted)]">
214
+ {formatBytes(file.data.size)}
215
+ {shownText === null
216
+ ? ""
217
+ : ` · ${String(shownText.trimEnd().split("\n").length)} lines`}
218
+ </p>
219
+ ) : null}
220
+ {files.data ? (
221
+ <p className="text-sm text-[var(--stipe-fg-muted)]">
222
+ Latest commit · {files.data.commit.author.name} ·{" "}
223
+ <Link
224
+ to={href({
225
+ page: "commit",
226
+ repo: route.repo,
227
+ hash: files.data.commit.hash,
228
+ })}
229
+ >
230
+ {shortHash(files.data.commit.hash)}
231
+ </Link>{" "}
232
+ · {firstLine(files.data.commit.message)} ·{" "}
233
+ {relativeTime(files.data.commit.committedAt * 1000)}
234
+ </p>
235
+ ) : null}
236
+ {shown === null ? (
237
+ <p>Pick a file to read it.</p>
238
+ ) : file.isPending ? (
239
+ <Loading label="Loading file" />
240
+ ) : file.isError ? (
241
+ <Failure
242
+ error={file.error}
243
+ retry={() => {
244
+ void file.refetch();
245
+ }}
246
+ />
247
+ ) : shownText === null ? (
248
+ <p>
249
+ {file.data.binary ? "Binary file" : "Too large to show"}
250
+ </p>
251
+ ) : raw ? (
252
+ <pre className="overflow-x-auto whitespace-pre text-sm">
253
+ {shownText}
254
+ </pre>
255
+ ) : /\.(md|mdx|markdown)$/iu.test(shown) ? (
256
+ <Markdown text={shownText} />
257
+ ) : (
258
+ <FileView path={shown} text={shownText} />
259
+ )}
260
+ </div>
261
+ ),
262
+ },
263
+ ]}
264
+ />
265
+ </div>
266
+ );
267
+ }
@@ -0,0 +1,152 @@
1
+ /** Newest-first history: topology in a narrow rail, readable commit links beside it. */
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import type { Graph, GraphNode } from "../shared/contract.js";
4
+ import { layoutGraph } from "./network-graph.js";
5
+ import { agentColor, relativeTime, shortHash } from "./format.js";
6
+ import { href, Link } from "./router.js";
7
+ import type { AgentColors } from "./parts.js";
8
+
9
+ export function verticalHistory(graph: Graph) {
10
+ const layout = layoutGraph(graph, 1);
11
+ const step = Math.min(20, 112 / Math.max(1, layout.lanes.length));
12
+ const order = [...layout.order].reverse().map((item, row) => ({
13
+ ...item,
14
+ x: 12 + item.row * step,
15
+ y: row * 62 + 20,
16
+ }));
17
+ return {
18
+ order,
19
+ byHash: new Map(order.map((item) => [item.node.hash, item])),
20
+ home: layout.home,
21
+ width: 24 + Math.max(0, layout.lanes.length - 1) * step,
22
+ };
23
+ }
24
+
25
+ export function CommitTree({
26
+ graph,
27
+ colors,
28
+ selected,
29
+ onSelect,
30
+ }: {
31
+ graph: Graph;
32
+ colors: AgentColors;
33
+ selected?: string | undefined;
34
+ onSelect?: (node: GraphNode) => void;
35
+ }) {
36
+ const element = useRef<HTMLDivElement>(null);
37
+ useEffect(() => {
38
+ if (selected)
39
+ element.current
40
+ ?.querySelector('[aria-current="true"]')
41
+ ?.scrollIntoView({ block: "center" });
42
+ }, [selected]);
43
+ const tree = useMemo(() => verticalHistory(graph), [graph]);
44
+ if (tree.order.length === 0)
45
+ return (
46
+ <p className="text-sm text-[var(--stipe-fg-muted)] bonsai-muted">
47
+ No commits yet.
48
+ </p>
49
+ );
50
+ return (
51
+ <div
52
+ ref={element}
53
+ className="bonsai-history"
54
+ aria-label="Commit history, newest first"
55
+ >
56
+ <div className="bonsai-history-body" style={{ paddingLeft: tree.width }}>
57
+ <svg
58
+ className="bonsai-history-rail"
59
+ width={tree.width}
60
+ height={tree.order.length * 62}
61
+ aria-hidden="true"
62
+ >
63
+ {tree.order.flatMap((child) =>
64
+ child.node.parents.map((hash) => {
65
+ const parent = tree.byHash.get(hash);
66
+ return parent ? (
67
+ <path
68
+ key={`${child.node.hash}:${hash}`}
69
+ d={`M ${String(child.x)} ${String(child.y)} C ${String(child.x)} ${String(child.y + 30)}, ${String(parent.x)} ${String(parent.y - 30)}, ${String(parent.x)} ${String(parent.y)}`}
70
+ fill="none"
71
+ stroke={agentColor(colors.get(child.node.agent ?? "")?.color)}
72
+ strokeWidth={2}
73
+ />
74
+ ) : (
75
+ <path
76
+ key={`${child.node.hash}:${hash}`}
77
+ d={`M ${String(child.x)} ${String(child.y)} v 30`}
78
+ stroke="var(--stipe-fg-muted)"
79
+ strokeDasharray="3 3"
80
+ >
81
+ <title>Parent outside this history sample</title>
82
+ </path>
83
+ );
84
+ }),
85
+ )}
86
+ {tree.order.map((item) => (
87
+ <circle
88
+ key={item.node.hash}
89
+ cx={item.x}
90
+ cy={item.y}
91
+ r={item.node.parents.length > 1 ? 5 : 4}
92
+ fill={
93
+ item.node.parents.length > 1
94
+ ? "var(--color-base-100)"
95
+ : agentColor(colors.get(item.node.agent ?? "")?.color)
96
+ }
97
+ stroke={agentColor(colors.get(item.node.agent ?? "")?.color)}
98
+ strokeWidth={2}
99
+ />
100
+ ))}
101
+ </svg>
102
+ <ol className="bonsai-history-commits">
103
+ {tree.order.map(({ node }) => {
104
+ const lane = tree.home.get(node.hash);
105
+ return (
106
+ <li
107
+ key={node.hash}
108
+ className={
109
+ selected === node.hash
110
+ ? "bg-[var(--stipe-surface-selected)]"
111
+ : undefined
112
+ }
113
+ >
114
+ <Link
115
+ onClick={(event) => {
116
+ if (
117
+ onSelect &&
118
+ !event.metaKey &&
119
+ !event.ctrlKey &&
120
+ !event.shiftKey &&
121
+ event.button === 0
122
+ ) {
123
+ event.preventDefault();
124
+ onSelect(node);
125
+ }
126
+ }}
127
+ aria-current={selected === node.hash ? "true" : undefined}
128
+ to={href({
129
+ page: "commit",
130
+ repo: lane?.repo ?? graph.lanes[0]?.repo ?? "",
131
+ hash: node.hash,
132
+ })}
133
+ >
134
+ <span className="min-w-0 truncate">{node.message}</span>
135
+ <span className="max-w-[45%] shrink-0 truncate text-xs text-[var(--stipe-fg-muted)] bonsai-history-branch">
136
+ {lane?.owner ? `${lane.owner}/` : ""}
137
+ {lane?.ref}
138
+ </span>
139
+ </Link>
140
+ <p className="text-sm text-[var(--stipe-fg-muted)] bonsai-muted">
141
+ <code>{shortHash(node.hash)}</code> ·{" "}
142
+ {node.agent ?? node.author} ·{" "}
143
+ {relativeTime(node.committedAt * 1000)}
144
+ </p>
145
+ </li>
146
+ );
147
+ })}
148
+ </ol>
149
+ </div>
150
+ </div>
151
+ );
152
+ }
package/web/format.ts ADDED
@@ -0,0 +1,54 @@
1
+ /** Small presentation helpers shared by Bonsai's views. */
2
+
3
+ const AGENT_COLOR = /^c[0-7]$/u;
4
+
5
+ /** An agent's palette slot as a CSS colour; unknown agents read as neutral ink. */
6
+ export function agentColor(slot: string | undefined): string {
7
+ return slot !== undefined && AGENT_COLOR.test(slot)
8
+ ? `var(--bonsai-agent-${slot})`
9
+ : "var(--stipe-fg-muted)";
10
+ }
11
+
12
+ export function shortHash(hash: string): string {
13
+ return hash.slice(0, 7);
14
+ }
15
+
16
+ /**
17
+ * A readable stand-in for a human actor whose host-issued member id is the
18
+ * only identity Bonsai has recorded; never show that raw id.
19
+ */
20
+ export function shortActor(id: string): string {
21
+ return `Member ${id.slice(-6)}`;
22
+ }
23
+
24
+ const units: [Intl.RelativeTimeFormatUnit, number][] = [
25
+ ["year", 31_536_000_000],
26
+ ["month", 2_592_000_000],
27
+ ["week", 604_800_000],
28
+ ["day", 86_400_000],
29
+ ["hour", 3_600_000],
30
+ ["minute", 60_000],
31
+ ];
32
+
33
+ const relative = new Intl.RelativeTimeFormat("en", {
34
+ numeric: "auto",
35
+ style: "narrow",
36
+ });
37
+
38
+ export function relativeTime(at: number, now = Date.now()): string {
39
+ const delta = at - now;
40
+ for (const [unit, size] of units)
41
+ if (Math.abs(delta) >= size)
42
+ return relative.format(Math.round(delta / size), unit);
43
+ return "just now";
44
+ }
45
+
46
+ export function firstLine(message: string): string {
47
+ return message.split("\n")[0] ?? "";
48
+ }
49
+
50
+ export function formatBytes(size: number): string {
51
+ if (size < 1024) return `${String(size)} B`;
52
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
53
+ return `${(size / 1024 / 1024).toFixed(1)} MB`;
54
+ }