@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,145 @@
1
+ import { useState } from "react";
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import { Button } from "@fungi.computer/caps/components/button";
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardHeader,
8
+ CardTitle,
9
+ } from "@fungi.computer/caps/components/card";
10
+ import { Input } from "@fungi.computer/caps/components/input";
11
+ import { repositoryDeletion } from "../shared/repository-deletion.js";
12
+ import { callMethod } from "./transport.js";
13
+ import { href, Link, useRouter } from "./router.js";
14
+ import { AppBuildPanel } from "./app-build-panel.js";
15
+
16
+ /** Keep cleanup retry usable after the public repository entry is withdrawn. */
17
+ export function RepoSettings({ repo }: { repo: string }) {
18
+ const { go } = useRouter();
19
+ const client = useQueryClient();
20
+ const [confirming, setConfirming] = useState(false);
21
+ const [confirmation, setConfirmation] = useState("");
22
+ const deletion = useMutation({
23
+ mutationFn: () => callMethod("repos.delete", { repo }, repositoryDeletion),
24
+ onSuccess: async () => {
25
+ client.removeQueries({ queryKey: ["repo", repo] });
26
+ await client.invalidateQueries({ queryKey: ["repos"] });
27
+ go("/");
28
+ },
29
+ });
30
+ const failure = deletion.error?.message;
31
+ const retryMessage =
32
+ failure === "Provider temporarily unavailable; retry deletion" ||
33
+ failure === "Bonsai could not finish that request"
34
+ ? "Deletion didn't finish because storage was unavailable. Confirm again to finish removing it."
35
+ : failure;
36
+ return (
37
+ <div className="flex min-w-0 flex-col gap-5">
38
+ <nav
39
+ aria-label="Repository"
40
+ className="flex flex-wrap items-center gap-2 text-sm"
41
+ >
42
+ <Link to="/">Bonsai</Link>
43
+ <span aria-hidden="true">/</span>
44
+ <Link
45
+ className="break-all"
46
+ to={href({
47
+ page: "repo",
48
+ repo,
49
+ tab: "overview",
50
+ ref: null,
51
+ path: "",
52
+ file: null,
53
+ })}
54
+ >
55
+ {repo}
56
+ </Link>
57
+ <span aria-hidden="true">/</span>
58
+ <span aria-current="page">Settings</span>
59
+ </nav>
60
+ <h1 className="caps-card-title">Repository settings</h1>
61
+ <Card className="w-full max-w-2xl" variant="outline">
62
+ <CardHeader>
63
+ <CardTitle level={2}>Delete repository</CardTitle>
64
+ </CardHeader>
65
+ <CardContent className="flex flex-col gap-4">
66
+ <p>
67
+ This removes <strong className="break-all">{repo}</strong> and its
68
+ Git content. This cannot be undone.
69
+ </p>
70
+ <p className="text-sm text-[var(--stipe-fg-muted)]">
71
+ Only the owner can delete a repository. Delete its forks first.
72
+ </p>
73
+ {confirming ? (
74
+ <form
75
+ className="flex flex-col gap-4"
76
+ onSubmit={(event) => {
77
+ event.preventDefault();
78
+ if (confirmation === repo && !deletion.isPending)
79
+ deletion.mutate();
80
+ }}
81
+ >
82
+ <label
83
+ className="flex flex-col gap-2"
84
+ htmlFor="repository-deletion-name"
85
+ >
86
+ <span>Repository name</span>
87
+ <span className="text-sm text-[var(--stipe-fg-muted)]">
88
+ Type the name above to confirm.
89
+ </span>
90
+ <Input
91
+ id="repository-deletion-name"
92
+ value={confirmation}
93
+ onChange={(event) => {
94
+ setConfirmation(event.target.value);
95
+ }}
96
+ autoComplete="off"
97
+ disabled={deletion.isPending}
98
+ autoFocus
99
+ />
100
+ </label>
101
+ {deletion.isError ? (
102
+ <div role="alert" className="text-sm text-error">
103
+ <p>{retryMessage}</p>
104
+ </div>
105
+ ) : null}
106
+ <div className="flex flex-wrap gap-3">
107
+ <Button
108
+ variant="destructive"
109
+ type="submit"
110
+ disabled={confirmation !== repo || deletion.isPending}
111
+ >
112
+ {deletion.isPending ? "Deleting…" : "Confirm deletion"}
113
+ </Button>
114
+ <Button
115
+ variant="outline"
116
+ type="button"
117
+ disabled={deletion.isPending}
118
+ onClick={() => {
119
+ setConfirming(false);
120
+ setConfirmation("");
121
+ deletion.reset();
122
+ }}
123
+ >
124
+ Cancel
125
+ </Button>
126
+ </div>
127
+ </form>
128
+ ) : (
129
+ <div>
130
+ <Button
131
+ variant="destructive"
132
+ onClick={() => {
133
+ setConfirming(true);
134
+ }}
135
+ >
136
+ Delete repository
137
+ </Button>
138
+ </div>
139
+ )}
140
+ </CardContent>
141
+ </Card>
142
+ <AppBuildPanel />
143
+ </div>
144
+ );
145
+ }
package/web/router.tsx ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * App navigation stays in memory. Numbered Changes also accept a hash link
3
+ * when the frame opens, or when its hash changes.
4
+ */
5
+ import * as z from "zod";
6
+ import {
7
+ createContext,
8
+ useContext,
9
+ useEffect,
10
+ useState,
11
+ type AnchorHTMLAttributes,
12
+ type MouseEvent,
13
+ type ReactNode,
14
+ } from "react";
15
+
16
+ export type Route =
17
+ | { page: "home" }
18
+ | {
19
+ page: "repo";
20
+ repo: string;
21
+ tab: "code" | "commits" | "changes" | "issues" | "overview" | "settings";
22
+ ref: string | null;
23
+ path: string;
24
+ file: string | null;
25
+ commit?: string;
26
+ }
27
+ | { page: "commit"; repo: string; hash: string }
28
+ | { page: "change"; repo: string; number: number };
29
+
30
+ function parseRoute(path: string): Route {
31
+ const location = new URL(path, "https://bonsai.local");
32
+ if (location.hash.startsWith("#change/")) {
33
+ const [kind, repo, rawNumber] = location.hash.slice(1).split("/");
34
+ const number = z.coerce.number().int().positive().safeParse(rawNumber);
35
+ if (kind === "change" && repo && number.success)
36
+ return {
37
+ page: "change",
38
+ repo: decodeURIComponent(repo),
39
+ number: number.data,
40
+ };
41
+ return { page: "home" };
42
+ }
43
+ const parts = location.pathname
44
+ .split("/")
45
+ .filter(Boolean)
46
+ .map(decodeURIComponent);
47
+ const [repo, section, hash] = parts;
48
+ if (repo === undefined) return { page: "home" };
49
+ if (section === "commit" && hash !== undefined)
50
+ return { page: "commit", repo, hash };
51
+ const tab = location.searchParams.get("tab");
52
+ const route: Extract<Route, { page: "repo" }> = {
53
+ page: "repo",
54
+ repo,
55
+ tab:
56
+ tab === "commits" ||
57
+ tab === "changes" ||
58
+ tab === "issues" ||
59
+ tab === "settings" ||
60
+ tab === "overview"
61
+ ? tab
62
+ : tab === "insights"
63
+ ? "overview"
64
+ : tab === "code"
65
+ ? "code"
66
+ : "overview",
67
+ ref: location.searchParams.get("ref"),
68
+ path: location.searchParams.get("path") ?? "",
69
+ file: location.searchParams.get("file"),
70
+ };
71
+ const commit = location.searchParams.get("commit");
72
+ if (commit !== null) route.commit = commit;
73
+ return route;
74
+ }
75
+
76
+ export function href(route: Route): string {
77
+ if (route.page === "home") return "/";
78
+ if (route.page === "commit")
79
+ return `/${encodeURIComponent(route.repo)}/commit/${route.hash}`;
80
+ if (route.page === "change")
81
+ return `#change/${encodeURIComponent(route.repo)}/${String(route.number)}`;
82
+ const params = new URLSearchParams();
83
+ if (route.tab !== "overview") params.set("tab", route.tab);
84
+ if (route.ref !== null) params.set("ref", route.ref);
85
+ if (route.path !== "") params.set("path", route.path);
86
+ if (route.file !== null) params.set("file", route.file);
87
+ if (route.commit !== undefined) params.set("commit", route.commit);
88
+ const query = params.toString();
89
+ return `/${encodeURIComponent(route.repo)}${query ? `?${query}` : ""}`;
90
+ }
91
+
92
+ export type RouterValue = Readonly<{ route: Route; go: (to: string) => void }>;
93
+
94
+ const RouterContext = createContext<RouterValue | null>(null);
95
+
96
+ export function RouterProvider({ children }: { children: ReactNode }) {
97
+ const [path, setPath] = useState(() =>
98
+ location.hash.startsWith("#change/") ? location.hash : "/",
99
+ );
100
+ useEffect(() => {
101
+ const changed = () => {
102
+ setPath(location.hash.startsWith("#change/") ? location.hash : "/");
103
+ };
104
+ window.addEventListener("hashchange", changed);
105
+ return () => {
106
+ window.removeEventListener("hashchange", changed);
107
+ };
108
+ }, []);
109
+ const go = (to: string) => {
110
+ setPath(to);
111
+ scrollTo({ top: 0 });
112
+ };
113
+ return (
114
+ <RouterContext.Provider value={{ route: parseRoute(path), go }}>
115
+ {children}
116
+ </RouterContext.Provider>
117
+ );
118
+ }
119
+
120
+ export function useRouter(): RouterValue {
121
+ const value = useContext(RouterContext);
122
+ if (value === null) throw new Error("Bonsai routes need a RouterProvider");
123
+ return value;
124
+ }
125
+
126
+ export function Link({
127
+ to,
128
+ onClick,
129
+ ...props
130
+ }: { to: string } & AnchorHTMLAttributes<HTMLAnchorElement>) {
131
+ const { go } = useRouter();
132
+ return (
133
+ <a
134
+ {...props}
135
+ href={to}
136
+ onClick={(event: MouseEvent<HTMLAnchorElement>) => {
137
+ onClick?.(event);
138
+ if (
139
+ event.defaultPrevented ||
140
+ event.metaKey ||
141
+ event.ctrlKey ||
142
+ event.shiftKey ||
143
+ event.button !== 0
144
+ )
145
+ return;
146
+ event.preventDefault();
147
+ go(to);
148
+ }}
149
+ />
150
+ );
151
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * How the UI reaches Bonsai's App methods: agar's `app.methods` capability
3
+ * when the host offers it, otherwise the admitted App backend. The host attributes every call
4
+ * to the signed-in person.
5
+ */
6
+ import { appsOpen, type AppRoleChoice } from "@fungi.computer/agar/apps";
7
+ import { AppError, type AppRoot } from "@fungi.computer/agar";
8
+ import { appBackend, appMethods } from "@fungi.computer/agar/backend";
9
+ import type * as z from "zod";
10
+ import { errorBody, jsonValue, type JsonValue } from "../shared/contract.js";
11
+ import {
12
+ readCopyRebuilding,
13
+ copyRebuildPage,
14
+ filesNavigateIntent,
15
+ filesRole,
16
+ } from "@fungi.computer/agar/roles/files";
17
+
18
+ export class ApiError extends Error {
19
+ constructor(
20
+ readonly status: number,
21
+ message: string,
22
+ ) {
23
+ super(message);
24
+ }
25
+ }
26
+
27
+ let connection: Pick<AppRoot, "capabilities"> | null = null;
28
+
29
+ /** Remember the host connection mountApp handed over (null outside a host). */
30
+ export function connect(app: Pick<AppRoot, "capabilities"> | null): void {
31
+ connection = app;
32
+ }
33
+
34
+ /** The Team's files-role App as the shell's role routing names it; null hides the entry. */
35
+ export async function filesEditor(): Promise<AppRoleChoice> {
36
+ if (!connection?.capabilities.has(appsOpen)) return null;
37
+ return connection.capabilities.require(appsOpen).role(filesRole);
38
+ }
39
+
40
+ /** Open the file in whichever App the Team chose for the files role. */
41
+ export async function editInFilesEditor(
42
+ repository: string,
43
+ branch: string,
44
+ path: string,
45
+ ): Promise<void> {
46
+ if (connection === null)
47
+ throw new ApiError(503, "Open Bonsai from an App host");
48
+ await connection.capabilities.require(appsOpen).open({
49
+ role: filesRole,
50
+ intent: filesNavigateIntent({ repository, branch, path }),
51
+ });
52
+ }
53
+
54
+ /** Call one App method and parse its result against the shared contract. */
55
+ export async function callMethod<S extends z.ZodType>(
56
+ method: string,
57
+ input: JsonValue,
58
+ schema: S,
59
+ ): Promise<z.infer<S>> {
60
+ if (connection === null)
61
+ throw new ApiError(503, "Open Bonsai from an App host such as Oyster");
62
+ // Typed method calls let the host apply each method's effect (public
63
+ // visitors may call read methods); hosts without them get the backend.
64
+ const capabilities = connection.capabilities;
65
+ let result;
66
+ try {
67
+ result = capabilities.has(appMethods)
68
+ ? await capabilities
69
+ .require(appMethods)
70
+ .call(method, JSON.stringify(input))
71
+ : await capabilities
72
+ .require(appBackend)
73
+ .request(JSON.stringify({ method, input }));
74
+ } catch (error) {
75
+ if (error instanceof AppError && error.code === "CAPABILITY_DENIED")
76
+ throw new ApiError(403, "Sign in to this Oyster to do that");
77
+ throw error;
78
+ }
79
+ if (readCopyRebuilding.safeParse(result).success) {
80
+ let cursor: string | null = null;
81
+ let page;
82
+ do {
83
+ page = await callMethod("rebuildCopies", { cursor }, copyRebuildPage);
84
+ cursor = page.cursor;
85
+ } while (!page.done);
86
+ return callMethod(method, input, schema);
87
+ }
88
+ const failure = errorBody.safeParse(result);
89
+ if (failure.success) throw new ApiError(400, failure.data.error);
90
+ return schema.parse(jsonValue.parse(result));
91
+ }
package/web/views.tsx ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Bonsai's file, diff, tree and Markdown views. Code and diffs are Pierre
3
+ * (themed like Inkcap: Catppuccin latte/mocha by the page theme), the tree is
4
+ * Pierre trees through Stipe's projection, and Markdown is TanStack Markdown
5
+ * inside Caps Prose.
6
+ */
7
+ import { Markdown as CapsMarkdown } from "@fungi.computer/caps/components/markdown";
8
+ import { pierreTreesStyle } from "@fungi.computer/stipe/pierre/trees";
9
+ import { getTheme, subscribeTheme } from "@fungi.computer/stipe/theme";
10
+ import { File as PierreFile, MultiFileDiff } from "@pierre/diffs/react";
11
+ import { FileTree, useFileTree } from "@pierre/trees/react";
12
+ import { useEffect, useMemo, useRef, useState } from "react";
13
+ import type { FileDiff } from "../shared/contract.js";
14
+
15
+ const DARK_THEMES = new Set([
16
+ "mocha",
17
+ "dark",
18
+ "readout-night",
19
+ "field-guide-night",
20
+ "embed-dark",
21
+ "phosphor",
22
+ ]);
23
+
24
+ const DARK_QUERY = "(prefers-color-scheme: dark)";
25
+
26
+ function readThemeType(): "light" | "dark" {
27
+ const theme = getTheme();
28
+ if (theme !== null) return DARK_THEMES.has(theme) ? "dark" : "light";
29
+ return matchMedia(DARK_QUERY).matches ? "dark" : "light";
30
+ }
31
+
32
+ /** Light or dark, following the page's Stipe theme or the reader's setting. */
33
+ function useThemeType(): "light" | "dark" {
34
+ const [type, setType] = useState(readThemeType);
35
+ useEffect(() => {
36
+ const query = matchMedia(DARK_QUERY);
37
+ const update = () => {
38
+ setType(readThemeType());
39
+ };
40
+ query.addEventListener("change", update);
41
+ const unsubscribe = subscribeTheme(update);
42
+ return () => {
43
+ query.removeEventListener("change", update);
44
+ unsubscribe();
45
+ };
46
+ }, []);
47
+ return type;
48
+ }
49
+
50
+ const pierreTheme = {
51
+ dark: "catppuccin-mocha",
52
+ light: "catppuccin-latte",
53
+ } as const;
54
+
55
+ export function FileView({ path, text }: { path: string; text: string }) {
56
+ const themeType = useThemeType();
57
+ const file = useMemo(() => ({ name: path, contents: text }), [path, text]);
58
+ return (
59
+ <div className="bonsai-pierre">
60
+ <PierreFile
61
+ file={file}
62
+ options={{
63
+ disableFileHeader: true,
64
+ overflow: "scroll",
65
+ themeType,
66
+ theme: pierreTheme,
67
+ }}
68
+ />
69
+ </div>
70
+ );
71
+ }
72
+
73
+ export function DiffView({ file }: { file: FileDiff }) {
74
+ const themeType = useThemeType();
75
+ const oldFile = useMemo(
76
+ () => ({ name: file.path, contents: file.oldText ?? "" }),
77
+ [file.path, file.oldText],
78
+ );
79
+ const newFile = useMemo(
80
+ () => ({ name: file.path, contents: file.newText ?? "" }),
81
+ [file.path, file.newText],
82
+ );
83
+ if (file.oldText === null || file.newText === null)
84
+ return (
85
+ <section
86
+ className="caps-card caps-card-surface bonsai-diff-file"
87
+ aria-label={file.path}
88
+ >
89
+ <header>
90
+ <span className="bonsai-diff-path">{file.path}</span>
91
+ </header>
92
+ <p className="bonsai-diff-note">
93
+ {file.binary ? "Binary file" : "Too large to show"}
94
+ </p>
95
+ </section>
96
+ );
97
+ return (
98
+ <section
99
+ className="caps-card caps-card-surface bonsai-diff-file bonsai-pierre"
100
+ aria-label={file.path}
101
+ id={`file-${file.path}`}
102
+ >
103
+ <MultiFileDiff
104
+ oldFile={oldFile}
105
+ newFile={newFile}
106
+ options={{
107
+ diffStyle: "unified",
108
+ overflow: "scroll",
109
+ themeType,
110
+ theme: pierreTheme,
111
+ }}
112
+ />
113
+ </section>
114
+ );
115
+ }
116
+
117
+ export type TreeProps = Readonly<{
118
+ paths: readonly string[];
119
+ active: string | null;
120
+ onOpen: (path: string) => void;
121
+ }>;
122
+
123
+ /** The repository tree. Remount it (key) when the path set changes. */
124
+ export function RepoTree({ paths, active, onOpen }: TreeProps) {
125
+ const open = useRef(onOpen);
126
+ open.current = onOpen;
127
+ const { model } = useFileTree({
128
+ paths,
129
+ itemHeight: 30,
130
+ initialExpansion: 1,
131
+ onSelectionChange(selected) {
132
+ const path = selected[0];
133
+ if (path !== undefined && !path.endsWith("/")) open.current(path);
134
+ },
135
+ });
136
+ useEffect(() => {
137
+ if (active !== null) model.focusPath(active);
138
+ }, [model, active]);
139
+ return (
140
+ <FileTree
141
+ model={model}
142
+ className="bonsai-tree"
143
+ style={pierreTreesStyle({ itemHeight: 30 })}
144
+ />
145
+ );
146
+ }
147
+
148
+ /** Markdown from a repository, rendered by the shared Caps component. */
149
+ export function Markdown({ text }: { text: string }) {
150
+ return <CapsMarkdown className="bonsai-markdown">{text}</CapsMarkdown>;
151
+ }
@@ -0,0 +1,98 @@
1
+ import type { Activity } from "../shared/contract.js";
2
+ import { AgentMark, AgentName, type AgentColors } from "./parts.js";
3
+ import { relativeTime } from "./format.js";
4
+
5
+ const activityWindowMs = 15 * 60_000;
6
+
7
+ function refLabel(ref: string): string {
8
+ return ref.replace(/^refs\/heads\//u, "").replace(/^refs\/tags\//u, "");
9
+ }
10
+
11
+ /** Agents who pushed in this repository family in the last 15 minutes. */
12
+ export function workingNow(events: readonly Activity[]) {
13
+ const since = Date.now() - activityWindowMs;
14
+ const byAgent = new Map<
15
+ string,
16
+ { repo: string; ref: string; at: number; paths: Set<string> }
17
+ >();
18
+ for (const event of events) {
19
+ if (event.type !== "push" || event.at < since) continue;
20
+ const current = byAgent.get(event.actor);
21
+ const ref = refLabel(event.data.updates[0]?.ref ?? "");
22
+ if (!current)
23
+ byAgent.set(event.actor, {
24
+ repo: event.repo,
25
+ ref,
26
+ at: event.at,
27
+ paths: new Set(event.data.paths),
28
+ });
29
+ else for (const path of event.data.paths) current.paths.add(path);
30
+ }
31
+ return [...byAgent.entries()].map(([agent, value]) => ({ agent, ...value }));
32
+ }
33
+
34
+ /** Expire recorded activity even when the cached event page stays unchanged. */
35
+ export function useWorkingNow(events: readonly Activity[]) {
36
+ const [, refresh] = useReducer((value: number) => value + 1, 0);
37
+ const current = workingNow(events);
38
+ const expiresAt = current.length
39
+ ? Math.min(...current.map((item) => item.at)) + activityWindowMs + 1
40
+ : null;
41
+ useEffect(() => {
42
+ if (expiresAt === null) return;
43
+ const timeout = setTimeout(refresh, Math.max(0, expiresAt - Date.now()));
44
+ return () => {
45
+ clearTimeout(timeout);
46
+ };
47
+ }, [expiresAt]);
48
+ return current;
49
+ }
50
+
51
+ function areaOf(path: string): string {
52
+ const parts = path.split("/");
53
+ return parts.length > 1 ? `${parts.slice(0, -1).join("/")}/` : path;
54
+ }
55
+
56
+ export function WorkingNow({
57
+ events,
58
+ colors,
59
+ }: {
60
+ events: readonly Activity[];
61
+ colors: AgentColors;
62
+ }) {
63
+ const working = useWorkingNow(events);
64
+ if (working.length === 0) return null;
65
+ return (
66
+ <ul className="bonsai-working">
67
+ {working.map((item) => {
68
+ const areas = [...new Set([...item.paths].map(areaOf))].slice(0, 4);
69
+ return (
70
+ <li key={item.agent} data-agent-slot={colors.get(item.agent)?.color}>
71
+ <AgentMark name={item.agent} colors={colors} />
72
+ <div>
73
+ <p>
74
+ <AgentName name={item.agent} colors={colors} />{" "}
75
+ <span className="text-sm text-[var(--stipe-fg-muted)] bonsai-muted">
76
+ on
77
+ </span>{" "}
78
+ <span className="font-mono text-sm bonsai-ref">
79
+ {item.repo.includes("--") ? `${item.agent}/` : ""}
80
+ {item.ref}
81
+ </span>
82
+ </p>
83
+ <p className="text-xs bonsai-working-areas">
84
+ {areas.map((area) => (
85
+ <code key={area}>{area}</code>
86
+ ))}
87
+ </p>
88
+ </div>
89
+ <time className="text-xs text-[var(--stipe-fg-muted)] bonsai-feed-time">
90
+ {relativeTime(item.at)}
91
+ </time>
92
+ </li>
93
+ );
94
+ })}
95
+ </ul>
96
+ );
97
+ }
98
+ import { useEffect, useReducer } from "react";