Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-dashboard/src/dashboard.ts

Snapshot at 34e3175

Containing directory

17,740 bytes · typescriptView raw
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