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.

837 lines (827 loc) 33.2 kB
<!-- ═══ 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 &mdash; 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">&#9000;</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>