feat(electrobun): agent execution layer — sidecar manager + message adapters + store

- SidecarManager: spawns claude/codex/ollama runners via Bun.spawn(),
  NDJSON stdio protocol, Claude CLI auto-detection, env stripping,
  AbortController stop, Deno/Node runtime detection
- MessageAdapter: parses Claude stream-json, Codex ThreadEvent, Ollama
  chunks into common AgentMessage format
- agent-store.svelte.ts: per-project reactive session state, RPC event
  listeners for agent.message/status/cost
- AgentPane: wired to real sessions (start/stop/prompt), stop button,
  thinking/system message rendering
- ProjectCard: status dot from real agent status, cost/tokens from store
- 5 new RPC types (agent.start/stop/prompt/list + events)
This commit is contained in:
Hibryda 2026-03-22 01:03:05 +01:00
parent 95f1f8208f
commit ef0183de7f
8 changed files with 1566 additions and 61 deletions

View file

@ -1,20 +1,11 @@
<script lang="ts">
import { tick } from 'svelte';
import ChatInput from './ChatInput.svelte';
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
interface AgentMessage {
id: number;
role: MsgRole;
content: string;
toolName?: string;
toolPath?: string;
}
import type { AgentMessage, AgentStatus } from './agent-store.svelte.ts';
interface Props {
messages: AgentMessage[];
status: 'running' | 'idle' | 'stalled';
status: AgentStatus;
costUsd: number;
tokens: number;
model?: string;
@ -23,6 +14,7 @@
contextPct?: number;
burnRate?: number;
onSend?: (text: string) => void;
onStop?: () => void;
}
let {
@ -34,11 +26,12 @@
provider = 'claude',
contextPct = 0,
onSend,
onStop,
}: Props = $props();
let scrollEl: HTMLDivElement;
let promptText = $state('');
let expandedTools = $state<Set<number>>(new Set());
let expandedTools = $state<Set<string>>(new Set());
// Drag-resize state
let agentPaneEl: HTMLDivElement;
@ -58,7 +51,7 @@
onSend?.(text);
}
function toggleTool(id: number) {
function toggleTool(id: string) {
const next = new Set(expandedTools);
next.has(id) ? next.delete(id) : next.add(id);
expandedTools = next;
@ -67,10 +60,18 @@
function fmtTokens(n: number) { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
function fmtCost(n: number) { return `$${n.toFixed(3)}`; }
function dotClass(s: string) {
function dotClass(s: AgentStatus) {
if (s === 'running') return 'dot-progress';
if (s === 'stalled') return 'dot-error';
return 'dot-success';
if (s === 'error') return 'dot-error';
if (s === 'done') return 'dot-success';
return 'dot-idle';
}
function statusLabel(s: AgentStatus) {
if (s === 'running') return 'Running';
if (s === 'error') return 'Error';
if (s === 'done') return 'Done';
return 'Idle';
}
function onResizeMouseDown(e: MouseEvent) {
@ -98,12 +99,19 @@
<!-- Status strip (top) -->
<div class="status-strip">
<span class="strip-dot {dotClass(status)}"></span>
<span class="strip-label">{status === 'running' ? 'Running' : status === 'stalled' ? 'Stalled' : 'Done'}</span>
<span class="strip-label">{statusLabel(status)}</span>
<span class="strip-model">{model}</span>
<span class="strip-spacer"></span>
<span class="strip-tokens">{fmtTokens(tokens)} tok</span>
<span class="strip-sep" aria-hidden="true"></span>
<span class="strip-cost">{fmtCost(costUsd)}</span>
{#if status === 'running' && onStop}
<button class="strip-stop-btn" onclick={onStop} title="Stop agent" aria-label="Stop agent">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="3" y="3" width="10" height="10" rx="1" />
</svg>
</button>
{/if}
</div>
<!-- Main pane with floating input -->
@ -126,6 +134,22 @@
<div class="timeline-content">{msg.content}</div>
</div>
{:else if msg.role === 'thinking'}
<div class="timeline-row">
{#if !isFirst}<div class="timeline-line-up"></div>{/if}
<div class="timeline-diamond dot-thinking"></div>
{#if !isLast}<div class="timeline-line-down"></div>{/if}
<div class="thinking-content">{msg.content}</div>
</div>
{:else if msg.role === 'system'}
<div class="timeline-row">
{#if !isFirst}<div class="timeline-line-up"></div>{/if}
<div class="timeline-diamond dot-system"></div>
{#if !isLast}<div class="timeline-line-down"></div>{/if}
<div class="system-content">{msg.content}</div>
</div>
{:else if msg.role === 'tool-call'}
<div class="timeline-row">
{#if !isFirst}<div class="timeline-line-up"></div>{/if}
@ -222,6 +246,9 @@
.dot-success { background: var(--ctp-green); }
.dot-progress { background: var(--ctp-peach); }
.dot-error { background: var(--ctp-red); }
.dot-idle { background: var(--ctp-overlay1); }
.dot-thinking { background: var(--ctp-mauve); }
.dot-system { background: var(--ctp-overlay0); }
.strip-label { color: var(--ctp-subtext1); font-weight: 500; }
.strip-model { color: var(--ctp-overlay1); margin-left: 0.25rem; }
@ -234,6 +261,32 @@
}
.strip-cost { color: var(--ctp-text); font-weight: 500; }
.strip-stop-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
padding: 0;
margin-left: 0.25rem;
background: transparent;
border: 1px solid var(--ctp-red);
border-radius: 0.2rem;
color: var(--ctp-red);
cursor: pointer;
transition: background 0.12s, color 0.12s;
flex-shrink: 0;
}
.strip-stop-btn:hover {
background: color-mix(in srgb, var(--ctp-red) 20%, transparent);
}
.strip-stop-btn svg {
width: 10px;
height: 10px;
}
/* ── Main pane ────────────────────────────────────────────── */
.agent-pane {
display: flex;
@ -424,6 +477,27 @@
.tool-result-content { color: var(--ctp-teal); }
/* ── Thinking content ──────────────────────────────────────── */
.thinking-content {
font-size: 0.8125rem;
line-height: 1.6;
color: var(--ctp-overlay1);
font-style: italic;
padding: 0.5rem 0;
white-space: pre-wrap;
word-break: break-word;
}
/* ── System content ────────────────────────────────────────── */
.system-content {
font-size: 0.75rem;
line-height: 1.5;
color: var(--ctp-overlay0);
padding: 0.25rem 0;
white-space: pre-wrap;
word-break: break-word;
}
.tool-fade-overlay {
position: absolute;
bottom: 0;