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.

436 lines (435 loc) 19.1 kB
"use strict"; /** * Project, terminal, and skill-evaluator action fragments for the dashboard Alpine app. * dashboardMergeAppFragments stitches these into one app object. These fragments own the standalone * skill evaluator (drop/upload/paste markdown, then POST for a quality verdict), project-list * management methods, clipboard + toast utilities, the full terminal-session method surface, and the * small time-formatting helpers. As elsewhere, most methods are thin `this`-bound shims over shared * `dashboard*` helpers; the few with logic inline (clipboard fallback, scrollback export, time * formatting) are self-contained and noted on their own doc comments. */ /** Reset per-run evaluator state and cancel any stale copied-report label timer. */ function dashboardResetSkillEvaluatorRun(ctx) { ctx.skillEvaluatorError = null; ctx.skillEvaluatorResult = null; ctx.skillEvaluatorReportCopied = false; if (ctx._skillEvaluatorReportCopiedTimer) { clearTimeout(ctx._skillEvaluatorReportCopiedTimer); ctx._skillEvaluatorReportCopiedTimer = null; } } /** Return whether the evaluator has markdown from uploaded files or pasted content. */ function dashboardHasSkillEvaluatorInput(ctx) { return (ctx.skillEvaluatorFiles.length > 0 || ctx.skillEvaluatorContent.trim().length > 0); } /** Build the evaluator request body, preferring uploaded files over pasted content. */ function dashboardBuildSkillEvaluatorRequestBody(ctx) { const body = {}; if (ctx.skillEvaluatorFiles.length > 0) { body.files = ctx.skillEvaluatorFiles; } else { body.content = ctx.skillEvaluatorContent; } body.kind = "skill"; const name = ctx.skillEvaluatorName.trim(); if (name.length > 0) body.suggestedName = name; return body; } /** * Run one skill-evaluator request. Network, parse, and API errors are recovered into * skillEvaluatorError so a failed evaluate request reports in-view instead of breaking Alpine. */ async function dashboardRunSkillEvaluator(ctx) { dashboardResetSkillEvaluatorRun(ctx); if (!dashboardHasSkillEvaluatorInput(ctx)) { ctx.skillEvaluatorError = "Drop .md files, upload, or paste markdown first."; return; } ctx.skillEvaluatorLoading = true; // Verdicts depend on the project's quality profile, so a project switch // while the request is in flight must not surface the old project's verdict // or error. Loading still clears in finally; leaving it set would keep the // Evaluate button disabled in the new project. const requestProjectPath = ctx.projectPath; try { const url = `/api/quality/evaluate?path=${encodeURIComponent(requestProjectPath)}`; const res = await dashboardFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(dashboardBuildSkillEvaluatorRequestBody(ctx)), }); const data = readRecord(await res.json(), "Evaluate result"); if (ctx.projectPath !== requestProjectPath) return; const error = readErrorMessage(data); if (error) { ctx.skillEvaluatorError = error; return; } ctx.skillEvaluatorResult = data; } catch (err) { if (ctx.projectPath !== requestProjectPath) return; ctx.skillEvaluatorError = err instanceof Error ? err.message : String(err); } finally { ctx.skillEvaluatorLoading = false; } } /** Dump an xterm buffer and trim trailing blank lines for a readable download. */ function dashboardDumpTerminalBuffer(buf) { const lines = []; for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i); if (line) lines.push(line.translateToString(true)); } while (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } return lines.join("\n"); } /** Build scrollback text, including the alternate screen when a TUI currently owns it. */ function dashboardTerminalScrollbackText(xterm) { const normalText = dashboardDumpTerminalBuffer(xterm.buffer.normal); const altActive = xterm.buffer.active === xterm.buffer.alternate; const altText = altActive ? dashboardDumpTerminalBuffer(xterm.buffer.alternate) : ""; const parts = []; if (normalText) parts.push(normalText); if (altText) { parts.push("", "--- alternate screen (current TUI view) ---", "", altText); } return parts.join("\n"); } /** Download terminal scrollback text using the runner and short session id as the filename. */ function dashboardDownloadTerminalScrollback(runner, sessionId, text) { const shortId = sessionId.slice(0, 8); const blob = new Blob([text], { type: "text/plain" }); const url = URL.createObjectURL(blob); const downloadLink = document.createElement("a"); downloadLink.href = url; downloadLink.download = `${runner}-${shortId}.txt`; downloadLink.click(); URL.revokeObjectURL(url); } /** Export one live xterm tab's scrollback; no download occurs when the tab has no xterm handle. */ function dashboardExportSessionScrollback(ctx, sessionId) { const refs = ctx._terminalRefs[sessionId]; if (!refs?.xterm) return; const session = ctx.sessions.find((s) => s.id === sessionId); const runner = session?.runner ?? "terminal"; dashboardDownloadTerminalScrollback(runner, sessionId, dashboardTerminalScrollbackText(refs.xterm)); } /** * Build the skill-evaluator fragment: drop/remove/run methods for the ad-hoc markdown evaluator. * runSkillEvaluator owns its fetch inline because it has a one-off request/response shape, and it * catches a failure into the evaluator's error field and reports it in-view rather than throwing, so * a bad evaluate request never breaks the app. Merged by dashboardMergeAppFragments. */ function dashboardSkillEvaluatorInputFragment() { return { /** drop handler - read every dropped .md file and append to the list. */ skillEvaluatorDrop(event) { event.preventDefault(); this.skillEvaluatorDragActive = false; const files = event.dataTransfer?.files; if (!files || files.length === 0) return; void this._ingestSkillEvaluatorFiles(files); }, /** Remove one already-attached file by name. */ removeSkillEvaluatorFile(name) { this.skillEvaluatorFiles = this.skillEvaluatorFiles.filter((file) => file.name !== name); }, /** * POST the dropped/pasted markdown to the quality evaluate endpoint and store the verdict. * Returns early (no request) when neither files nor content are present, setting a prompt. * On a fetch/parse failure it does not propagate the error; instead it recovers by writing the * message into skillEvaluatorError and reports it in-view, so a bad request never breaks the * app. Loading state is always cleared in finally. */ async runSkillEvaluator() { await dashboardRunSkillEvaluator(this); }, }; } /** * Build the saved-project action fragment. * * Project actions stay separate from the skill evaluator so this fragment remains a small dispatch * surface over dashboard project helpers. */ function dashboardProjectActionsFragment() { return { // -- Projects -- async addProject() { await dashboardAddProject(this); }, /** Remove a project from the saved workspace list. */ removeProject(path) { dashboardRemoveProject(this, path); }, /** Sort saved projects by the active key and direction. */ sortProjects(key) { dashboardSortProjects(this, key); }, /** Sort projects by visible columns while keeping the derived "name" column first-class. */ get sortedProjectsList() { return dashboardSortedProjectsList(this); }, /** Refresh audit status for every saved project. */ async auditAllProjects() { await dashboardAuditAllProjects(this); }, /** Load saved dashboard state from disk, with localStorage as a migration fallback. */ async _loadSavedDashboardState() { await dashboardLoadSavedDashboardState(this); }, /** Persist the current dashboard state to localStorage and the server store. */ _saveDashboardState() { dashboardSaveDashboardState(this); }, /** Begin editing the current project's title (inline header rename). */ startEditProjectTitle() { dashboardStartEditProjectTitle(this); }, /** Commit the inline-edited title for the current project path. An empty * or whitespace-only draft clears the override so the path basename wins. */ saveProjectTitle() { dashboardSaveProjectTitle(this); }, /** Discard the inline-edited title. */ cancelEditProjectTitle() { dashboardCancelEditProjectTitle(this); }, /** Persist the current project list through the shared dashboard state store. */ _saveProjectsList() { this._saveDashboardState(); }, }; } /** * Build shared dashboard utility methods. * * Clipboard fallback and toast state are used across views, while terminal availability is the small * bridge that lets the shell decide whether terminal controls should render. */ function dashboardUtilityActionsFragment() { return { // -- Clipboard + Toast -- /** * Copy text to the clipboard, preferring the async Clipboard API. When that throws (the API is * undefined in insecure contexts, or the promise rejects) it recovers via a hidden-textarea * `execCommand("copy")` fallback instead of surfacing the error. Returns whether the copy * succeeded by either path; false means both the modern API and the legacy fallback failed. */ async copyTextToClipboard(text) { try { await navigator.clipboard.writeText(text); return true; } catch (err) { // Falls through to legacy textarea+execCommand on TypeError (clipboard // API undefined in insecure contexts) or any Promise reject reason. void err; } const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional fallback for insecure contexts without Clipboard API const ok = document.execCommand("copy"); document.body.removeChild(textarea); return ok; }, /** Copy text and flash the "Copied!" button label, reverting to "Copy" after 2s. Fire-and-forget: the copy result is ignored. */ copyText(text) { void this.copyTextToClipboard(text); this.copyLabel = "Copied!"; setTimeout(() => { this.copyLabel = "Copy"; }, 2000); }, /** Show a temporary toast message. */ showToast(msg, isError) { this.toast = msg; this.toastError = isError ?? false; setTimeout(() => { this.toast = ""; }, 4000); }, // -- Terminal -- async checkTerminalAvailable() { await dashboardCheckTerminalAvailable(this); }, }; } /** * Build terminal launch and reconnect method shims. * * These methods intentionally delegate to shared `dashboard*` terminal helpers because the * WebSocket and xterm mechanics are shared with the non-fragmented code paths and must stay in one * place; the fragment is just the named Alpine entry point surface. */ function dashboardTerminalLaunchActionsFragment() { return { /** Refresh terminal session state from the server. */ async updateSessionCount() { await dashboardUpdateSessionCount(this); }, /** Clear non-active (terminated/starting) sessions, preserving running ones. */ async endAllSessions() { await dashboardEndAllSessions(this); }, /** Retry a terminal session that failed or stalled before first output. */ async retryTerminalSession(sessionId) { await dashboardRetryTerminalSession(this, sessionId); }, /** Load the xterm.js globals on demand before any terminal view is rendered. */ async loadXterm() { await dashboardLoadXterm(this); }, /** Launch a preset prompt in the selected runner. */ async launchPreset(prompt, runner, label, options) { await dashboardLaunchPreset(this, prompt, runner, label, options); }, /** Drop a session id from every project's saved list, pruning empty entries. */ _forgetSavedSession(sessionId) { dashboardForgetSavedSession(this, sessionId); }, /** Persist a launch-time title for reconnect and refresh recovery. */ rememberSessionTitle(sessionId, title) { dashboardRememberSessionTitle(this, sessionId, title); }, /** Add an ended local session to the Workspace recent-history list. */ rememberRecentSession(session) { dashboardRememberRecentSession(this, session); }, /** Resolve the display title for a terminal session. */ sessionTitleFor(session) { return dashboardSessionTitle(this, session); }, /** Detach the current browser terminal while preserving reconnect metadata. */ detachTerminal(forProjectPath) { dashboardDetachTerminal(this, forProjectPath); }, /** Reconnect the workspace to every saved backend session for this project. */ async reconnectTerminal() { return dashboardReconnectTerminal(this); }, /** Create a new backend terminal session and open it in the workspace. */ async launchInTerminal(prompt, runner = "claude", { promptLabel = null, presetId = null, cwdPath = null, targetPath = null, } = {}) { await dashboardLaunchInTerminal(this, prompt, runner, { promptLabel, presetId, cwdPath, targetPath, }); }, }; } /** * Build terminal binding, export, and session-selection methods. * * These methods complete the terminal app surface after the launch/reconnect methods in * dashboardTerminalLaunchActionsFragment. exportSession delegates to the scrollback helper because * the xterm buffer walk is browser-only but easier to verify outside the Alpine fragment literal. */ function dashboardTerminalSessionActionsFragment() { return { /** Bind a browser xterm instance to a backend PTY session. */ connectTerminal(sessionId, wsUrl) { dashboardConnectTerminal(this, sessionId, wsUrl); }, /** End a local terminal session and release its browser bindings. */ endSession(sessionId) { dashboardEndSession(this, sessionId); }, /** * Download one terminal tab's scrollback as a .txt file built from its xterm buffer. Dumps the * normal buffer and, when a TUI has switched to the alternate screen, appends that view under a * divider so the export captures what the user currently sees. Returns early (no download) when * the session has no live xterm instance; trailing blank lines are trimmed from the normal buffer. */ exportSession(sessionId) { dashboardExportSessionScrollback(this, sessionId); }, /** Exit the active terminal session from the workspace view. */ exitTerminal() { dashboardExitTerminal(this); }, /** Switch the workspace to an existing local terminal session. */ switchToSession(sessionId) { dashboardSwitchToSession(this, sessionId); }, /** Attach the workspace to an existing backend terminal session. */ async openServerSession(serverSession) { await dashboardOpenServerSession(this, serverSession); }, /** Terminate a backend terminal session by ID. */ async endServerSession(sessionId) { await dashboardEndServerSession(this, sessionId); }, // -- Computed Properties -- auditDetailAgent: null, }; } /** * Build the time-formatting helper fragment: pure relative-time formatters the templates bind to. * No state or I/O - they turn a date into a short "Xm/h/d ago" label. The two differ only in their * null/zero handling, called out on each method. Merged into the app by dashboardMergeAppFragments. */ function dashboardTimeFormattingFragment() { return { // -- Helpers -- /** * Format a past date as a coarse "just now / Xm / Xh / Xd ago" label for activity timestamps. * A null date returns "" (render nothing); negative/future deltas are not specially handled. */ formatTimeAgo(date) { if (!date) return ""; const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); if (seconds < 60) return "just now"; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; }, /** * Format an audit's age like formatTimeAgo, but tuned for "freshness" copy. A null date reads * "just now" (treat a never-stamped audit as current, not blank), the elapsed time is clamped at * zero so clock skew never shows a negative age, and hours are shown up to 72h before switching * to days so a recent multi-day audit still reads in hours. */ formatAuditAge(date) { if (!date) return "just now"; const seconds = Math.max(0, Math.floor((Date.now() - new Date(date).getTime()) / 1000)); if (seconds < 60) return "just now"; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 72) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; }, }; }