@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.
837 lines (827 loc) • 33.2 kB
HTML
<!-- ═══ Workspace View ═══ -->
<div
x-show="activeView === 'workspace'"
x-cloak
style="height: calc(100vh - 3.5rem); overflow: hidden"
x-data="{
collapsedSessionTooltip: null,
localSessionRows() {
return this.sessions.filter((s) => !s.ended).map((s) => ({
id: s.id,
status: 'active',
createdAt: new Date(s.startTime).toISOString(),
projectPath: s.projectPath,
cwd: s.cwd,
targetPath: s.targetPath,
runner: s.runner,
lastInputAt: s.lastInputTime,
awaitingInput: s.awaitingInput === true,
waitingForRunner: s.connected === true && s.ended !== true && s.awaitingInput !== true && s.loadingPhase !== 'ready' && s.loadingPhase !== 'error' && ((s.outputTail || '').length === 0),
age: Math.max(0, Math.floor((Date.now() - s.startTime) / 1000)),
idleDuration: Math.max(0, Math.floor((Date.now() - s.lastInputTime) / 1000)),
projectName: this.displayNameFor(s.projectPath)
}));
},
allSessions() {
const rows = [...this.localSessionRows(), ...this.currentProjectSessions, ...this.otherProjectSessions].filter(s => s.status === 'active');
const seen = new Set();
return rows.filter((s) => {
if (seen.has(s.id)) return false;
seen.add(s.id);
return true;
});
},
recentSessions() {
const inactive = this.serverSessions.filter(s => s.status !== 'active');
const rows = [...this.recentTerminalSessions, ...inactive];
const seen = new Set();
return rows.filter((s) => {
if (seen.has(s.id)) return false;
seen.add(s.id);
return true;
}).slice(0, 4);
},
sessionIsWaiting(s) {
return s.awaitingInput === true || s.waitingForRunner === true || (typeof s.idleDuration === 'number' && this.idleTimeoutMinutes > 0 && s.idleDuration >= Math.floor(this.idleTimeoutMinutes * 0.85) * 60);
},
sessionTone(s) { return this.sessionIsWaiting(s) ? 'waiting' : (s.status === 'active' ? 'running' : 'recent'); },
sessionGlyph(s) { return this.sessionIsWaiting(s) ? '!' : (s.status === 'active' ? '●' : '○'); },
sessionStatusLabel(s) { return this.sessionIsWaiting(s) ? 'Waiting' : (s.status === 'active' ? 'Running' : 'Recent'); },
sessionPipTone(s) {
const local = this.sessions.find((item) => item.id === s.id);
if (local && local.loadingPhase === 'error') return 'error';
if (this.sessionIsWaiting(s)) return 'waiting';
if (s.status === 'active') return 'running';
return 'idle';
},
waitingSessions() {
return this.allSessions().filter(s => this.sessionIsWaiting(s));
},
runningSessions() {
return this.allSessions().filter(s => s.status === 'active' && !this.sessionIsWaiting(s));
},
meterRunning() { return this.runningSessions().length; },
meterRecent() { return this.recentSessions().length; },
meterWaiting() { return this.waitingSessions().length; },
ageLabel(s) {
const m = Math.floor((s.age || 0) / 60);
const h = Math.floor(m / 60);
return h > 0 ? h + 'h ' + (m % 60) + 'm' : m + 'm';
},
sessionMeta(s) {
return [s.projectName || projectName, s.runner, this.ageLabel(s)].filter(Boolean).join(' · ');
},
sessionProjectAgeMeta(s) {
return [s.projectName || projectName, this.ageLabel(s)].filter(Boolean).join(' · ');
},
sessionTooltipMeta(s) {
return [s.runner, this.ageLabel(s)].filter(Boolean).join(' · ');
},
sessionAgentClass(s) {
return 'workspace-agent-' + (s.runner || 'unknown');
},
showCollapsedSessionTooltip(event, s) {
const rail = event.currentTarget.closest('.workspace-session-collapsed');
const buttonRect = event.currentTarget.getBoundingClientRect();
const railRect = rail ? rail.getBoundingClientRect() : buttonRect;
this.collapsedSessionTooltip = {
id: s.id,
title: this.sessionTitleFor(s),
meta: this.sessionTooltipMeta(s),
top: buttonRect.top - railRect.top + (buttonRect.height / 2)
};
},
hideCollapsedSessionTooltip(s) {
if (!this.collapsedSessionTooltip || this.collapsedSessionTooltip.id === s.id) {
this.collapsedSessionTooltip = null;
}
},
collapsedSessionTooltipStyle() {
return { top: (this.collapsedSessionTooltip ? this.collapsedSessionTooltip.top : 0) + 'px' };
},
openSession(s) {
if (this.confirmEndSessionId === s.id) return;
if (this.isSessionBoundLocally(s.id)) this.activeSessionId = s.id;
else this.openServerSession(s);
}
}"
>
<div
style="
width: 100%;
height: 100%;
margin: 0;
padding: 16px 24px;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 14px;
"
>
<div style="max-width: 720px">
<h2
class="gf-text-primary"
style="font-size: 18px; font-weight: 600; margin-bottom: 4px"
>
Workspace
</h2>
<p class="gf-text-muted" style="font-size: 14px">
Terminal sessions running across projects. Pick a session on the left
to inspect output and continue work. Drag and drop images into the
terminal to attach them to your prompt.
</p>
</div>
<div
class="gf-card"
style="
padding: 6px 12px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
"
>
<span
class="gf-text-muted"
style="font-size: 12px"
x-text="`${serverSessions.length} of ${serverMaxSessions}`"
></span>
<span class="gf-text-muted">·</span>
<span
class="gf-status-dot gf-status-running"
aria-label="Running sessions"
>●</span
>
<span
class="gf-text-muted"
style="font-size: 12px"
x-text="`${meterRunning()} running`"
></span>
<span class="gf-text-muted">·</span>
<span
class="gf-status-dot gf-status-waiting"
aria-label="Waiting sessions"
>!</span
>
<span
class="gf-text-muted"
style="font-size: 12px"
x-text="`${meterWaiting()} waiting`"
></span>
<span class="gf-text-muted">·</span>
<span
class="gf-text-muted"
style="font-size: 12px"
x-text="`${meterRecent()} recent`"
></span>
</div>
</div>
<div
:style="{ gridTemplateColumns: sessionsCollapsed ? '64px minmax(0, 1fr)' : '220px minmax(0, 1fr)' }"
style="display: grid; gap: 12px; flex: 1; min-height: 0"
>
<!-- Left rail -->
<div
class="workspace-session-rail"
:class="{ 'is-collapsed': sessionsCollapsed }"
style="
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
height: 100%;
box-sizing: border-box;
overflow: visible;
border-right: 1px solid var(--border-subtle);
background: var(--surface-base);
"
>
<div
id="workspace-session-sidebar-content"
x-show="!sessionsCollapsed"
class="workspace-session-expanded"
>
<section class="workspace-session-heading">
<div class="section-label">
ACTIVE
<span class="gf-section-count"
>· <span x-text="allSessions().length"></span
></span>
</div>
</section>
<div class="workspace-session-list-scroll">
<section>
<div
x-show="allSessions().length === 0"
class="gf-empty-state workspace-session-empty"
>
<p>No sessions yet — launch one from prompts</p>
</div>
<div
x-show="allSessions().length > 0"
class="workspace-session-card-stack"
>
<template x-for="s in allSessions()" :key="'ws-s-' + s.id">
<div
@click="openSession(s)"
class="gf-card workspace-session-card"
:class="{
'is-active': s.id === activeSessionId,
'is-waiting': sessionIsWaiting(s)
}"
>
<div
style="
display: flex;
gap: 10px;
align-items: flex-start;
min-width: 0;
"
>
<span
class="gf-status-dot"
:class="'gf-status-' + sessionTone(s)"
:aria-label="sessionStatusLabel(s)"
x-text="sessionGlyph(s)"
style="margin-top: 2px"
></span>
<div class="workspace-session-card-body">
<div class="workspace-session-title-row">
<div
class="gf-text-primary workspace-session-title"
:title="sessionTitleFor(s)"
x-text="sessionTitleFor(s)"
></div>
</div>
<p
class="gf-text-muted"
style="
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
margin: 0;
"
:title="sessionProjectAgeMeta(s)"
x-text="sessionProjectAgeMeta(s)"
></p>
<p
x-show="s.cwd && s.cwd !== s.projectPath"
class="gf-text-muted"
style="
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
margin: 0;
"
>
cwd
<span
class="mono"
x-text="displayNameFor(s.cwd)"
></span>
</p>
<div
style="
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
padding-top: 6px;
"
>
<span
class="workspace-agent-chip"
:class="sessionAgentClass(s)"
x-text="s.runner"
></span>
<span class="gf-tooltip-wrap">
<button
@click.stop="confirmEndSessionId = (confirmEndSessionId === s.id ? null : s.id)"
class="gf-btn gf-btn-sm workspace-danger-ghost"
type="button"
:aria-describedby="'ws-end-tip-' + s.id"
>
End
</button>
<span
:id="'ws-end-tip-' + s.id"
class="gf-tooltip gf-tooltip-right"
role="tooltip"
>End session</span
>
</span>
</div>
</div>
</div>
<div
x-show="confirmEndSessionId === s.id"
class="workspace-session-confirm"
>
<span class="gf-text-muted">End this session?</span>
<button
@click.stop="endServerSession(s.id); confirmEndSessionId = null"
class="gf-btn gf-btn-sm gf-btn-danger"
>
Yes
</button>
<button
@click.stop="confirmEndSessionId = null"
class="gf-btn gf-btn-sm gf-btn-secondary"
>
No
</button>
</div>
</div>
</template>
</div>
</section>
<section x-show="recentSessions().length > 0">
<div
style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
"
>
<div class="section-label">
RECENT
<span class="gf-section-count"
>· <span x-text="recentSessions().length"></span
></span>
</div>
<button
@click="endAllSessions()"
class="gf-btn gf-btn-sm gf-btn-text"
>
Clear history
</button>
</div>
<div
class="scrollbar"
style="display: flex; flex-direction: column; gap: 8px"
>
<template x-for="s in recentSessions()" :key="'ws-r-' + s.id">
<div
class="gf-card"
style="padding: 10px 12px; opacity: 0.84"
>
<div
class="gf-text-primary"
style="
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
"
:title="sessionTitleFor(s)"
x-text="sessionTitleFor(s)"
></div>
<p
class="gf-text-muted"
style="font-size: 12px; margin: 2px 0 0"
x-text="sessionMeta(s)"
></p>
</div>
</template>
</div>
</section>
</div>
<div class="workspace-session-launch">
<button
@click="activeView = 'prompts'; $nextTick(() => { $refs.presetSearchInput && $refs.presetSearchInput.focus(); })"
class="gf-btn gf-btn-md gf-btn-primary"
style="width: 100%; justify-content: center; padding: 10px"
>
+ Launch from prompts
</button>
</div>
</div>
<div x-show="sessionsCollapsed" class="workspace-session-collapsed">
<span class="gf-tooltip-wrap">
<button
@click="activeView = 'prompts'; $nextTick(() => { $refs.presetSearchInput && $refs.presetSearchInput.focus(); })"
class="gf-btn gf-btn-sm gf-btn-primary workspace-collapsed-launch"
type="button"
aria-label="New session"
aria-describedby="workspace-new-session-tip"
>
<span aria-hidden="true">+</span>
</button>
<span
id="workspace-new-session-tip"
class="gf-tooltip gf-tooltip-right"
role="tooltip"
>New session</span
>
</span>
<div class="workspace-collapsed-divider"></div>
<div class="workspace-collapsed-stack scrollbar">
<template x-for="s in allSessions()" :key="'ws-collapsed-' + s.id">
<span class="gf-tooltip-wrap">
<button
@click="openSession(s)"
@mouseenter="showCollapsedSessionTooltip($event, s)"
@focus="showCollapsedSessionTooltip($event, s)"
@mouseleave="hideCollapsedSessionTooltip(s)"
@blur="hideCollapsedSessionTooltip(s)"
class="workspace-session-dot-btn"
type="button"
:class="{ 'is-active': s.id === activeSessionId }"
:aria-label="'Open ' + sessionTitleFor(s)"
aria-describedby="workspace-collapsed-session-tip"
>
<span
class="workspace-session-dot"
:class="sessionAgentClass(s)"
aria-hidden="true"
>
<span
class="workspace-session-pip"
:class="'is-' + sessionPipTone(s)"
></span>
</span>
</button>
</span>
</template>
</div>
<span
id="workspace-collapsed-session-tip"
class="gf-tooltip gf-tooltip-right workspace-session-tooltip workspace-floating-dot-tooltip"
:class="{ 'is-visible': collapsedSessionTooltip }"
:style="collapsedSessionTooltipStyle()"
role="tooltip"
>
<span
x-text="collapsedSessionTooltip ? collapsedSessionTooltip.title : ''"
></span>
<span
class="gf-tooltip-secondary"
x-text="collapsedSessionTooltip ? collapsedSessionTooltip.meta : ''"
></span>
</span>
</div>
<div class="workspace-session-footer">
<span class="gf-tooltip-wrap workspace-footer-tooltip-wrap">
<button
@click="sessionsCollapsed = !sessionsCollapsed"
class="gf-btn gf-btn-md gf-btn-secondary workspace-session-toggle"
type="button"
:aria-expanded="(!sessionsCollapsed).toString()"
aria-controls="workspace-session-sidebar-content"
:aria-label="sessionsCollapsed ? 'Expand sessions' : 'Collapse sidebar'"
:aria-describedby="sessionsCollapsed ? 'workspace-expand-tip' : 'workspace-collapse-tip'"
>
<span
aria-hidden="true"
class="workspace-session-toggle-glyph"
x-text="sessionsCollapsed ? '»' : '«'"
></span>
<span x-show="!sessionsCollapsed" x-text="'Close sidebar'"></span>
<span
x-show="sessionsCollapsed"
class="sr-only"
x-text="'Expand sessions'"
></span>
</button>
<span
x-show="sessionsCollapsed"
id="workspace-expand-tip"
class="gf-tooltip gf-tooltip-right"
role="tooltip"
>Expand sessions</span
>
<span
x-show="!sessionsCollapsed"
id="workspace-collapse-tip"
class="gf-tooltip gf-tooltip-right"
role="tooltip"
>Collapse sidebar</span
>
</span>
</div>
</div>
<!-- Right terminal panel -->
<div
class="gf-card"
style="
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
"
>
<template x-if="!terminalAvailable">
<div class="flex-1 flex items-center justify-center">
<div class="text-center space-y-3 max-w-md px-4">
<div class="text-4xl">⌨</div>
<h2 class="text-xl font-semibold gf-text-secondary">
Terminal not available
</h2>
<p class="text-sm gf-text-muted">
node-pty failed to compile. Install C++ build tools, then
rebuild:
</p>
<div class="gf-card" style="padding: 10px 12px; text-align: left">
<template x-if="platformHint === 'linux'">
<code class="block text-xs font-mono gf-text-secondary"
>sudo apt install build-essential python3 && npm rebuild
node-pty</code
>
</template>
<template x-if="platformHint === 'darwin'">
<code class="block text-xs font-mono gf-text-secondary"
>xcode-select --install && npm rebuild node-pty</code
>
</template>
<template x-if="platformHint === 'win32'">
<code class="block text-xs font-mono gf-text-secondary"
>npm rebuild node-pty</code
>
</template>
<template x-if="!platformHint">
<code class="block text-xs font-mono gf-text-secondary"
>npm rebuild node-pty</code
>
</template>
<code class="block text-xs font-mono gf-text-secondary mt-1"
>pnpm: pnpm approve-builds</code
>
</div>
<p class="text-xs gf-text-muted" style="margin-top: 8px">
The CLI auditor, setup, and all other dashboard tabs work
without node-pty. This only affects the embedded terminal.
</p>
<p class="text-xs gf-text-muted">
Not using the embedded terminal? Install with
<code class="font-mono">--omit=optional</code> to skip the
native build entirely.
</p>
</div>
</div>
</template>
<template x-if="terminalAvailable && !terminalSessionId">
<div class="flex-1 flex items-center justify-center">
<div class="text-center space-y-3">
<p class="gf-text-muted">No active terminal session</p>
<button
@click="launchInTerminal('', activeRunner, { promptLabel: 'Manual session' })"
:disabled="launching || serverSessions.length >= serverMaxSessions"
:title="serverSessions.length >= serverMaxSessions ? 'Session limit reached. End one to launch another.' : 'Open terminal'"
class="gf-btn gf-btn-md gf-btn-primary"
>
<span
x-text="launching ? 'Launching terminal...' : 'Open terminal'"
></span>
</button>
</div>
</div>
</template>
<template x-if="terminalAvailable && sessions.length > 0">
<div class="flex flex-col flex-1 overflow-hidden">
<div
style="
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
"
>
<div style="display: flex; gap: 10px; min-width: 0">
<span
class="gf-status-dot"
:class="(terminalWaitingForRunner || terminalAwaitingInput) ? 'gf-status-waiting' : (terminalConnected ? 'gf-status-running' : 'gf-status-recent')"
:aria-label="(terminalWaitingForRunner || terminalAwaitingInput) ? 'Waiting' : (terminalConnected ? 'Running' : 'Disconnected')"
x-text="(terminalWaitingForRunner || terminalAwaitingInput) ? '!' : (terminalConnected ? '●' : '○')"
style="margin-top: 4px"
></span>
<div style="min-width: 0">
<div
style="
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
"
>
<h3
class="gf-text-primary"
style="
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 600;
"
:title="lastRunPrompt || 'Runner session'"
x-text="lastRunPrompt || 'Runner session'"
></h3>
<span
x-show="terminalWaitingForRunner"
style="
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid var(--border-subtle);
color: var(--text-muted);
background: var(--surface);
white-space: nowrap;
"
>Waiting for runner...</span
>
<span
x-show="terminalAwaitingInput"
style="
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid var(--amber-border);
color: var(--amber-400);
background: var(--amber-bg);
white-space: nowrap;
"
>Awaiting input</span
>
</div>
<p
class="gf-text-muted"
style="font-size: 12px; margin: 3px 0 0"
>
<span
class="mono"
x-text="lastRunAgent || activeRunner"
></span>
<span style="color: var(--text-disabled); margin: 0 6px"
>·</span
>
<span class="mono" x-text="terminalAge || '0m'"></span>
</p>
</div>
</div>
<div style="display: flex; gap: 6px; flex-shrink: 0">
<button
@click="launchInTerminal('', activeRunner, { promptLabel: 'Manual session' })"
:disabled="launching || serverSessions.length >= serverMaxSessions"
:title="serverSessions.length >= serverMaxSessions ? 'Session limit reached. End one to launch another.' : 'Open new terminal'"
class="gf-btn gf-btn-sm gf-btn-primary"
type="button"
>
<span aria-hidden="true">+</span>
<span
x-text="launching ? 'Launching...' : 'New terminal'"
></span>
</button>
<button
x-show="isSessionBoundLocally(activeSessionId)"
@click="exportSession(activeSessionId)"
class="gf-btn gf-btn-sm gf-btn-ghost"
type="button"
title="Export session scrollback"
>
Export
</button>
<button
@click="exitTerminal()"
class="gf-btn gf-btn-sm gf-btn-danger-solid"
>
End session
</button>
</div>
</div>
<div
class="flex-1 relative"
@dragenter.prevent="handleTerminalDragEnter($event)"
@dragover.prevent="handleTerminalDragOver($event)"
@dragleave.prevent="handleTerminalDragLeave($event)"
@drop.prevent="handleTerminalDrop($event)"
>
<template x-for="session in sessions" :key="session.id">
<div
class="terminal-session-shell"
x-show="session.id === activeSessionId"
style="position: absolute; inset: 0"
>
<div
:id="`gf-terminal-${session.id}`"
class="terminal-container"
style="position: absolute; inset: 0"
></div>
<div
x-show="!session.ended && session.loadingPhase !== 'ready'"
x-cloak
class="terminal-loading-overlay"
:class="session.loadingPhase === 'error' && 'terminal-loading-overlay-error'"
>
<div class="terminal-loading-card">
<div class="terminal-loading-row">
<span
x-show="session.loadingPhase !== 'error'"
class="terminal-loading-spinner"
aria-hidden="true"
></span>
<span x-text="terminalLoadingMessage(session)"></span>
</div>
<p
x-show="session.loadingShowSlowHint && session.loadingPhase !== 'error'"
class="terminal-loading-hint"
>
Still starting - cold starts can take a few seconds.
</p>
<button
x-show="session.loadingShowRetry || session.loadingPhase === 'error'"
@click.stop="retryTerminalSession(session.id)"
class="gf-btn gf-btn-sm gf-btn-secondary"
type="button"
>
Retry
</button>
</div>
</div>
</div>
</template>
<div
x-show="terminalDragActive && !terminalEnded && activeSessionId"
x-cloak
style="
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(99, 102, 241, 0.18);
border: 2px dashed var(--accent);
border-radius: 6px;
pointer-events: none;
z-index: 5;
"
>
<div
class="gf-text-primary"
style="
font-size: 14px;
font-weight: 600;
padding: 8px 16px;
background: var(--surface);
border-radius: 6px;
"
>
Drop image to attach
<span
x-show="terminalUploading"
class="gf-text-muted"
style="font-size: 12px; margin-left: 6px"
>
Uploading...
</span>
</div>
</div>
<div
x-show="terminalDetached || terminalEnded"
class="terminal-overlay"
>
<div class="text-center space-y-3">
<p
class="text-white text-lg font-semibold"
x-text="(terminalDetached || _projectSessions[projectPath]) ? 'Session detached' : 'Session ended'"
></p>
<div class="flex gap-2 justify-center">
<button
x-show="terminalDetached || _projectSessions[projectPath]"
@click="reconnectTerminal()"
class="gf-btn gf-btn-md gf-btn-primary"
>
Reconnect
</button>
<button
@click="exitTerminal()"
class="gf-btn gf-btn-md gf-btn-secondary"
>
New Session
</button>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>