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.

427 lines (426 loc) 18.4 kB
"use strict"; /** * Core state fragments for the dashboard Alpine app. dashboardMergeAppFragments stitches them into * one app object via descriptor merge (so getters and method `this` survive). These fragments seed * the injected audit report, selected project path, theme, per-session/terminal UI flags, and the * state used by projects, tasks, hooks, quality, and the skill evaluator. Order matters: fields here * must exist before later fragments' methods read them. */ /** * Build the core-state fragment: audit report, project path, theme, and session/terminal UI flags. * The returned object is one input to dashboardMergeAppFragments, not a standalone app; its fields * are the reactive baseline later fragments' getters and methods assume already exist. * * @param supportedAgents - agents the server reports as launchable, used to seed runner UI options * @param defaultRunner - runner pre-selected in the launcher until the user picks another * @returns the fragment object of initial state fields merged into the Alpine app */ function dashboardCoreStateFragment(supportedAgents, defaultRunner) { return { // --- Core state --- report: readInjectedReport(), projectPath: window.__GOAT_FLOW_DEFAULT_PATH__ ?? ".", dashboardVersion: window.__GOAT_FLOW_VERSION__ ?? "0.0.0", darkMode: localStorage.getItem("gf-dark") === "true" || (!localStorage.getItem("gf-dark") && window.matchMedia("(prefers-color-scheme: dark)").matches), auditing: false, indexRegenerating: false, indexRegenerateError: "", toast: "", toastError: false, copyLabel: "Copy", srAnnouncement: "", activeView: "home", sideNavCollapsed: localStorage.getItem("gf-side-nav-collapsed") === "true", supportedAgents, installedAgents: [], allAgents: [], agentsLoaded: false, get agentSkeletonList() { return this.installedAgents.length === 0 && !this.agentsLoaded ? this.supportedAgents : []; }, activeRunner: defaultRunner, userRole: "", workspacePanel: "terminal", sessionsCollapsed: localStorage.getItem("gf-sessions-collapsed") === "true", otherCollapsed: false, confirmEndSessionId: null, _workspacePoll: null, /** Optional user-supplied display titles keyed by stable project identity. * Persisted alongside paths/favorites in .goat-flow/dashboard-state.json so * titles follow repos across path moves when the server can resolve identity. */ projectTitles: {}, projectIdentities: {}, editingProjectTitle: false, projectTitleDraft: "", /** Resolve the stable dashboard-state key for a path, falling back for older payloads. */ projectKeyFor(path) { return this.projectIdentities[path] ?? path; }, /** Resolve the display name for a project path, preferring a user override. */ displayNameFor(path) { const identityKey = this.projectKeyFor(path); const override = this.projectTitles[identityKey] ?? this.projectTitles[path]; if (typeof override === "string" && override.length > 0) return override; return getProjectDisplayName(path); }, /** Return the current project name. */ get projectName() { return this.displayNameFor(this.projectPath); }, /** Keep a stable accent color per project so quick switches stay visually anchored. * Hash the path (not the display name) so renaming doesn't change the accent. */ get projectColor() { const key = this.projectPath; let hash = 0; for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash); const hue = Math.abs(hash) % 360; return `hsl(${hue}, 60%, 50%)`; }, showBrowser: false, browserCurrent: "", browserParent: "", browserDirs: [], lastAuditTime: null, auditCached: false, // --- Audit detail state --- selectedFixes: [], fixCopyLabel: "Copy fixes", // --- Terminal state --- terminalAvailable: false, platformHint: null, idleTimeoutMinutes: 480, terminalSessionCount: 0, serverSessions: [], serverMaxSessions: 10, sessionTitles: readStoredStringMap("goat-flow-session-titles"), recentTerminalSessions: [], showMaxSessionsModal: false, sessions: [], activeSessionId: null, selectedPreset: null, promptRunStates: {}, launching: false, availableRunners: [], // Project switches intentionally preserve backend sessions so returning to a workspace // can reattach instead of spawning a fresh agent process. Each project keeps the full // list of its bound sessions plus the id that was active at detach time. _projectSessions: {}, _projectActiveSession: {}, _terminalRefs: {}, _xtermLoaded: false, // detachTerminal() flips this while it closes browser-side sockets so ws.onclose only // marks sessions ended when the runner actually exits on the backend. _detaching: false, // Drag-drop image upload state for the active terminal pane. terminalDragActive: false, terminalUploading: false, _terminalDragDepth: 0, }; } /** Resolve the active local terminal session from Alpine state. */ function activeTerminalSession(sessions, activeSessionId) { if (activeSessionId === null) return null; return sessions.find((session) => session.id === activeSessionId) ?? null; } /** Return true only when a browser tab is disconnected from a still-live backend session. */ function isTerminalDetached(session, serverSessions) { if (!session || session.ended || session.connected) return false; return serverSessions.some((serverSession) => serverSession.id === session.id && serverSession.status === "active"); } /** * Return true only in the pre-output startup gap. The ordered guards keep ended, awaiting-input, * ready, and error sessions from showing a loading overlay after the terminal is already usable. */ function isTerminalWaitingForRunner(session) { if (!session) return false; if (!session.connected || session.ended) return false; if (session.awaitingInput) return false; if (session.loadingPhase === "ready" || session.loadingPhase === "error") return false; const tail = session.outputTail ?? ""; return tail.length === 0; } /** Format the startup overlay for the active terminal session. */ function terminalLoadingMessageFor(session) { if (!session) return ""; if (session.loadingPhase === "error") { return `Failed to start: ${session.loadingError || "Could not start session."}`; } if (session.loadingPhase === "loading") { return "Connected. Loading shell..."; } return `Spinning up ${session.runner} session...`; } /** Resolve terminal handles by the active session id without materialising Alpine refs. */ function terminalRefFor(refs, activeSessionId) { return refs[activeSessionId ?? ""]; } /** * Build active terminal-session getters. * * These are intentional getters rather than methods, because they must recompute reactively from raw * state and the merge step preserves them. Loading/transport getters and session-list ordering live * in companion fragments so each fragment keeps one responsibility. */ function dashboardActiveTerminalSessionFragment() { return { /** Return the active local session. */ get _activeSession() { return activeTerminalSession(this.sessions, this.activeSessionId); }, /** Return the active terminal session ID. */ get terminalSessionId() { return this._activeSession?.id ?? null; }, /** Return whether the active terminal is connected. */ get terminalConnected() { return this._activeSession?.connected ?? false; }, /** Return whether the active terminal has ended. */ get terminalEnded() { return this._activeSession?.ended ?? false; }, /** Return whether the active terminal is detached from a live backend session. */ get terminalDetached() { return isTerminalDetached(this._activeSession, this.serverSessions); }, /** Return whether the active terminal appears to be awaiting a user choice. */ get terminalAwaitingInput() { return this._activeSession?.awaitingInput === true; }, }; } /** Build active terminal loading, title, and transport getters. */ function dashboardTerminalStatusAccessorsFragment() { return { /** * True when the active terminal is connected but the runner has not * produced any output yet. Surfaces the gap between WebSocket attach * (~100 ms) and Claude Code's first PTY paint (~5 s observed locally) so * users see in-place progress instead of a silent terminal. */ get terminalWaitingForRunner() { return isTerminalWaitingForRunner(this._activeSession); }, /** Return the active terminal loading-overlay message. */ terminalLoadingMessage(session) { return terminalLoadingMessageFor(session); }, /** Return the active terminal age label. */ get terminalAge() { return this._activeSession?.age ?? ""; }, /** Return the last run prompt label. */ get lastRunPrompt() { return this._activeSession ? this.sessionTitleFor(this._activeSession) : null; }, /** Return the last run agent ID. */ get lastRunAgent() { return this._activeSession?.runner ?? null; }, /** Return the active terminal WebSocket reference. */ get _terminalWs() { return terminalRefFor(this._terminalRefs, this.activeSessionId)?.ws; }, /** Return the active xterm instance. */ get _terminalXterm() { return terminalRefFor(this._terminalRefs, this.activeSessionId)?.xterm; }, }; } /** * Build session-list getters plus Projects, Tasks, and Hooks state. * * The list getters hold a stable ordering contract the templates depend on: current-project * sessions sort newest-first, and other-project sessions sort by project name then newest-first, so * the rendered order is deterministic across re-renders rather than reflecting array insertion order. */ function dashboardWorkspaceCollectionsStateFragment() { return { /** Sessions whose project matches the current projectPath, newest first. */ get currentProjectSessions() { return this.serverSessions .filter((s) => s.projectPath === this.projectPath) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); }, /** Sessions for other projects, grouped by project name then newest first. */ get otherProjectSessions() { return this.serverSessions .filter((s) => s.projectPath !== this.projectPath) .sort((a, b) => { const byName = (a.projectName || "").localeCompare(b.projectName || ""); if (byName !== 0) return byName; return (new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); }); }, /** Active sessions for the current project; valid targets for `Send to active`. */ get sendTargetsInCurrentProject() { return this.serverSessions.filter((s) => s.projectPath === this.projectPath && s.status === "active"); }, /** Whether a backend session is currently bound to a local xterm instance. */ isSessionBoundLocally(id) { return this.sessions.some((s) => s.id === id && s.ended !== true && s.connected === true); }, // --- Projects state --- projectsList: [], projectsAuditing: false, showAddProject: false, projectsSortKey: "name", projectsSortAsc: true, newProjectPath: "", // --- Tasks state --- tasksState: null, tasksLoading: false, tasksActivePlanSaving: null, tasksError: "", selectedTaskPlan: null, // --- Hooks state --- hooksState: [], hooksLoading: false, hooksError: "", hookSavingId: null, hooksFilter: "all", hooksSearch: "", }; } function dashboardQualitySetupStateFragment(supportedAgents, defaultRunner, defaultSetupAgents) { return { // --- Quality state --- qualityAgent: defaultRunner, selectedQualityModeId: "agent-setup", qualityLoading: false, qualityResult: null, qualityCopyLabel: "Copy", qualityHistoryLoading: false, qualityHistoryRows: [], qualityHistoryLatest: null, qualityHistoryWarnings: [], _qualityHistoryTimer: null, homeQualityLoading: false, homeQualityLatest: null, // --- Skill quality state --- skillQualityArtifacts: [], skillQualitySelectedId: null, skillQualityReport: null, skillQualityLoading: false, skillQualityAbortController: null, /** Cache of per-artifact reports so the sidebar can show a grade for each * skill without waiting on per-click fetches. Populated by prefetchSkillReports * after loadSkillQualityInventory. */ skillQualityReports: {}, skillQualityAuditedAt: null, skillQualityPrefetching: false, skillQualityPrefetchGeneration: 0, // --- Skill evaluator page state --- skillEvaluatorName: "", skillEvaluatorContent: "", skillEvaluatorFiles: [], skillEvaluatorDragActive: false, skillEvaluatorResult: null, skillEvaluatorLoading: false, skillEvaluatorError: null, skillEvaluatorReportCopied: false, _skillEvaluatorReportCopiedTimer: null, /** Per-metric collapse state for the evaluator result tip groups. */ skillEvaluatorTipCollapsed: {}, /** Resolve the current display name for one supported agent id. */ agentName(agentId) { return (this.supportedAgents.find((agent) => agent.id === agentId)?.name ?? agentId); }, /** Return the audit-based status shown on each Setup page agent card. */ setupAgentStatus(agentId) { if (!this.report) return { label: "Not audited", color: "#52525b" }; const score = this.report.agentScores.find((score) => score.id === agentId); if (!score) return { label: "Not audited", color: "#52525b" }; const agentPass = score.agent.status === "pass"; const harnessPass = !score.harness || score.harness.status === "pass"; if (agentPass && harnessPass) return { label: "Passing", color: "var(--status-pass)" }; if (!agentPass) return { label: "Setup failing", color: "#f87171" }; return { label: "Harness failing", color: "#fbbf24" }; }, /** Convert one audit scope into a percentage, including score-only maturity checks. */ auditScopePercent(scope) { const checks = scope?.checks ?? []; const scored = checks.filter((check) => check.status !== "skipped"); if (scored.length === 0) return null; const passed = scored.filter((check) => check.status === "pass").length; return Math.round((passed / scored.length) * 100); }, /** Readiness for the Setup page target card: setup + selected agent + harness. */ setupTargetScore(agentId) { if (!this.report) return null; const score = this.report.agentScores.find((score) => score.id === agentId); if (!score) return null; const parts = [ this.auditScopePercent(this.report.scopes.setup), this.auditScopePercent(score.agent), this.auditScopePercent(score.harness), ].filter((value) => value !== null && !Number.isNaN(value)); if (parts.length === 0) return null; return Math.round(parts.reduce((total, value) => total + value, 0) / parts.length); }, /** Convert the selected setup target's readiness score into a letter grade. */ setupTargetGrade(agentId) { const score = this.setupTargetScore(agentId); if (score === null) return "-"; if (score >= 90) return "A"; if (score >= 80) return "B"; if (score >= 70) return "C"; if (score >= 60) return "D"; return "F"; }, /** Format the selected setup target's readiness score for the target card. */ setupTargetPercent(agentId) { const score = this.setupTargetScore(agentId); return score === null ? "Not audited" : `${score}%`; }, // --- Setup state --- setupDetecting: false, setupSelectedAgent: defaultRunner, setupData: { languages: [], frameworks: [], commands: { ...DEFAULT_SETUP_COMMANDS }, agents: { ...defaultSetupAgents }, existing: { ...DEFAULT_EXISTING_ARTIFACTS }, nonGoatFlow: [], }, setupGenerating: false, setupOutputs: {}, _setupOutputProjectPath: null, _setupPromptRequestKey: null, _setupPromptTimer: null, // --- Launcher state --- presets: readInjectedPresets(), customPrompts: [], }; }