UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

334 lines (333 loc) 13.2 kB
"use strict"; /** * Alpine watcher registration for the dashboard app. */ function dashboardResizeTerminalRef(sessionId, refs, xterm) { const container = document.getElementById(`gf-terminal-${sessionId}`); if (!container || container.offsetWidth === 0) return false; xterm._addonFit?.fit(); if (refs.ws?.readyState === WebSocket.OPEN) { refs.ws.send(JSON.stringify({ type: "resize", cols: xterm.cols, rows: xterm.rows })); } return true; } /** Whether entering this view should warm xterm assets before a terminal is attached. */ function dashboardShouldWarmXterm(ctx, view) { return (view === "workspace" || view === "setup") && ctx.terminalAvailable; } /** Fire-and-forget xterm warmup keeps navigation responsive; terminal overlays report failures. */ function dashboardWarmXtermForView(ctx, view) { if (!dashboardShouldWarmXterm(ctx, view)) return; void ctx.loadXterm().catch(() => { }); } /** Return the xterm handle only when it can be fitted by the addon. */ function dashboardRefitCapableXterm(refs) { const xterm = refs?.xterm; return xterm?._addonFit ? xterm : null; } /** Send the current xterm dimensions over an open backend WebSocket. */ function dashboardSendTerminalResize(ws, xterm) { if (ws?.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ type: "resize", cols: xterm.cols, rows: xterm.rows, })); } /** Fit a terminal and notify the backend of its new dimensions. */ function dashboardFitTerminalWithResize(xterm, ws) { xterm._addonFit?.fit(); dashboardSendTerminalResize(ws, xterm); } /** Retry active-view refits until the freshly-shown terminal container has layout width. */ function dashboardPollActiveTerminalRefit(ctx, refs, xterm, attempts = 0) { if (attempts > TERMINAL_REFIT_MAX_ATTEMPTS) return; requestAnimationFrame(() => { if (!dashboardResizeTerminalRef(ctx.activeSessionId ?? "", refs, xterm)) { setTimeout(() => { dashboardPollActiveTerminalRefit(ctx, refs, xterm, attempts + 1); }, TERMINAL_REFIT_RETRY_DELAY_MS); } }); } /** Refit the active terminal after the workspace view becomes visible. */ function dashboardRefitWorkspaceViewTerminal(ctx, view) { if (view !== "workspace" || !ctx.activeSessionId) return; const refs = ctx._terminalRefs[ctx.activeSessionId]; const xterm = dashboardRefitCapableXterm(refs); if (!refs || !xterm) return; void ctx.$nextTick(() => { dashboardPollActiveTerminalRefit(ctx, refs, xterm); }); } /** Refit the active terminal when the workspace panel switches back to the terminal tab. */ function dashboardRefitWorkspacePanelTerminal(ctx, view) { const xterm = ctx._terminalXterm; if (view !== "terminal" || !xterm?._addonFit) return; requestAnimationFrame(() => { dashboardFitTerminalWithResize(xterm, ctx._terminalWs); }); } /** Refit and focus a newly selected terminal tab after Alpine has rendered it. */ function dashboardRefitSelectedTerminal(ctx, id) { if (!id) return; const refs = ctx._terminalRefs[id]; const xterm = dashboardRefitCapableXterm(refs); if (!refs || !xterm) return; void ctx.$nextTick(() => { requestAnimationFrame(() => { dashboardFitTerminalWithResize(xterm, refs.ws); xterm.focus(); }); }); } /** * Reset all skill-quality view state to empty, aborting any in-flight evaluation request first. * Called when the runner or project changes so a stale report/inventory never lingers across a * switch. Bumps skillQualityPrefetchGeneration so any prefetch that resolves after this reset is * recognised as stale by its generation check and discarded rather than applied. */ function dashboardResetSkillQualityState(ctx) { ctx.skillQualityAbortController?.abort(); ctx.skillQualityAbortController = null; ctx.skillQualityArtifacts = []; ctx.skillQualitySelectedId = null; ctx.skillQualityReport = null; ctx.skillQualityLoading = false; ctx.skillQualityReports = {}; ctx.skillQualityAuditedAt = null; ctx.skillQualityPrefetching = false; ctx.skillQualityPrefetchGeneration = Number(ctx.skillQualityPrefetchGeneration) + 1; } /** * Register the Alpine watchers that keep the xterm terminal sized and focused as the view changes. * Watches activeView/workspacePanel/activeSessionId and, on each relevant change, refits the active * terminal and pushes the new cols/rows to the backend over the open WebSocket. The refit is done * inside requestAnimationFrame (and a bounded retry poll for activeView) because a freshly-shown * panel has zero width until the browser lays it out; measuring too early yields a 0-size fit. The * lazy `loadXterm()` triggered on view entry swallows its rejection - a failed asset load must not * break view switching, and the terminal's own loading overlay reports the failure to the user. */ function dashboardRegisterTerminalWatchers(ctx) { ctx.$watch("activeView", (view) => { dashboardWarmXtermForView(ctx, view); dashboardRefitWorkspaceViewTerminal(ctx, view); }); ctx.$watch("workspacePanel", (view) => { dashboardRefitWorkspacePanelTerminal(ctx, view); }); ctx.$watch("activeSessionId", (id) => { dashboardRefitSelectedTerminal(ctx, id); }); } /** * Register the watchers that lazy-load each view's data when the user navigates to it and react to * the quality filters. Entering a view triggers its loader (audit/quality/skills/setup/plans/hooks); * the per-view fan-out is intentional because data is fetched on demand rather than all at once on * boot, keeping the initial render cheap. The workspace view additionally starts a 10s session-count * poll that is cleared on every activeView change, because leaving the workspace must stop the * interval so only one poll is ever live and a backgrounded view does not keep hitting the server. */ function dashboardRegisterViewWatchers(ctx) { ctx.$watch("activeView", (view) => { if (ctx._workspacePoll) { clearInterval(ctx._workspacePoll); ctx._workspacePoll = null; } if (["home", "projects", "workspace", "prompts"].includes(view)) { void ctx.updateSessionCount(); } if (view === "home") void ctx.generateHomeQualitySummary(); if (view === "workspace") { ctx._workspacePoll = setInterval(() => { void ctx.updateSessionCount(); }, 10_000); } if (view === "quality") { void ctx.generateQuality({ fast: true }); ctx.scheduleQualityHistory(); } if (view === "skills") void ctx.loadSkillQualityInventory(); if (view === "setup") { void ctx.detectStack(); ctx.scheduleSetupPrompt(); } if (view === "plans") void ctx.loadTasks(); if (view === "hooks") void ctx.loadHooks(); }); ctx.$watch("qualityAgent", () => { if (ctx.activeView === "quality") { void ctx.generateQuality({ fast: true }); ctx.scheduleQualityHistory(); } }); ctx.$watch("selectedQualityModeId", () => { if (ctx.activeView === "quality") { void ctx.generateQuality({ fast: true }); ctx.scheduleQualityHistory(); } }); } function dashboardRegisterRunnerAndProjectWatchers(ctx) { ctx.$watch("activeRunner", () => { if (ctx.activeView === "home") void ctx.generateHomeQualitySummary(); if (ctx.activeView === "skills") { dashboardResetSkillQualityState(ctx); void ctx.loadSkillQualityInventory(); } }); ctx.$watch("sessionsCollapsed", (value) => { localStorage.setItem("gf-sessions-collapsed", String(value)); }); const updateTitle = () => { document.title = `${ctx.projectName} | GOAT Flow`; }; ctx.$watch("projectPath", (newPath, oldPath) => { updateTitle(); if (!oldPath || newPath === oldPath) return; ctx.detachTerminal(oldPath); void ctx.reconnectTerminal(); void ctx.updateSessionCount(); if (ctx.activeView === "quality") { void ctx.generateQuality({ fast: true }); ctx.scheduleQualityHistory(); } if (ctx.activeView === "setup") { void ctx.detectStack(); ctx.scheduleSetupPrompt(); } if (ctx.activeView === "home") void ctx.generateHomeQualitySummary(); if (ctx.activeView === "plans") { ctx.selectedTaskPlan = null; void ctx.loadTasks(); } if (ctx.activeView === "hooks") void ctx.loadHooks(); dashboardResetSkillQualityState(ctx); if (ctx.activeView === "skills") void ctx.loadSkillQualityInventory(); }); updateTitle(); } function dashboardHandleGlobalShortcut(ctx, event) { if (event.key === "Escape") ctx.showBrowser = false; if (event.key === "D" && event.ctrlKey && event.shiftKey && ctx.activeView === "workspace" && ctx.terminalSessionId) { event.preventDefault(); ctx.exitTerminal(); return true; } if (event.key === "/" && !["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName ?? "")) { if (ctx.activeView === "workspace" && ctx.terminalSessionId && !ctx.terminalEnded) { return true; } event.preventDefault(); ctx.activeView = "prompts"; void ctx.$nextTick(() => { const searchInput = ctx.$refs.presetSearchInput; if (searchInput instanceof HTMLInputElement) searchInput.focus(); }); return true; } return false; } function dashboardHandlePromptShortcut(ctx, event) { if (ctx.activeView !== "prompts") return; if (event.key === "Escape" && ctx.showCustomPromptEditor) { event.preventDefault(); ctx.cancelCustomPromptEdit(); return; } const inputFocused = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName ?? ""); if (!inputFocused) { if (event.key === "ArrowDown") { event.preventDefault(); ctx.selectPresetByOffset(1); } else if (event.key === "ArrowUp") { event.preventDefault(); ctx.selectPresetByOffset(-1); } else if (event.key === "Enter" && ctx.selectedPreset && !ctx.launching && Math.max(ctx.sessions.length, ctx.serverSessions.length) < ctx.serverMaxSessions) { event.preventDefault(); void ctx.launchPreset(ctx.selectedPreset.prompt, ctx.activeRunner, ctx.selectedPreset.name, { presetId: ctx.selectedPreset.id }); } } if (event.key === "Escape") { if (ctx.presetSearch) ctx.presetSearch = ""; else if (ctx.selectedPreset) ctx.selectedPreset = null; } } /** * Wire the single document-level keydown listener that drives the dashboard's keyboard shortcuts. * Global shortcuts are tried first and, when one handles the event, the prompt-view shortcuts are * skipped (the global handler returning true short-circuits) so the two sets never both fire for * one keypress. One listener for the whole app, registered once during init. */ function dashboardRegisterKeyboardShortcuts(ctx) { document.addEventListener("keydown", (event) => { if (dashboardHandleGlobalShortcut(ctx, event)) return; dashboardHandlePromptShortcut(ctx, event); }); } /** * One-shot bootstrap run once when the Alpine app initialises: register every watcher and keyboard * shortcut, apply the persisted dark-mode class, load saved custom prompts and dashboard state, and * kick off the first audit/agent/terminal-availability fetches. The initial network calls are * guarded behind an http(s) protocol check so opening the built HTML from `file://` (no server) * loads the UI without firing requests that would only fail. Side-effecting; returns nothing. */ function dashboardInit(ctx) { ctx.$watch("darkMode", (value) => { localStorage.setItem("gf-dark", String(value)); document.documentElement.classList.toggle("dark", value); }); dashboardRegisterTerminalWatchers(ctx); dashboardRegisterViewWatchers(ctx); dashboardRegisterRunnerAndProjectWatchers(ctx); document.documentElement.classList.toggle("dark", ctx.darkMode); dashboardLoadCustomPrompts(ctx); void ctx._loadSavedDashboardState().then(() => { if (ctx.projectsList.length > 0) void ctx.auditAllProjects(); }); if (location.protocol === "http:" || location.protocol === "https:") { void ctx.runAudit(); void ctx.checkTerminalAvailable(); void ctx.fetchInstalledAgents(); } dashboardRegisterKeyboardShortcuts(ctx); }