@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.
394 lines (387 loc) • 13.2 kB
HTML
<!-- ═══ Projects View ═══ -->
<div
x-show="activeView === 'projects'"
x-cloak
style="height: calc(100vh - 3.5rem); overflow-y: auto"
x-data="{
projectSearch: '',
confirmRemovePath: null,
stateBadge(project) {
if (project.action === 'audit') return 'gf-badge-pass';
if (project.action === 'upgrade') return 'gf-badge-warn';
if (project.action === 'migration') return 'gf-badge-high';
if (project.action === 'setup') return 'gf-badge-ap';
if (project.action === 'fix') return 'gf-badge-ap';
return 'gf-badge-muted';
},
get filteredProjects() {
const q = this.projectSearch.toLowerCase().trim();
const list = this.sortedProjectsList;
if (!q) return list;
return list.filter(p => {
const name = this.displayNameFor(p.path);
return name.toLowerCase().includes(q) || (p.state || '').toLowerCase().includes(q) || (p.action || '').toLowerCase().includes(q);
});
},
stateCounts() {
const counts = {};
for (const p of this.projectsList) {
const key = p.action || 'unknown';
counts[key] = (counts[key] || 0) + 1;
}
return counts;
},
doAction(project) {
if (project.action === 'audit') {
this.projectPath = project.path; this.activeView = 'home'; this.runAudit();
} else if (project.action === 'setup') {
this.projectPath = project.path; this.activeView = 'setup'; this.detectStack();
} else if (project.action === 'migration' || project.action === 'upgrade') {
this.projectPath = project.path; this.activeView = 'setup'; this.detectStack();
} else {
this.projectPath = project.path; this.activeView = 'home'; this.runAudit();
}
}
}"
>
<div style="max-width: 1100px; margin: 0 auto; padding: 20px 28px 24px">
<!-- Header -->
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
"
>
<span class="gf-text-primary" style="font-size: 20px; font-weight: 500"
>Projects</span
>
<div style="display: flex; gap: 8px">
<button
@click="auditAllProjects()"
:disabled="projectsAuditing"
class="gf-btn gf-btn-md gf-btn-secondary"
>
<span x-text="projectsAuditing ? 'Auditing...' : 'Audit All'"></span>
</button>
<button
@click="showAddProject = true; $nextTick(() => $refs.addProjectInput?.focus())"
class="gf-btn gf-btn-md gf-btn-primary"
>
Add Project
</button>
</div>
</div>
<!-- Summary bar -->
<div
x-show="projectsList.length > 0"
class="gf-text-muted"
style="
font-size: 12px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
"
>
<span
x-text="`${projectsList.length} project${projectsList.length !== 1 ? 's' : ''}`"
class="gf-text-secondary"
style="font-weight: 500"
></span>
<span style="opacity: 0.4">—</span>
<template
x-for="[action, count] in Object.entries(stateCounts())"
:key="'sum-' + action"
>
<span>
<span x-text="count" style="font-weight: 600"></span>
<span x-text="action"></span>
<span style="opacity: 0.3; margin: 0 2px">·</span>
</span>
</template>
</div>
<!-- Search filter -->
<div x-show="projectsList.length > 3" style="margin-bottom: 12px">
<input
type="text"
x-model="projectSearch"
class="gf-input"
style="
width: 100%;
font-size: 12px;
padding: 7px 12px;
border-radius: 6px;
outline: none;
"
placeholder="Filter projects..."
/>
</div>
<!-- Add project panel -->
<div
x-show="showAddProject"
x-cloak
class="gf-card"
style="padding: 14px 16px; margin-bottom: 16px"
>
<div
class="gf-text-secondary"
style="font-size: 12px; font-weight: 500; margin-bottom: 8px"
>
Add project path
</div>
<div style="display: flex; gap: 8px">
<input
type="text"
x-model="newProjectPath"
x-ref="addProjectInput"
class="gf-input"
style="
flex: 1;
font-family: var(--font-mono);
font-size: 12px;
padding: 6px 10px;
border-radius: 6px;
outline: none;
"
placeholder="/path/to/project"
@keydown.enter="addProject()"
/>
<button @click="addProject()" class="gf-btn gf-btn-md gf-btn-primary">
Add
</button>
<button
@click="showAddProject = false; newProjectPath = ''"
class="gf-btn gf-btn-md gf-btn-text"
style="background: none; border: none; color: var(--text-muted)"
>
Cancel
</button>
</div>
</div>
<!-- Projects table -->
<div
x-show="projectsList.length > 0"
class="gf-card"
style="border-radius: 8px; overflow: hidden; margin-bottom: 20px"
>
<table style="width: 100%; border-collapse: collapse">
<thead>
<tr>
<template
x-for="col in [{label: 'Project', key: 'name'}, {label: 'State', key: 'state'}, {label: 'Action', key: 'action'}, {label: 'Details', key: 'details'}, {label: '', key: null}]"
>
<th
class="gf-th"
:style="col.key ? 'cursor: pointer; user-select: none;' : ''"
@click="col.key && sortProjects(col.key)"
>
<span x-text="col.label"></span>
<span
x-show="col.key && projectsSortKey === col.key"
x-text="projectsSortAsc ? ' ▲' : ' ▼'"
style="font-size: 9px; opacity: 0.6"
></span>
</th>
</template>
</tr>
</thead>
<tbody>
<template
x-for="project in filteredProjects"
:key="project.identity || project.path"
>
<tr class="gf-tr">
<!-- Project name (clickable → home view) -->
<td class="gf-td">
<button
@click="projectPath = project.path; activeView = 'home'; runAudit()"
style="
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-mono);
"
@mouseenter="$el.style.color = 'var(--accent)'"
@mouseleave="$el.style.color = 'var(--text-primary)'"
>
<span x-text="displayNameFor(project.path)"></span>
</button>
<div
class="gf-text-muted"
style="
font-size: 10px;
margin-top: 1px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
"
x-text="project.path"
:title="project.path"
></div>
<div
x-show="project.paths && project.paths.length > 1"
class="gf-text-muted"
style="
font-size: 10px;
margin-top: 1px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
"
x-text="'Aliases: ' + project.paths.length"
:title="project.paths.join('\n')"
></div>
</td>
<!-- State badge (color-coded) -->
<td class="gf-td">
<span
style="
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
"
:class="stateBadge(project)"
x-text="project.state"
></span>
</td>
<!-- Action (clickable button) -->
<td class="gf-td">
<button
@click="doAction(project)"
style="
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: var(--accent);
"
@mouseenter="$el.style.textDecoration = 'underline'"
@mouseleave="$el.style.textDecoration = 'none'"
x-text="project.action"
></button>
</td>
<!-- Details (truncated, full text in tooltip) -->
<td class="gf-td">
<span
class="gf-text-muted"
style="
font-size: 11px;
display: block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
"
x-text="project.details"
:title="project.details"
></span>
</td>
<!-- Actions -->
<td class="gf-td" style="text-align: right; white-space: nowrap">
<button
@click="projectPath = project.path; activeView = 'workspace'"
x-show="terminalAvailable"
class="gf-btn gf-btn-sm gf-btn-ghost"
style="margin-right: 4px"
>
Terminal
</button>
<!-- Remove with confirmation -->
<button
x-show="confirmRemovePath !== project.path"
@click="confirmRemovePath = project.path"
class="gf-btn gf-btn-sm"
style="
background: none;
border: none;
color: var(--text-muted);
font-size: 11px;
padding: 2px 6px;
"
@mouseenter="$el.style.color = 'var(--red-400)'"
@mouseleave="$el.style.color = 'var(--text-muted)'"
>
Remove
</button>
<span
x-show="confirmRemovePath === project.path"
style="display: inline-flex; align-items: center; gap: 4px"
>
<span class="gf-text-muted" style="font-size: 11px"
>Remove?</span
>
<button
@click="removeProject(project.path); confirmRemovePath = null"
class="gf-btn gf-btn-sm gf-btn-danger"
style="font-size: 11px; padding: 1px 6px"
>
Yes
</button>
<button
@click="confirmRemovePath = null"
class="gf-btn gf-btn-sm"
style="
background: none;
border: none;
color: var(--text-muted);
font-size: 11px;
padding: 1px 6px;
"
>
No
</button>
</span>
</td>
</tr>
</template>
</tbody>
</table>
<!-- No search results -->
<div
x-show="projectSearch && filteredProjects.length === 0"
class="gf-empty-state"
style="padding: 24px 16px"
>
<span class="gf-empty-mark">⌕</span>
<p>No projects match filter</p>
</div>
</div>
<!-- Empty state -->
<div
x-show="projectsList.length === 0"
class="gf-surface gf-empty-state"
style="border-radius: 8px; margin-bottom: 20px"
>
<span class="gf-empty-mark">PR</span>
<p>
No projects added. Click "Add Project" to monitor a project directory.
</p>
</div>
<!-- Footer -->
<div
class="gf-footer"
style="text-align: center; font-size: 11px; padding: 16px 0 8px"
>
Built by
<a
href="https://www.blundergoat.com"
target="_blank"
class="gf-footer-link"
>BlunderGOAT</a
>
· <span x-text="dashboardVersion ? `v${dashboardVersion}` : ''"></span>
</div>
</div>
</div>