Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-dashboard/src/dashboard.ts
Snapshot at 34e3175
1import type { Brand, DashboardTransport, ProjectOption } from "./contracts.ts";
2import { ApiError } from "./transport.ts";
3import { assertState, canArchive, blockerGatesReview, blockedLabel, reviewGate, reviewState, type ViewModel } from "./model.ts";
4import { createElement } from "react";
5import { createRoot } from "react-dom/client";
6import { flushSync } from "react-dom";
7import { DashboardView, type FramePresentation, type Section } from "./view.tsx";
8
9export interface DashboardOptions {
10 transport: DashboardTransport;
11 brand: Brand;
12 projects?: ProjectOption[];
13 initialProject?: string;
14 /** Only the fixture entry sets this. Hosts should leave it false. */
15 fixture?: boolean;
16 agent?: string;
17 owner?: boolean;
18 openPrompt?(prompt: string): Promise<void>;
19 framePresentation?: FramePresentation;
20 ownerLink?: string;
21}
22
23/** The same view mounts in the web shell, sidebar app or gadget with a host transport. */
24export function mountDashboard(
25 root: HTMLElement,
26 options: DashboardOptions,
27): { refresh(): Promise<void>; dispose(): void } {
28 const projects = [...(options.projects ?? [])];
29 const initial = options.initialProject ?? projects[0]?.id ?? "";
30 if (initial && !projects.some((project) => project.id === initial))
31 projects.push({ id: initial, label: initial });
32 const view: ViewModel = {
33 brand: options.brand,
34 projects,
35 project: initial,
36 fixture: options.fixture ?? false,
37 activity: [],
38 busy: false,
39 };
40 let generation = 0;
41 let disposed = false;
42 let unsubscribe: (() => void) | undefined;
43 let invalidated = false;
44
45 const react = createRoot(root);
46 const window = root.ownerDocument.defaultView!;
47 const sectionFromHash = (): Section => {
48 const hash = window.location.hash.slice(1).split("/")[0];
49 if (hash === "race") return "work";
50 return ["overview", "work", "review", "agents", "races"].includes(hash) ? (hash as Section) : "overview";
51 };
52 const reviewFromHash = () => {
53 const match = /^#review\/(.+)$/.exec(window.location.hash);
54 // Older capability hosts retain the queue; bookmarks must not open a dead-end detail.
55 if (match && !options.transport.getReview) {
56 try {
57 window.history.replaceState(null, "", "#review");
58 } catch {
59 window.location.hash = "review";
60 }
61 return undefined;
62 }
63 try {
64 return match ? decodeURIComponent(match[1]) : undefined;
65 } catch {
66 return undefined;
67 }
68 };
69 const raceFromHash = () => {
70 const match = /^#race\/(.+)$/.exec(window.location.hash);
71 try {
72 return match ? decodeURIComponent(match[1]) : undefined;
73 } catch {
74 return undefined;
75 }
76 };
77 let selectedRaceId = raceFromHash();
78 let section = sectionFromHash();
79 const raceReturnFromHistory = (): "work" | "races" =>
80 window.history.state?.raceReturnSection === "races" ? "races" : "work";
81 let raceReturnSection = raceReturnFromHistory();
82 if (selectedRaceId) section = raceReturnSection;
83 let selectedReviewId = reviewFromHash();
84 function openReview(id?: string) {
85 if (id && !options.transport.getReview) return;
86 section = "review";
87 selectedReviewId = id;
88 selectedRaceId = undefined;
89 const hash = id ? `review/${encodeURIComponent(id)}` : "review";
90 try {
91 window.history.pushState(null, "", `#${hash}`);
92 } catch {
93 window.location.hash = hash;
94 }
95 draw();
96 }
97 function openRace(id?: string) {
98 const previous = selectedRaceId;
99 if (id && !previous) raceReturnSection = section === "races" ? "races" : "work";
100 section = raceReturnSection;
101 selectedRaceId = id;
102 selectedReviewId = undefined;
103 const hash = id ? `race/${encodeURIComponent(id)}` : raceReturnSection;
104 try {
105 window.history.pushState(id ? { raceReturnSection } : null, "", `#${hash}`);
106 } catch {
107 window.location.hash = hash;
108 }
109 draw();
110 if (!id && previous)
111 requestAnimationFrame(() => {
112 if (raceReturnSection === "races") {
113 [...root.querySelectorAll<HTMLElement>("[data-race-packet]")]
114 .find((row) => row.dataset.racePacket === previous)
115 ?.querySelector<HTMLElement>(".race-list-link")?.focus();
116 return;
117 }
118 [...root.querySelectorAll<HTMLElement>("[data-packet-id]")]
119 .find((row) => row.dataset.packetId === previous)
120 ?.querySelector<HTMLElement>(".packet-link")
121 ?.focus();
122 });
123 }
124 function setSection(value: Section) {
125 section = value;
126 selectedRaceId = undefined;
127 selectedReviewId = undefined;
128 // srcdoc inherits the OS URL; never let native anchors navigate its frame.
129 try {
130 window.history.replaceState(null, "", `#${value}`);
131 } catch {
132 // Opaque srcdoc rejects History API URLs, but same-document fragments are allowed.
133 window.location.hash = value;
134 }
135 draw();
136 }
137 function onHash() {
138 const previousRaceId = selectedRaceId;
139 section = sectionFromHash();
140 selectedReviewId = reviewFromHash();
141 selectedRaceId = raceFromHash();
142 if (selectedRaceId) {
143 // The opaque-frame hash fallback already recorded its source in memory.
144 if (selectedRaceId !== previousRaceId) raceReturnSection = raceReturnFromHistory();
145 section = raceReturnSection;
146 }
147 draw();
148 }
149 window.addEventListener("hashchange", onHash);
150 window.addEventListener("popstate", onHash);
151 function draw() {
152 if (disposed) return;
153 flushSync(() =>
154 react.render(
155 createElement(DashboardView, {
156 view: { ...view },
157 actions: {
158 section,
159 raceId: selectedRaceId,
160 raceBackSection: raceReturnSection,
161 openRace,
162 reviewId: selectedReviewId,
163 openReview,
164 getReview: options.transport.getReview
165 ? (id: string) => options.transport.getReview!(view.project, id)
166 : undefined,
167 getDiff: options.transport.getDiff
168 ? (id: string, path: string) => options.transport.getDiff!(view.project, id, path)
169 : undefined,
170 returnReview:
171 options.owner !== false && options.transport.returnReview ? returnReview : undefined,
172 archivePacket:
173 options.owner !== false && options.transport.archivePacket
174 ? (id: string) => archivePacket("archive", id) : undefined,
175 unarchivePacket:
176 options.owner !== false && options.transport.unarchivePacket
177 ? (id: string) => archivePacket("unarchive", id) : undefined,
178 resolveBlocker:
179 options.owner !== false && options.transport.resolveBlocker ? resolveBlocker : undefined,
180 setSection,
181 selectProject,
182 retry: refresh,
183 reconnect: options.transport.live?.reconnect,
184 owner: options.owner ?? true,
185 agent: options.agent,
186 framePresentation: options.owner === false ? options.framePresentation : undefined,
187 ownerLink: options.owner === false ? options.ownerLink : undefined,
188 ask: options.openPrompt
189 ? () =>
190 options.openPrompt!(
191 `Help me review project ${view.project} in ${view.brand.name}.`,
192 )
193 : undefined,
194 action,
195 getReceipt:
196 options.owner !== false && options.transport.getReceipt
197 ? (review: string) => options.transport.getReceipt!(view.project, review)
198 : undefined,
199 setSettings:
200 options.owner !== false && options.transport.setSettings ? setSettings : undefined,
201 },
202 }),
203 ),
204 );
205 }
206
207 async function load(afterWrite = false) {
208 const current = ++generation;
209 view.refreshing = true;
210 view.error = undefined;
211 draw();
212 try {
213 const state = await options.transport.getState(view.project);
214 if (current !== generation || disposed) return;
215 const recent = state.recentActivity ?? [];
216 const activity = await options.transport.getActivity(
217 view.project,
218 recent.at(-1)?.sequence ?? 0,
219 );
220 assertState(state);
221 if (state.project.id !== view.project)
222 throw new Error("The API returned a different project. Check the host adapter.");
223 if (!Array.isArray(activity.activity) || !Number.isSafeInteger(activity.nextAfter))
224 throw new Error("Invalid activity response.");
225 if (current !== generation || disposed) return;
226 view.state = state;
227 view.activity = [
228 ...new Map(
229 [...recent, ...activity.activity].map((entry) => [entry.sequence, entry]),
230 ).values(),
231 ]
232 .toSorted((a, b) => a.sequence - b.sequence)
233 .slice(-100);
234 view.updatedAt = new Date().toISOString();
235 view.stale = false;
236 } catch (error) {
237 if (current !== generation || disposed) return;
238 view.error = error instanceof Error ? error.message : "Unable to load this project.";
239 // Retain evidence, but fence every owner write until a complete read succeeds.
240 view.stale = !!view.state;
241 } finally {
242 if (current === generation && !disposed) {
243 view.refreshing = false;
244 if (afterWrite) view.busy = false;
245 draw();
246 }
247 }
248 }
249
250 async function refresh() {
251 if (disposed || !view.project || view.busy || view.refreshing) return;
252 view.notice = undefined;
253 view.noticeReview = undefined;
254 await load();
255 if (invalidated) {
256 invalidated = false;
257 await refresh();
258 }
259 }
260
261 async function selectProject(id: string) {
262 if (options.owner === false && id !== initial) return;
263 if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) || id.length > 48) {
264 view.error =
265 "Project IDs use lowercase letters, digits and internal hyphens (maximum 48 characters).";
266 draw();
267 return;
268 }
269 unsubscribe?.();
270 unsubscribe = undefined;
271 if (!view.projects.some((project) => project.id === id)) view.projects.push({ id, label: id });
272 view.project = id;
273 view.state = undefined;
274 view.activity = [];
275 view.updatedAt = undefined;
276 view.stale = false;
277 view.notice = undefined;
278 view.noticeReview = undefined;
279 invalidated = false;
280 await load();
281 if (disposed) return;
282 try {
283 unsubscribe = options.transport.subscribe?.(id, () => {
284 if (view.busy || view.refreshing) invalidated = true;
285 else void refresh();
286 });
287 } catch (error) {
288 view.error =
289 error instanceof Error
290 ? error.message
291 : "Unable to subscribe to activity. Try reopening the app.";
292 draw();
293 }
294 }
295
296 function beginWrite() {
297 generation++;
298 view.refreshing = false;
299 view.busy = true;
300 }
301
302 async function action(kind: "approve" | "promote", reviewId: string) {
303 if (options.owner === false || view.busy || view.stale || !view.state) return false;
304 const review = view.state.reviews.find((entry) => entry.id === reviewId);
305 if (!review || !reviewGate(review, view.state)[kind]) return false;
306 beginWrite();
307 view.error = undefined;
308 view.notice = undefined;
309 view.noticeReview = undefined;
310 draw();
311 let message: string;
312 try {
313 if (kind === "approve") {
314 await options.transport.approve(view.project, review.id);
315 message = `Approved ${review.sha.slice(0, 7)}`;
316 } else {
317 await options.transport.promote(view.project, review.id);
318 message = `Promoted to ${view.state.project.defaultBranch}`;
319 }
320 } catch (error) {
321 message =
322 error instanceof Error
323 ? error.message
324 : "Owner action failed. Refresh before trying again.";
325 // The exact receipt conflict belongs solely to the open Promote dialog.
326 const handledReceiptFailure =
327 kind === "promote" &&
328 view.state?.project.settings?.requireReceipt === true &&
329 error instanceof ApiError &&
330 error.status === 409 &&
331 message === "Checks failed before promote.";
332 // No retry: even a connection failure can follow a successful server-side write.
333 await load(true);
334 if (handledReceiptFailure) throw error;
335 view.error = message;
336 draw();
337 return false;
338 }
339 await load(true);
340 view.notice = message;
341 view.noticeSha = kind === "approve" ? review.sha.slice(0, 7) : undefined;
342 draw();
343 if (invalidated) {
344 invalidated = false;
345 await refresh();
346 }
347 return true;
348 }
349
350 async function archivePacket(kind: "archive" | "unarchive", id: string) {
351 const write = kind === "archive"
352 ? options.transport.archivePacket : options.transport.unarchivePacket;
353 if (options.owner === false || view.busy || view.stale || !view.state || !write) return false;
354 const packet = view.state.packets.find((entry) => entry.id === id);
355 if (!packet || (kind === "archive" ? !canArchive(view.state, id) : !packet.archived)) return false;
356 beginWrite();
357 view.notice = undefined;
358 view.noticeSha = undefined;
359 draw();
360 try {
361 await write(view.project, id);
362 } catch (error) {
363 // Re-fetch only. The panel owns sanitized action errors, including uncertain writes.
364 await load(true);
365 throw error;
366 }
367 await load(true);
368 view.notice = `${kind === "archive" ? "Archived" : "Unarchived"} ${id}`;
369 draw();
370 return true;
371 }
372
373 async function returnReview(id: string, reason: string) {
374 if (options.owner === false || view.busy || view.stale || !view.state || !options.transport.returnReview)
375 return false;
376 const review = view.state.reviews.find((r) => r.id === id);
377 const fork = view.state.forks.find((f) => f.repoName === review?.repoName);
378 if (
379 !review ||
380 reviewState(review, view.state).kind !== "current" ||
381 review.status === "stale" ||
382 review.status === "promoted" ||
383 fork?.head !== review.sha ||
384 reason.length < 1 ||
385 reason.length > 2000
386 )
387 return false;
388 beginWrite();
389 view.notice = undefined;
390 view.noticeReview = undefined;
391 draw();
392 try {
393 await options.transport.returnReview(view.project, id, reason);
394 } catch (error) {
395 // Refresh reads only: an uncertain write is never retried. Dialog owns its error.
396 await load(true);
397 throw error;
398 }
399 await load(true);
400 view.notice = `Returned to ${review.agent}`;
401 view.noticeSha = undefined;
402 draw();
403 return true;
404 }
405
406 async function resolveBlocker(id: string, reason: string) {
407 if (options.owner === false || view.busy || view.stale || !view.state || !options.transport.resolveBlocker ||
408 reason.trim().length < 1 || reason.length > 200)
409 throw new Error("Collision action unavailable.");
410 // The server is idempotent: keep an opened dialog usable if a claim ends meanwhile.
411 const blocker = view.state.blockers.find((b) => b.id === id);
412 const project = view.project;
413 if (!blocker) throw new ApiError(404, "Collision unavailable.");
414 beginWrite();
415 view.notice = undefined;
416 view.noticeReview = undefined;
417 view.noticeSha = undefined;
418 draw();
419 let result;
420 try {
421 result = await options.transport.resolveBlocker(project, id, reason);
422 if (!result.blocker.resolved) throw new Error("Resolution missing from response.");
423 } catch (error) {
424 // Reads only after uncertain writes. The dialog owns safe error copy.
425 if (!disposed && project === view.project) await load(true);
426 throw error;
427 }
428 if (disposed || project !== view.project) return result.blocker;
429 await load(true);
430 if (disposed || project !== view.project) return result.blocker;
431 const resolution = result.blocker.resolved!;
432 const blocked = blockedLabel(blocker);
433 view.noticeReview = view.state?.reviews.find((r) => blockerGatesReview(blocker, r) &&
434 reviewState(r, view.state!).kind === "current" && r.status !== "stale" &&
435 view.state!.forks.some((f) => f.repoName === r.repoName && f.head === r.sha))?.id;
436 view.notice = resolution.by === "owner"
437 ? `Marked resolved: ${blocked} is unblocked`
438 : `${blocked} was already unblocked when a claim was ${resolution.by === "promote" ? "promoted" : "released"}.`;
439 draw();
440 return result.blocker;
441 }
442
443 async function setSettings(requireReceipt: boolean) {
444 if (options.owner === false || view.busy || view.stale || !options.transport.setSettings || !view.state)
445 return;
446 beginWrite();
447 view.error = undefined;
448 view.notice = undefined;
449 view.noticeReview = undefined;
450 draw();
451 try {
452 await options.transport.setSettings(view.project, { requireReceipt });
453 await load(true);
454 view.notice = "Receipt settings updated";
455 view.noticeSha = undefined;
456 } catch (error) {
457 await load(true);
458 view.error =
459 error instanceof Error
460 ? error.message
461 : "Unable to update settings. Refresh and try again.";
462 }
463 draw();
464 }
465
466 const stopLive = options.transport.live?.subscribe((paused) => {
467 view.livePaused = paused;
468 draw();
469 });
470 // One quiet clock updates the stamp and stale-control tooltips together, without reads.
471 const freshnessClock = setInterval(() => { if (view.state) draw(); }, 30_000);
472 draw();
473 if (initial) void selectProject(initial);
474 return {
475 refresh,
476 dispose() {
477 disposed = true;
478 generation++;
479 unsubscribe?.();
480 stopLive?.();
481 clearInterval(freshnessClock);
482 window.removeEventListener("hashchange", onHash);
483 window.removeEventListener("popstate", onHash);
484 flushSync(() => react.unmount());
485 },
486 };
487}
488