import type { Brand, DashboardTransport, ProjectOption } from "./contracts.ts"; import { ApiError } from "./transport.ts"; import { assertState, canArchive, blockerGatesReview, blockedLabel, reviewGate, reviewState, type ViewModel } from "./model.ts"; import { createElement } from "react"; import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; import { DashboardView, type FramePresentation, type Section } from "./view.tsx"; export interface DashboardOptions { transport: DashboardTransport; brand: Brand; projects?: ProjectOption[]; initialProject?: string; /** Only the fixture entry sets this. Hosts should leave it false. */ fixture?: boolean; agent?: string; owner?: boolean; openPrompt?(prompt: string): Promise; framePresentation?: FramePresentation; ownerLink?: string; } /** The same view mounts in the web shell, sidebar app or gadget with a host transport. */ export function mountDashboard( root: HTMLElement, options: DashboardOptions, ): { refresh(): Promise; dispose(): void } { const projects = [...(options.projects ?? [])]; const initial = options.initialProject ?? projects[0]?.id ?? ""; if (initial && !projects.some((project) => project.id === initial)) projects.push({ id: initial, label: initial }); const view: ViewModel = { brand: options.brand, projects, project: initial, fixture: options.fixture ?? false, activity: [], busy: false, }; let generation = 0; let disposed = false; let unsubscribe: (() => void) | undefined; let invalidated = false; const react = createRoot(root); const window = root.ownerDocument.defaultView!; const sectionFromHash = (): Section => { const hash = window.location.hash.slice(1).split("/")[0]; if (hash === "race") return "work"; return ["overview", "work", "review", "agents", "races"].includes(hash) ? (hash as Section) : "overview"; }; const reviewFromHash = () => { const match = /^#review\/(.+)$/.exec(window.location.hash); // Older capability hosts retain the queue; bookmarks must not open a dead-end detail. if (match && !options.transport.getReview) { try { window.history.replaceState(null, "", "#review"); } catch { window.location.hash = "review"; } return undefined; } try { return match ? decodeURIComponent(match[1]) : undefined; } catch { return undefined; } }; const raceFromHash = () => { const match = /^#race\/(.+)$/.exec(window.location.hash); try { return match ? decodeURIComponent(match[1]) : undefined; } catch { return undefined; } }; let selectedRaceId = raceFromHash(); let section = sectionFromHash(); const raceReturnFromHistory = (): "work" | "races" => window.history.state?.raceReturnSection === "races" ? "races" : "work"; let raceReturnSection = raceReturnFromHistory(); if (selectedRaceId) section = raceReturnSection; let selectedReviewId = reviewFromHash(); function openReview(id?: string) { if (id && !options.transport.getReview) return; section = "review"; selectedReviewId = id; selectedRaceId = undefined; const hash = id ? `review/${encodeURIComponent(id)}` : "review"; try { window.history.pushState(null, "", `#${hash}`); } catch { window.location.hash = hash; } draw(); } function openRace(id?: string) { const previous = selectedRaceId; if (id && !previous) raceReturnSection = section === "races" ? "races" : "work"; section = raceReturnSection; selectedRaceId = id; selectedReviewId = undefined; const hash = id ? `race/${encodeURIComponent(id)}` : raceReturnSection; try { window.history.pushState(id ? { raceReturnSection } : null, "", `#${hash}`); } catch { window.location.hash = hash; } draw(); if (!id && previous) requestAnimationFrame(() => { if (raceReturnSection === "races") { [...root.querySelectorAll("[data-race-packet]")] .find((row) => row.dataset.racePacket === previous) ?.querySelector(".race-list-link")?.focus(); return; } [...root.querySelectorAll("[data-packet-id]")] .find((row) => row.dataset.packetId === previous) ?.querySelector(".packet-link") ?.focus(); }); } function setSection(value: Section) { section = value; selectedRaceId = undefined; selectedReviewId = undefined; // srcdoc inherits the OS URL; never let native anchors navigate its frame. try { window.history.replaceState(null, "", `#${value}`); } catch { // Opaque srcdoc rejects History API URLs, but same-document fragments are allowed. window.location.hash = value; } draw(); } function onHash() { const previousRaceId = selectedRaceId; section = sectionFromHash(); selectedReviewId = reviewFromHash(); selectedRaceId = raceFromHash(); if (selectedRaceId) { // The opaque-frame hash fallback already recorded its source in memory. if (selectedRaceId !== previousRaceId) raceReturnSection = raceReturnFromHistory(); section = raceReturnSection; } draw(); } window.addEventListener("hashchange", onHash); window.addEventListener("popstate", onHash); function draw() { if (disposed) return; flushSync(() => react.render( createElement(DashboardView, { view: { ...view }, actions: { section, raceId: selectedRaceId, raceBackSection: raceReturnSection, openRace, reviewId: selectedReviewId, openReview, getReview: options.transport.getReview ? (id: string) => options.transport.getReview!(view.project, id) : undefined, getDiff: options.transport.getDiff ? (id: string, path: string) => options.transport.getDiff!(view.project, id, path) : undefined, returnReview: options.owner !== false && options.transport.returnReview ? returnReview : undefined, archivePacket: options.owner !== false && options.transport.archivePacket ? (id: string) => archivePacket("archive", id) : undefined, unarchivePacket: options.owner !== false && options.transport.unarchivePacket ? (id: string) => archivePacket("unarchive", id) : undefined, resolveBlocker: options.owner !== false && options.transport.resolveBlocker ? resolveBlocker : undefined, setSection, selectProject, retry: refresh, reconnect: options.transport.live?.reconnect, owner: options.owner ?? true, agent: options.agent, framePresentation: options.owner === false ? options.framePresentation : undefined, ownerLink: options.owner === false ? options.ownerLink : undefined, ask: options.openPrompt ? () => options.openPrompt!( `Help me review project ${view.project} in ${view.brand.name}.`, ) : undefined, action, getReceipt: options.owner !== false && options.transport.getReceipt ? (review: string) => options.transport.getReceipt!(view.project, review) : undefined, setSettings: options.owner !== false && options.transport.setSettings ? setSettings : undefined, }, }), ), ); } async function load(afterWrite = false) { const current = ++generation; view.refreshing = true; view.error = undefined; draw(); try { const state = await options.transport.getState(view.project); if (current !== generation || disposed) return; const recent = state.recentActivity ?? []; const activity = await options.transport.getActivity( view.project, recent.at(-1)?.sequence ?? 0, ); assertState(state); if (state.project.id !== view.project) throw new Error("The API returned a different project. Check the host adapter."); if (!Array.isArray(activity.activity) || !Number.isSafeInteger(activity.nextAfter)) throw new Error("Invalid activity response."); if (current !== generation || disposed) return; view.state = state; view.activity = [ ...new Map( [...recent, ...activity.activity].map((entry) => [entry.sequence, entry]), ).values(), ] .toSorted((a, b) => a.sequence - b.sequence) .slice(-100); view.updatedAt = new Date().toISOString(); view.stale = false; } catch (error) { if (current !== generation || disposed) return; view.error = error instanceof Error ? error.message : "Unable to load this project."; // Retain evidence, but fence every owner write until a complete read succeeds. view.stale = !!view.state; } finally { if (current === generation && !disposed) { view.refreshing = false; if (afterWrite) view.busy = false; draw(); } } } async function refresh() { if (disposed || !view.project || view.busy || view.refreshing) return; view.notice = undefined; view.noticeReview = undefined; await load(); if (invalidated) { invalidated = false; await refresh(); } } async function selectProject(id: string) { if (options.owner === false && id !== initial) return; if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) || id.length > 48) { view.error = "Project IDs use lowercase letters, digits and internal hyphens (maximum 48 characters)."; draw(); return; } unsubscribe?.(); unsubscribe = undefined; if (!view.projects.some((project) => project.id === id)) view.projects.push({ id, label: id }); view.project = id; view.state = undefined; view.activity = []; view.updatedAt = undefined; view.stale = false; view.notice = undefined; view.noticeReview = undefined; invalidated = false; await load(); if (disposed) return; try { unsubscribe = options.transport.subscribe?.(id, () => { if (view.busy || view.refreshing) invalidated = true; else void refresh(); }); } catch (error) { view.error = error instanceof Error ? error.message : "Unable to subscribe to activity. Try reopening the app."; draw(); } } function beginWrite() { generation++; view.refreshing = false; view.busy = true; } async function action(kind: "approve" | "promote", reviewId: string) { if (options.owner === false || view.busy || view.stale || !view.state) return false; const review = view.state.reviews.find((entry) => entry.id === reviewId); if (!review || !reviewGate(review, view.state)[kind]) return false; beginWrite(); view.error = undefined; view.notice = undefined; view.noticeReview = undefined; draw(); let message: string; try { if (kind === "approve") { await options.transport.approve(view.project, review.id); message = `Approved ${review.sha.slice(0, 7)}`; } else { await options.transport.promote(view.project, review.id); message = `Promoted to ${view.state.project.defaultBranch}`; } } catch (error) { message = error instanceof Error ? error.message : "Owner action failed. Refresh before trying again."; // The exact receipt conflict belongs solely to the open Promote dialog. const handledReceiptFailure = kind === "promote" && view.state?.project.settings?.requireReceipt === true && error instanceof ApiError && error.status === 409 && message === "Checks failed before promote."; // No retry: even a connection failure can follow a successful server-side write. await load(true); if (handledReceiptFailure) throw error; view.error = message; draw(); return false; } await load(true); view.notice = message; view.noticeSha = kind === "approve" ? review.sha.slice(0, 7) : undefined; draw(); if (invalidated) { invalidated = false; await refresh(); } return true; } async function archivePacket(kind: "archive" | "unarchive", id: string) { const write = kind === "archive" ? options.transport.archivePacket : options.transport.unarchivePacket; if (options.owner === false || view.busy || view.stale || !view.state || !write) return false; const packet = view.state.packets.find((entry) => entry.id === id); if (!packet || (kind === "archive" ? !canArchive(view.state, id) : !packet.archived)) return false; beginWrite(); view.notice = undefined; view.noticeSha = undefined; draw(); try { await write(view.project, id); } catch (error) { // Re-fetch only. The panel owns sanitized action errors, including uncertain writes. await load(true); throw error; } await load(true); view.notice = `${kind === "archive" ? "Archived" : "Unarchived"} ${id}`; draw(); return true; } async function returnReview(id: string, reason: string) { if (options.owner === false || view.busy || view.stale || !view.state || !options.transport.returnReview) return false; const review = view.state.reviews.find((r) => r.id === id); const fork = view.state.forks.find((f) => f.repoName === review?.repoName); if ( !review || reviewState(review, view.state).kind !== "current" || review.status === "stale" || review.status === "promoted" || fork?.head !== review.sha || reason.length < 1 || reason.length > 2000 ) return false; beginWrite(); view.notice = undefined; view.noticeReview = undefined; draw(); try { await options.transport.returnReview(view.project, id, reason); } catch (error) { // Refresh reads only: an uncertain write is never retried. Dialog owns its error. await load(true); throw error; } await load(true); view.notice = `Returned to ${review.agent}`; view.noticeSha = undefined; draw(); return true; } async function resolveBlocker(id: string, reason: string) { if (options.owner === false || view.busy || view.stale || !view.state || !options.transport.resolveBlocker || reason.trim().length < 1 || reason.length > 200) throw new Error("Collision action unavailable."); // The server is idempotent: keep an opened dialog usable if a claim ends meanwhile. const blocker = view.state.blockers.find((b) => b.id === id); const project = view.project; if (!blocker) throw new ApiError(404, "Collision unavailable."); beginWrite(); view.notice = undefined; view.noticeReview = undefined; view.noticeSha = undefined; draw(); let result; try { result = await options.transport.resolveBlocker(project, id, reason); if (!result.blocker.resolved) throw new Error("Resolution missing from response."); } catch (error) { // Reads only after uncertain writes. The dialog owns safe error copy. if (!disposed && project === view.project) await load(true); throw error; } if (disposed || project !== view.project) return result.blocker; await load(true); if (disposed || project !== view.project) return result.blocker; const resolution = result.blocker.resolved!; const blocked = blockedLabel(blocker); view.noticeReview = view.state?.reviews.find((r) => blockerGatesReview(blocker, r) && reviewState(r, view.state!).kind === "current" && r.status !== "stale" && view.state!.forks.some((f) => f.repoName === r.repoName && f.head === r.sha))?.id; view.notice = resolution.by === "owner" ? `Marked resolved: ${blocked} is unblocked` : `${blocked} was already unblocked when a claim was ${resolution.by === "promote" ? "promoted" : "released"}.`; draw(); return result.blocker; } async function setSettings(requireReceipt: boolean) { if (options.owner === false || view.busy || view.stale || !options.transport.setSettings || !view.state) return; beginWrite(); view.error = undefined; view.notice = undefined; view.noticeReview = undefined; draw(); try { await options.transport.setSettings(view.project, { requireReceipt }); await load(true); view.notice = "Receipt settings updated"; view.noticeSha = undefined; } catch (error) { await load(true); view.error = error instanceof Error ? error.message : "Unable to update settings. Refresh and try again."; } draw(); } const stopLive = options.transport.live?.subscribe((paused) => { view.livePaused = paused; draw(); }); // One quiet clock updates the stamp and stale-control tooltips together, without reads. const freshnessClock = setInterval(() => { if (view.state) draw(); }, 30_000); draw(); if (initial) void selectProject(initial); return { refresh, dispose() { disposed = true; generation++; unsubscribe?.(); stopLive?.(); clearInterval(freshnessClock); window.removeEventListener("hashchange", onHash); window.removeEventListener("popstate", onHash); flushSync(() => react.unmount()); }, }; }