@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.
555 lines (554 loc) • 23.7 kB
JavaScript
"use strict";
/**
* Prompt, custom-prompt, terminal-upload, and audit fragments of the dashboard Alpine app.
* dashboardMergeAppFragments later stitches these into one app object. These fragments own the
* user-authored custom prompt editor, preset browsing/copying, terminal image drops, app init,
* navigation helpers, and audit refresh actions. Heavy validation and workflow logic lives in
* shared dashboard helpers; the methods here are thin `this`-bound entry points so Alpine can call
* them by name.
*/
/** Focus a custom prompt editor field after Alpine renders the editor. */
function dashboardFocusCustomPromptField(id = "custom-prompt-name") {
requestAnimationFrame(() => {
const field = document.getElementById(id);
if (field instanceof HTMLElement)
field.focus();
});
}
/** Return image files from a terminal drop event. */
function dashboardDroppedTerminalImageFiles(event) {
return Array.from(event.dataTransfer?.files ?? []).filter((file) => file.type.startsWith("image/"));
}
/** Return true when a drag event includes at least one image file item. */
function dashboardDragHasImageFiles(event) {
const items = event.dataTransfer?.items;
if (!items || items.length === 0)
return false;
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (item?.kind === "file" && item.type.startsWith("image/"))
return true;
}
return false;
}
/** Reset terminal drag highlight state after a drop or cancelled nested drag. */
function dashboardResetTerminalDragState(ctx) {
ctx._terminalDragDepth = 0;
ctx.terminalDragActive = false;
}
/**
* Upload dropped terminal images. Fetch/parse/backend failures recover into toasts so a bad upload
* reports in-view instead of breaking the active terminal session.
*/
async function dashboardUploadTerminalImages(ctx, files) {
const sessionId = ctx.activeSessionId;
if (!sessionId)
return;
ctx.terminalUploading = true;
try {
const encoded = await encodeTerminalUploadFiles(files);
const res = await dashboardFetch(`/api/terminal/${encodeURIComponent(sessionId)}/upload-image`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ files: encoded }),
});
const payload = readRecord(await res.json(), "Terminal upload response");
const error = readErrorMessage(payload);
if (error) {
ctx.showToast(error, true);
return;
}
showTerminalUploadResult(ctx, sessionId, readTerminalUploadResult(payload));
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.showToast(msg || "Terminal image upload failed", true);
}
finally {
ctx.terminalUploading = false;
}
}
/** Validate and route a terminal image drop to the active session upload path. */
async function dashboardHandleTerminalImageDrop(ctx, event) {
dashboardResetTerminalDragState(ctx);
if (!ctx.activeSessionId || ctx.terminalEnded) {
ctx.showToast("No active terminal session for upload", true);
return;
}
const files = dashboardDroppedTerminalImageFiles(event);
if (files.length === 0) {
ctx.showToast("Only image files (.png, .jpg, .webp, .gif) can be dropped here", true);
return;
}
await dashboardUploadTerminalImages(ctx, files);
}
/** Build the audit API URL for the selected project and cache policy. */
function dashboardAuditUrl(projectPath, includeFresh) {
const freshParam = includeFresh ? "&fresh=true" : "";
return `/api/audit?path=${encodeURIComponent(projectPath)}&quality=true${freshParam}`;
}
/** Apply a successful audit payload and refresh dependent setup/home state. */
function dashboardApplyAuditPayload(ctx, payload, includeFresh) {
const cached = payload.cached === true;
const cachedAt = typeof payload.cachedAt === "string" ? payload.cachedAt : null;
ctx.report = readDashboardReport(payload);
ctx.auditCached = cached;
ctx.lastAuditTime = cachedAt ? new Date(cachedAt) : new Date();
if (includeFresh) {
ctx.setupOutputs = {};
ctx._setupOutputProjectPath = ctx.projectPath;
if (ctx.activeView === "setup")
ctx.scheduleSetupPrompt();
}
if (ctx.activeView === "home") {
void ctx.generateHomeQualitySummary();
}
}
/** Convert audit load failures into the dashboard toast copy users can act on. */
function dashboardShowAuditError(ctx, err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.showToast(msg.includes("Failed to fetch")
? "Server not running. Start with: goat-flow dashboard ."
: msg, true);
}
/** Refresh installed agents after an audit when the launcher has not loaded them yet. */
function dashboardRefreshAgentsAfterAudit(ctx) {
if (ctx.agentsLoaded)
return;
void ctx.fetchInstalledAgents().then((loaded) => {
if (!loaded)
ctx.agentsLoaded = true;
});
}
/** Load an audit snapshot and recover network/server failures into toasts. */
async function dashboardRunAudit(ctx, includeFresh = false) {
ctx.auditing = true;
ctx.toast = "";
try {
const res = await dashboardFetch(dashboardAuditUrl(ctx.projectPath, includeFresh));
if (!res.ok)
throw new Error(`Server returned ${res.status}`);
const payload = readRecord(await res.json(), "Audit response");
const error = readErrorMessage(payload);
if (error)
throw new Error(error);
dashboardApplyAuditPayload(ctx, payload, includeFresh);
}
catch (err) {
dashboardShowAuditError(ctx, err);
}
ctx.auditing = false;
dashboardRefreshAgentsAfterAudit(ctx);
}
/** Regenerate learning-loop indexes for the selected project, then refresh the Home audit payload. */
async function dashboardRegenerateLearningLoopIndex(ctx) {
if (ctx.indexRegenerating)
return;
const requestProjectPath = ctx.projectPath;
ctx.indexRegenerating = true;
ctx.indexRegenerateError = "";
try {
const res = await dashboardFetch("/api/index/regenerate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: requestProjectPath }),
});
if (!res.ok)
throw new Error(`Server returned ${res.status}`);
const payload = readRecord(await res.json(), "Index regenerate response");
const error = readErrorMessage(payload);
if (error)
throw new Error(error);
if (ctx.projectPath !== requestProjectPath)
return;
await dashboardRunAudit(ctx, true);
if (!ctx.toastError)
ctx.showToast("Learning-loop index regenerated");
}
catch (err) {
if (ctx.projectPath !== requestProjectPath)
return;
const msg = err instanceof Error ? err.message : String(err);
ctx.indexRegenerateError =
msg.length > 0 ? msg : "Index regeneration failed";
ctx.showToast(ctx.indexRegenerateError, true);
}
finally {
ctx.indexRegenerating = false;
}
}
/**
* Build the custom-prompt editor fragment: the draft being edited and its open/closed editor flags.
* One input to dashboardMergeAppFragments; the validation getters that read this draft live in the
* sibling fragment below, so this fragment must merge before they are evaluated.
*
* @returns the fragment object of custom-prompt editor state fields merged into the Alpine app
*/
function dashboardPromptBrowserStateFragment() {
return {
showCustomPromptEditor: false,
editingCustomPromptId: null,
customPromptDraft: dashboardDefaultCustomPromptDraft(),
customPromptSurfaceDraft: "",
customPromptSubmitAttempted: false,
showPromptStartPicker: false,
customPromptStartId: "",
presetFilter: "all",
presetSearch: "",
presetFavorites: readStoredStringArray("goat-flow-preset-favorites"),
/** Toggle a preset favorite state and persist the combined dashboard state. */
toggleFavorite(id) {
dashboardToggleFavorite(this, id);
},
/** Check whether a preset is marked as a favorite. */
isFavorite(id) {
return dashboardIsFavorite(this, id);
},
/** Select a prompt row and show its preview, leaving custom edit mode. */
selectPreset(preset) {
this.selectedPreset = preset;
this.showCustomPromptEditor = false;
this.editingCustomPromptId = null;
this.customPromptSubmitAttempted = false;
this.showPromptStartPicker = false;
},
/** Move the preview selection up (-1) or down (1) in screen order, with wrap. */
selectPresetByOffset(delta) {
dashboardSelectPresetByOffset(this, delta);
if (this.selectedPreset) {
this.showCustomPromptEditor = false;
this.editingCustomPromptId = null;
this.customPromptSubmitAttempted = false;
this.showPromptStartPicker = false;
}
},
/** Return the preset category filters. */
get presetCats() {
return dashboardPresetCats(this);
},
/** Compact prerequisite/fit badges for a preset row or detail view. */
presetBadges(preset) {
return dashboardPresetBadges(preset);
},
/** Route chip label for a prompt card or detail view. */
presetRouteLabel(preset) {
return dashboardPresetRouteLabel(preset);
},
/** Left-edge category accent for a prompt card. */
presetCategoryAccent(preset) {
return dashboardPresetCategoryAccent(preset);
},
/** Built-in presets plus local browser custom prompts. */
get allPresets() {
return dashboardAllPresets(this);
},
/**
* Favorites stay pinned to the top unless the user explicitly switches into
* the favorites-only filter, which keeps mixed browsing fast on large lists.
*/
get filteredPresets() {
return dashboardFilteredPresets(this);
},
/** Presets grouped by category for the Prompts page grouped rendering. */
get presetsByCategory() {
return dashboardPresetsByCategory(this);
},
/**
* Unified sequence of entries for the Prompts page list: inserts category
* headers before each group in grouped mode, falls back to flat rows
* otherwise. Rendered with a single `template x-for` in prompts.html.
*/
get renderedPresetEntries() {
return dashboardRenderedPresetEntries(this);
},
/**
* Flat list of preset IDs in screen order for keyboard nav. Uses grouped
* order when the list is grouped (filter=all + no search); otherwise
* falls back to filteredPresets order.
*/
get flatPresetOrder() {
return dashboardFlatPresetOrder(this);
},
/**
* Escaped, optionally search-highlighted HTML for the prompt preview.
* Escapes user-facing content before injecting <mark> tags so the preview
* stays safe when rendered via x-html.
*/
get highlightedPromptHtml() {
return dashboardHighlightedPromptHtml(this);
},
/** Adapt a preset prompt to the syntax expected by the selected runner. */
adaptPrompt(prompt, runner) {
return dashboardAdaptPrompt(this, prompt, runner);
},
/** Copy a preset prompt after applying runner-specific syntax tweaks. */
copyPreset(prompt) {
dashboardCopyPreset(this, prompt);
},
/** Return custom prompt route options with descriptions. */
customPromptRouteOptions() {
return dashboardCustomPromptRouteOptions();
},
/** Return the selected custom prompt route metadata. */
selectedCustomPromptRoute() {
return dashboardSelectedCustomPromptRoute(this.customPromptDraft);
},
/** Return grouped custom prompt flag metadata. */
customPromptFlagGroups() {
return dashboardCustomPromptFlagGroups();
},
/** Check whether a custom prompt flag should be disabled. */
customPromptFlagDisabled(flag) {
return (flag.field === "globalSafe" &&
this.customPromptDraft.requiresGoatFlowInstall);
},
/** Keep Global safe false when a prompt requires target goat-flow install. */
syncCustomPromptFlag(flag) {
if (flag.field === "requiresGoatFlowInstall" &&
this.customPromptDraft.requiresGoatFlowInstall) {
this.customPromptDraft.globalSafe = false;
}
},
/** Return validation errors for the current custom prompt draft. */
customPromptErrors() {
return dashboardValidateCustomPromptDraftDetails(this);
},
};
}
/**
* Build the custom-prompt validation fragment: per-field error lookups and draft-validity getters
* that read the editor draft seeded by the editor-state fragment. Each method delegates to a shared
* dashboard validation helper rather than inlining the rules, because the same validation must run
* identically here and on the server, so the branchy logic lives in one shared place and these
* methods only pass `this` so the helper sees live draft state. Merged by dashboardMergeAppFragments.
*/
function dashboardCustomPromptValidationFragment() {
return {
/** Return the first validation error for one draft field. */
customPromptFieldError(field) {
return dashboardCustomPromptFieldError(this, field);
},
/** Return non-blocking prompt-body guidance. */
customPromptWarning() {
return dashboardCustomPromptPromptWarning(this);
},
/** Return the current target surface tags. */
customPromptSurfaceTags() {
return dashboardCustomPromptSurfaceTags(this);
},
/** Return available target surface suggestions. */
customPromptSurfaceSuggestions() {
return dashboardCustomPromptSurfaceSuggestions(this);
},
/** Add a target surface tag. */
addCustomPromptSurface(surface) {
dashboardAddCustomPromptSurface(this, surface);
},
/** Commit the typed target surface tag, if any. */
commitCustomPromptSurfaceDraft() {
dashboardAddCustomPromptSurface(this, this.customPromptSurfaceDraft ?? "");
},
/** Remove a target surface tag. */
removeCustomPromptSurface(surface) {
dashboardRemoveCustomPromptSurface(this, surface);
},
/** Return a live preset-shaped preview for the custom prompt draft. */
customPromptPreview() {
return dashboardPreviewCustomPromptPreset(this);
},
/** Return preview name text, including an explicit placeholder. */
customPromptPreviewName() {
return this.customPromptDraft.name.trim() || "Untitled custom prompt";
},
/** Return preview description text, including an explicit placeholder. */
customPromptPreviewDescription() {
return this.customPromptDraft.desc.trim() || "No description yet";
},
};
}
/**
* Build custom-prompt editor actions.
*
* These methods open, focus, save, duplicate, and delete the editor draft seeded by
* dashboardPromptBrowserStateFragment while keeping validation failures focused in-view.
*/
function dashboardCustomPromptEditorActionsFragment() {
return {
/** Focus a custom prompt editor control after Alpine renders it. */
focusCustomPromptField(id = "custom-prompt-name") {
const self = this;
void self.$nextTick(() => {
dashboardFocusCustomPromptField(id);
});
},
/** Focus the first invalid custom prompt field. */
focusFirstCustomPromptError() {
const first = this.customPromptErrors()[0];
this.focusCustomPromptField(first?.anchor ?? "custom-prompt-name");
},
/** Open a blank custom prompt editor. */
openNewCustomPrompt() {
dashboardOpenNewCustomPrompt(this);
this.showPromptStartPicker = false;
this.customPromptStartId = "";
this.focusCustomPromptField();
},
/** Edit the currently selected custom prompt. */
editSelectedCustomPrompt() {
dashboardOpenEditCustomPrompt(this, this.selectedPreset);
this.showPromptStartPicker = false;
this.focusCustomPromptField();
},
/** Start a new custom prompt from the selected preset. */
duplicateSelectedCustomPrompt() {
dashboardDuplicateCustomPrompt(this, this.selectedPreset);
this.showPromptStartPicker = false;
this.customPromptStartId = "";
this.focusCustomPromptField();
},
/** Start a new custom prompt from one selected existing prompt. */
startCustomPromptFromPreset() {
dashboardStartCustomPromptFromPresetId(this, this.customPromptStartId);
this.showPromptStartPicker = false;
this.customPromptStartId = "";
this.focusCustomPromptField();
},
/** Save the custom prompt editor draft. */
saveCustomPrompt() {
this.customPromptSubmitAttempted = true;
const saved = dashboardSaveCustomPrompt(this);
if (!saved)
this.focusFirstCustomPromptError();
return saved;
},
/** Save the draft and immediately launch it with the active runner. */
async saveAndRunCustomPrompt() {
const saved = this.saveCustomPrompt();
if (!saved)
return;
const preset = dashboardCustomPromptToPreset(saved);
await this.launchPreset(preset.prompt, this.activeRunner, preset.name, {
presetId: preset.id,
});
},
/** Delete the selected custom prompt after confirmation. */
deleteSelectedCustomPrompt() {
dashboardDeleteSelectedCustomPrompt(this);
},
/** Cancel custom prompt editing without changing persisted prompts. */
cancelCustomPromptEdit() {
this.showCustomPromptEditor = false;
this.editingCustomPromptId = null;
this.customPromptSubmitAttempted = false;
this.showPromptStartPicker = false;
},
};
}
/** Build quality prompt and active-terminal send actions used by Prompts and Quality views. */
function dashboardQualityPromptActionsFragment() {
return {
/** Return quality-page prompt modes. */
get qualityModes() {
return dashboardQualityModes(this);
},
/** Return the selected quality mode option. */
get selectedQualityModeMeta() {
return dashboardSelectedQualityModeMeta(this);
},
/** Return the label to use for quality-mode terminal sessions. */
qualityLaunchLabel() {
return dashboardQualityLaunchLabel(this);
},
/** Return the selected setup target's instruction/config surfaces. */
setupInstructionSurfaces() {
return dashboardSetupInstructionSurfaces(this);
},
/** Send text to the active terminal session and focus it. */
sendToTerminal(text, { adapt = true } = {}) {
return dashboardSendToTerminal(this, text, { adapt });
},
/** Send a preset prompt to an active session in the current project. */
async sendToProjectTarget(prompt, target) {
await dashboardSendToProjectTarget(this, prompt, target);
},
};
}
/**
* Build terminal image upload actions.
*
* Drag depth stays in this fragment with upload handling so nested drag events and backend upload
* fallback behavior are maintained by one terminal-specific surface.
*/
function dashboardTerminalImageUploadFragment() {
return {
// --- Terminal image drag-drop ---
handleTerminalDragEnter(event) {
if (!this._dragHasImageFiles(event))
return;
if (!this.activeSessionId || this.terminalEnded)
return;
this._terminalDragDepth = Number(this._terminalDragDepth) + 1;
this.terminalDragActive = true;
},
/** Keep image drops routed to the active terminal pane instead of the browser. */
handleTerminalDragOver(event) {
if (!this._dragHasImageFiles(event))
return;
// Setting dropEffect on the dataTransfer is what lets browsers fire `drop`
// on this pane instead of routing the file to the OS handler.
if (event.dataTransfer)
event.dataTransfer.dropEffect = "copy";
},
/** Clear terminal drag state when the nested drag counter returns to zero. */
handleTerminalDragLeave(_dragEvent) {
this._terminalDragDepth = Math.max(0, this._terminalDragDepth - 1);
if (this._terminalDragDepth === 0)
this.terminalDragActive = false;
},
/** Upload dropped image files to the active terminal session. */
async handleTerminalDrop(event) {
await dashboardHandleTerminalImageDrop(this, event);
},
/** Detect image-file drags before showing the terminal drop target. */
_dragHasImageFiles(event) {
return dashboardDragHasImageFiles(event);
},
/** Encode and send dropped images to the backend terminal upload route; reports upload errors as toasts. */
async _uploadTerminalImages(files) {
await dashboardUploadTerminalImages(this, files);
},
};
}
/** Build dashboard lifecycle, navigation, and audit actions. */
function dashboardAuditAndNavigationActionsFragment() {
return {
// --- Init ---
/** Register Alpine watchers and swallows lazy terminal warmup errors because init must keep mounting. */
init() {
dashboardInit(this);
},
// -- Navigation --
comingSoonMeta(view) {
const meta = {};
return meta[view] ?? null;
},
/** Return whether a requested dashboard view is still routed to the coming-soon panel. */
isComingSoonView(view) {
return this.comingSoonMeta(view ?? this.activeView) !== null;
},
/** Toggle and persist the collapsed state of the dashboard side navigation. */
toggleSideNav() {
this.sideNavCollapsed = !this.sideNavCollapsed;
localStorage.setItem("gf-side-nav-collapsed", String(this.sideNavCollapsed));
},
// -- API Calls --
/** Load an audit snapshot; reports network/server errors as toasts because the dashboard must stay usable. */
async runAudit(includeFresh = false) {
await dashboardRunAudit(this, includeFresh);
},
/** Regenerate learning-loop indexes for the selected project and refresh Home. */
async regenerateLearningLoopIndex() {
await dashboardRegenerateLearningLoopIndex(this);
},
};
}