Agents page: reorganize into the Agent Command Center (per-agent live metrics)
ci / gates (push) Failing after 13s
ci / rust (push) Has been skipped
ci / sandbox-k8s (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped

Replace the single centered "anatomy profile" + resizable chat split with an
operator command center: compact 62px identity strip → 5-tile per-agent metrics
band → three independently-scrolling LIVE · BRAIN · SURFACE columns. Chat moves to
the computer's Chat app; the right computer pullout (DevicePanel) is untouched.

- backend: cm-api/routes/world.rs emits per-agent `telemetry{agentId,tokensPerMin,
  costPerHr,loops,doorsPending}` in the SSE loop, from 4 batched GROUP BY queries
  (usage_events tokens/min + credits/hr, active routines, pending approvals) — all
  real, no migration. taxonomy `telemetry` gains optional agentId; stateKey now
  keys it per-agent so slices don't clobber.
- frontend: new ClawCommandCenter + anatomy-cards (shared cards extracted from
  Dashboard); useAgentTelemetry(agentId) feeds the metric band (Doors amber>0/
  green=0); LIVE column streams the agent's task.update / reasoning.delta /
  tool.call (replaces the mocked VitalsCard heatmap with a live activity chart).
- Dashboard: left region → full-height ClawCommandCenter; chat launcher opens the
  computer Chat app; removed the dead anatomy cluster + unused imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-26 11:34:12 -07:00
co-authored by Claude Opus 4.8
parent 731adb6449
commit 11a1f22daa
9 changed files with 2883 additions and 261 deletions
+158
View File
@@ -0,0 +1,158 @@
# Agent Command Center — Build Guide
How the Clawmates **Agent Command Center** page is built, and the rules to follow to recreate or extend it. The `.dc.html` in this folder is the working reference — open it directly in a browser. This doc is the recipe + the prompt to regenerate it.
---
## 1. What this page is
A single-agent **command center** — the close-up view of one agent in the fleet. It replaces the old "scrolling profile" (big avatar + year-long heatmap on top, live status buried at the bottom) with an operator surface where **live state and controls sit above the fold** and identity shrinks to a header strip.
It answers, at a glance:
1. **Is it healthy and how hard is it working?** → metrics band (throughput, spend, loops, memory, doors)
2. **What is it doing right now?** → LIVE column (working-on-now, reasoning stream, activity)
3. **What is its brain made of?** → BRAIN column (system prompt, personality, skills, capabilities)
4. **What can it touch, remember, and is it safe?** → SURFACE column (tools·doors, memory, safety)
5. **What's on its desktop?** → pinned right-side **computer pullout** (apps, now-running, dock)
The core interaction idea: **read on the left/center, act on the right.** Identity is small; the work is big.
---
## 2. The layout system
Fixed full-viewport shell (`100vw × 100vh`, `overflow:hidden`). Lock this skeleton first.
```
┌──────────────────────────────────────────────────────────────────┐
│ TOP BAR (54px) logo · breadcrumb · live pill · tok/min · avatar │
├──┬─────────┬──────────────────────────────────────────┬───────────┤
│IC│ AGENT │ ┌ identity header strip ───────────────┐ │ COMPUTER │
│ON│ LIST │ ├ METRICS BAND (5 tiles) ──────────────┤ │ PULLOUT │
│54│ 222px │ │ LIVE │ BRAIN │ SURFACE │ │ 344px │
│ │ │ │ 1.15fr │ 1fr │ 1fr │ │ apps · │
│ │ │ │ (each col scrolls independently) │ │ running · │
│ │ │ └────────────────────────────────────────┘ │ dock │
├──┴─────────┴──────────────────────────────────────────┴───────────┤
│ STATUS BAR (28px) durable runner · checkpoint · sandbox · doors │
└──────────────────────────────────────────────────────────────────┘
```
- **Icon rail (54px):** product switcher (World / Agent / Infra). Active = coral glyph + coral left spine.
- **Agent list (222px):** every agent in the team. Row = avatar + status dot, name, mono role, live action line. Active row gets the 3px coral spine + coral tint.
- **Command-center area (fluid):** identity header → metrics band → three scrolling columns.
- **Computer pullout (344px, pinned):** mirrors the product's right-side computer panel. Stays put; cards live to its **left**. Toggle with the `showComputer` prop.
- **Status bar (28px):** ambient truth, always-on mono.
Only the three center columns and the panels' inner bodies scroll; all chrome is `flex:none`. The whole thing fits one 1080p screen.
### The three center columns
- **LIVE (1.15fr):** `WORKING ON NOW` card (task + step list with a blinking caret) → `REASONING STREAM` terminal (think/tool/door tagged lines) → `ACTIVITY · LIVE` bar chart.
- **BRAIN (1fr):** collapsible `SYSTEM PROMPT` (click header to expand) → `PERSONALITY` chips → `SKILLS` chips → `CAPABILITIES` chips.
- **SURFACE (1fr):** `TOOLS · DOORS` (gated/blocked list + inline §15 approval card) → `MEMORY` → `SAFETY · §15`.
---
## 3. Color
Near-black warm-neutral base; **functional accents only** — don't add a color unless it means something. Aim for ~90% neutral.
| Token | Value | Used for |
|---|---|---|
| Canvas | `#08080a` | page background |
| Surface | `#0d0d10` | cards |
| Chrome | `#0a0a0c` / `#0b0b0e` | rails, panels, status bar |
| Hairline | `rgba(255,255,255,.06–.08)` | every border/divider |
| Text high | `#f3f3f5` | headings, key values |
| Text mid | `#8a8a92 / #9a9aa2` | body |
| Text low | `#5a5a62 / #6a6a72` | mono labels, meta |
**Accents (one meaning each):**
- **Coral `#ff6f61`** → brand + primary action + active nav. The coral→red fill `linear-gradient(135deg,#ff8a7a,#ff5f57)` is the one filled-button treatment.
- **Cyan `#5ec8d8`** → live / working / network / throughput / links.
- **Green `#5fd08a`** → healthy / done / loops running.
- **Amber `#e8b465`** → pending / door awaiting approval / spend / blocked tool.
- **Violet `#c98af0`** → reasoning ("think") + personality.
- **Teal `#6fd0c0`** → safety / sandbox.
The **doors** metric tile and status-bar text turn amber the moment an approval is pending and flip green when clear — that color shift is the page's most important signal.
---
## 4. Type
Two families, sharp roles:
- **Helvetica Neue** (system sans) — headings, names, prose.
- **JetBrains Mono** — everything machine: roles, status, IPs/hosts, the reasoning stream, stat labels, chips, breadcrumb, status bar. This sans/mono split is what makes it read as an operator tool, not a website.
Scale (floors): agent name `24/700/-.02em` · card titles `13–15/600–700` · body `11–13` · mono labels `9–10`, letter-spacing `.08–.12em`, low-contrast.
The uppercase mono kicker (`WORKING ON NOW`, `SYSTEM PROMPT`, `TOOLS · DOORS`) above every card is the connective rhythm — keep it.
---
## 5. Components & patterns
- **Identity header strip** — small haloed avatar (62px, pulsing ring) + name + mono `role · spec`, then model chip + coral "Edit brain →" + settings gear. Identity is deliberately compact.
- **Metric tile** — mono label, big `22/700` value, mono unit. Notable tiles tint border + value (cyan throughput, amber spend, green loops, amber doors-when-pending). The throughput tile carries a live sparkline.
- **Card** — `#0d0d10` + hairline (or accent-tinted) border, 13px radius, mono kicker + icon chip header. The single repeated container.
- **Reasoning stream** — black terminal; each line is a colored tag (`think`/`tool`/`door`/`note`/`result`) + text; last line gets a blinking caret.
- **Chip** — mono, small, tinted pill for personality/skills/capabilities. A trailing `+N` chip implies overflow.
- **Inline §15 door** — amber-bordered approval card with a gradient top-rule: title, mono detail, Approve / Review / ✕. Approving clears it to a green "no doors awaiting approval" line and updates the metric + status bar.
- **Computer pullout** — pinned right panel: header w/ device icons, sandbox status, apps grid (real brand glyphs as inline SVG), NOW RUNNING list (loop/cron items with progress bars), bottom dock (Skills/Files/Routines/Settings).
- **Indicators** — reuse three primitives everywhere: colored **dot** (state), thin **bar** (utilization/progress), bordered **pill** (labeled status). Don't invent new ones.
---
## 6. Motion
Restrained and diegetic — movement only ever means "this is live" or "the view changed." Six keyframes:
`cm-blink` (live dots/caret), `cm-halo` (avatar ring pulse), `cm-type` (caret), `cm-bar` (sparkline/activity bars), `cm-fade` (0.2–0.25s panel/step entrance). No load chrome, no parallax, no decorative motion. All of it is gated behind the `liveStream` prop so it can be paused.
---
## 7. Voice
Technical, plain, security-forward. Buttons name the outcome ("Edit brain →", "Approve"). The §15 / door / sandbox / "no network" language repeats the trust story. Lead with what the agent is doing, not what it is.
---
## 8. How it's built (structure)
This is a **Design Component** (`.dc.html`) — one self-contained file + `support.js` (the runtime; keep it beside the page).
- **Template** = markup styled **entirely inline** (only `@keyframes` + scrollbar rules live in `<helmet>`). Inline styles paint immediately as the file streams.
- **Logic** = `class Component extends DCLogic` at the bottom. State: `selected` (which agent), `promptOpen`, `doorApproved`. `renderVals()` returns every `{{ … }}` the template binds + all handlers.
- **Data is mocked** in an `AGENTS` map inside `renderVals()` (Morpheus, Smith). Each agent carries its metrics, task, reasoning, personality, skills, tools, memory, safety, door, prompt, and now-running list. **To wire real data, replace that map with your fetched agent state** — the template shape is stable.
- **Control flow:** `<sc-for>` repeats agent rows, steps, reasoning lines, chips, tools, running items; `<sc-if>` toggles the prompt body, the door card vs the cleared state, and the computer pullout. State-driven contents, not separate pages.
- **Props / tweaks:** `liveStream` (pause all animation), `showComputer` (show/hide the pullout), `defaultPromptOpen` (start with the system prompt expanded).
### To build a page in this style
1. Drop the **shell** (top bar / icon rail / agent list / command area / computer pullout / status bar); pin everything but the center columns and panel bodies.
2. Shrink **identity to a header strip**; never let the avatar dominate.
3. Put the glance-priority metrics in a **5-tile band** directly under it.
4. Split the work into **LIVE · BRAIN · SURFACE** columns, each scrolling independently.
5. Keep the **computer as a pinned right pullout**; all cards go to its left.
6. Use only the accents above, **one meaning each**; show state with the dot/bar/pill trio and the six keyframes — nothing more.
---
## 9. Prompt to regenerate
> Build a single-agent **command center** as a Design Component in the Clawmates dark-operator visual system (near-black `#08080a` canvas, `#0d0d10` cards, hairline borders, Helvetica Neue + JetBrains Mono, functional accents: coral `#ff6f61` brand/primary, cyan `#5ec8d8` live, green `#5fd08a` healthy, amber `#e8b465` pending, violet `#c98af0` reasoning, teal `#6fd0c0` safety).
>
> Fixed full-viewport shell: 54px top bar (logo · breadcrumb · live pill · tok/min · avatar) → body [54px icon rail · 222px agent list · fluid command area · 344px pinned computer pullout] → 28px status bar. Only the center columns and panel bodies scroll.
>
> Command area = compact identity header strip (small haloed 62px avatar + name + mono role·spec + model chip + coral "Edit brain →") → 5-tile metrics band (Throughput w/ sparkline, Spend, Loops, Memory, Doors — doors tile + status bar turn amber when an approval is pending) → three independently-scrolling columns: **LIVE** (working-on-now task w/ step list + caret, reasoning-stream terminal, activity bars), **BRAIN** (collapsible system prompt, personality / skills / capabilities chips), **SURFACE** (tools·doors w/ inline §15 approval card, memory, safety).
>
> Pinned right **computer pullout**: header w/ device icons, "sandbox live · no network", apps grid (Browser/Slack/Terminal/Obsidian/Chat/Add as inline-SVG glyphs), NOW RUNNING list (loop/cron items w/ progress bars), bottom dock (Skills/Files/Routines/Settings).
>
> Mock 2 agents (a PM and a researcher) in an `AGENTS` map; clicking a list row switches everything. Interactions: switch agent, expand/collapse system prompt, approve the pending door (clears it + updates the doors metric + status bar). Inline styles only; six restrained keyframes (blink/halo/type/bar/fade) gated behind a `liveStream` prop. Expose props: `liveStream`, `showComputer`, `defaultPromptOpen`. Fit one 1080p screen.
---
## 10. Files in this package
- `Clawmates Agent Command Center.dc.html` — the page (open directly in a browser).
- `support.js` — the runtime the `.dc.html` needs; keep it beside the page.
- `BUILD-GUIDE.md` — this document.
@@ -0,0 +1,592 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: #08080a; }
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; }
@keyframes cm-blink { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
@keyframes cm-halo { 0% { transform: scale(.7); opacity: .5; } 100% { transform: scale(1.9); opacity: 0; } }
@keyframes cm-type { 0%,100% { opacity: 1; } 50% { opacity: 0; } }
@keyframes cm-bar { 0%,100% { transform: scaleY(.4); } 50% { transform: scaleY(1); } }
@keyframes cm-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.09); border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
</style>
</helmet>
<div style="width:100%; height:100vh; min-height:680px; background:#08080a; display:flex; flex-direction:column; color:#f3f3f5; overflow:hidden;">
<!-- TOP BAR -->
<div style="height:54px; flex:none; display:flex; align-items:center; gap:14px; padding:0 18px; border-bottom:1px solid rgba(255,255,255,.06); background:linear-gradient(180deg,#0d0d10,#0a0a0c);">
<div style="display:flex; align-items:center; gap:10px;">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<path d="M11 3 L18.5 17 L3.5 17 Z" stroke="#ff6f61" stroke-width="1.3" stroke-linejoin="round" opacity="0.55"></path>
<circle cx="11" cy="3.5" r="2.4" fill="#ff6f61"></circle><circle cx="18" cy="17" r="2.4" fill="#ff6f61"></circle><circle cx="4" cy="17" r="2.4" fill="#ff6f61"></circle>
</svg>
<span style="font-size:15px; font-weight:700; letter-spacing:-.01em;">Clawmates</span>
</div>
<div style="width:1px; height:22px; background:rgba(255,255,255,.08);"></div>
<div style="display:flex; align-items:center; gap:8px; font-family:'JetBrains Mono',monospace; font-size:12px;">
<span style="color:#6a6a72;">Large World</span>
<span style="color:#3a3a40;">/</span>
<span style="color:#9a9aa2;">Agents</span>
<span style="color:#3a3a40;">/</span>
<span style="color:#fff; background:rgba(255,111,97,.14); border:1px solid rgba(255,111,97,.3); padding:3px 8px; border-radius:6px;">{{ selName }}</span>
</div>
<div style="flex:1;"></div>
<div style="display:flex; align-items:center; gap:7px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#5ec8d8; padding:5px 10px; border:1px solid rgba(94,200,216,.25); border-radius:7px; background:rgba(94,200,216,.06);">
<span style="width:7px; height:7px; border-radius:50%; background:#5ec8d8; {{ blinkAnim }}"></span>{{ selStatusLabel }}
</div>
<div style="display:flex; align-items:center; gap:5px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#8a8a92;">
<span style="color:#e8b465;">▮</span> {{ selTok }} tok/min
</div>
<div style="width:32px; height:32px; border-radius:8px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:700; color:#2a0d0a;">O</div>
</div>
<!-- BODY -->
<div style="flex:1; display:flex; min-height:0;">
<!-- ICON RAIL -->
<div style="width:54px; flex:none; border-right:1px solid rgba(255,255,255,.06); background:#0a0a0c; display:flex; flex-direction:column; align-items:center; padding:12px 0; gap:6px;">
<div style="width:42px; height:46px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; border-radius:9px; color:#5a5a62; cursor:pointer;">
<svg width="19" height="19" viewBox="0 0 20 20"><circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.4" fill="none"></circle><circle cx="10" cy="10" r="2.4" fill="currentColor"></circle></svg>
<span style="font-family:'JetBrains Mono',monospace; font-size:7px; letter-spacing:.05em;">WORLD</span>
</div>
<div style="position:relative; width:42px; height:46px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; border-radius:9px; color:#ff6f61; background:rgba(255,111,97,.1); cursor:pointer;">
<span style="position:absolute; left:0; top:7px; bottom:7px; width:3px; border-radius:0 3px 3px 0; background:#ff6f61;"></span>
<svg width="19" height="19" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" stroke-width="1.4" fill="none"></rect><circle cx="10" cy="10" r="2.2" fill="currentColor"></circle></svg>
<span style="font-family:'JetBrains Mono',monospace; font-size:7px; letter-spacing:.05em;">AGENT</span>
</div>
<div style="width:42px; height:46px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; border-radius:9px; color:#5a5a62; cursor:pointer;">
<svg width="19" height="19" viewBox="0 0 20 20"><rect x="3.5" y="4" width="13" height="5" rx="1.4" stroke="currentColor" stroke-width="1.3" fill="none"></rect><rect x="3.5" y="11" width="13" height="5" rx="1.4" stroke="currentColor" stroke-width="1.3" fill="none"></rect></svg>
<span style="font-family:'JetBrains Mono',monospace; font-size:7px; letter-spacing:.05em;">INFRA</span>
</div>
<div style="flex:1;"></div>
<div style="width:30px; height:30px; border-radius:8px; border:1px dashed rgba(255,255,255,.16); display:flex; align-items:center; justify-content:center; color:#ff6f61; font-size:18px; font-weight:300; cursor:pointer;">+</div>
</div>
<!-- AGENT LIST -->
<div style="width:222px; flex:none; border-right:1px solid rgba(255,255,255,.06); background:#0b0b0e; display:flex; flex-direction:column; min-height:0;">
<div style="padding:14px 14px 10px;">
<div style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.12em; color:#5a5a62;">2 AGENTS · LIVE</div>
<div style="font-size:17px; font-weight:700; margin-top:3px;">Agents</div>
</div>
<div style="flex:1; overflow-y:auto; padding:6px;">
<div style="display:flex; flex-direction:column; gap:3px;">
<sc-for list="{{ agents }}" as="a" hint-placeholder-count="2">
<div style="display:flex; align-items:flex-start; gap:10px; padding:9px 10px; border-radius:10px; cursor:pointer; position:relative; background:{{ a.rowBg }}; border:1px solid {{ a.rowBorder }};" onClick="{{ a.onSelect }}">
<sc-if value="{{ a.selected }}" hint-placeholder-val="{{ true }}"><span style="position:absolute; left:0; top:9px; bottom:9px; width:3px; border-radius:0 3px 3px 0; background:#ff6f61;"></span></sc-if>
<div style="position:relative; width:32px; height:32px; flex:none;">
<div style="width:32px; height:32px; border-radius:9px; background:{{ a.grad }}; display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:700; color:{{ a.ink }};">{{ a.initial }}</div>
<span style="position:absolute; right:-2px; bottom:-2px; width:10px; height:10px; border-radius:50%; background:{{ a.statusColor }}; border:2px solid #0b0b0e;"></span>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:{{ a.labelColor }};">{{ a.name }}</div>
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:{{ a.roleColor }};">{{ a.role }}</div>
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; color:{{ a.statusColor }}; margin-top:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{{ a.action }}</div>
</div>
</div>
</sc-for>
</div>
</div>
<div style="flex:none; padding:10px 12px; border-top:1px solid rgba(255,255,255,.06);">
<div style="display:flex; align-items:center; justify-content:center; gap:7px; height:32px; border-radius:8px; border:1px solid rgba(94,200,216,.3); color:#5ec8d8; font-size:12px; font-weight:600; cursor:pointer;">⊕ Add to team</div>
</div>
</div>
<!-- COMMAND CENTER -->
<div data-screen-label="Agent Command Center" style="flex:1; min-width:0; display:flex; flex-direction:column; min-height:0; background:radial-gradient(130% 100% at 30% 0%, #100e13 0%, #08080a 60%);">
<!-- IDENTITY HEADER STRIP -->
<div style="flex:none; display:flex; align-items:center; gap:16px; padding:16px 20px 14px; border-bottom:1px solid rgba(255,255,255,.06);">
<div style="position:relative; width:62px; height:62px; flex:none;">
<div style="position:absolute; inset:-5px; border-radius:50%; border:1.5px solid rgba(255,111,97,.4);"></div>
<div style="position:absolute; inset:0; border-radius:50%; background:rgba(255,111,97,.3); {{ haloAnim }}"></div>
<div style="position:relative; width:62px; height:62px; border-radius:50%; background:{{ selGrad }}; display:flex; align-items:center; justify-content:center; font-size:24px; font-weight:700; color:{{ selInk }}; box-shadow:0 0 30px rgba(255,111,97,.4);">{{ selInitial }}</div>
</div>
<div style="min-width:0;">
<div style="font-size:24px; font-weight:700; letter-spacing:-.02em; line-height:1.1;">{{ selName }}</div>
<div style="font-family:'JetBrains Mono',monospace; font-size:11px; color:#6a6a72; margin-top:3px;">{{ selRole }} · {{ selSpec }}</div>
</div>
<div style="flex:1;"></div>
<div style="display:flex; align-items:center; gap:8px; padding:7px 11px; border-radius:9px; background:#101014; border:1px solid rgba(255,255,255,.08);">
<span style="width:7px; height:7px; border-radius:50%; background:#ff6f61;"></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:11px; color:#cfcfd5;">{{ selModel }}</span>
</div>
<div style="display:flex; align-items:center; gap:7px; padding:8px 13px; border-radius:9px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); color:#2a0d0a; font-size:12px; font-weight:700; cursor:pointer;">Edit brain →</div>
<div style="width:34px; height:34px; border-radius:9px; border:1px solid rgba(255,255,255,.1); display:flex; align-items:center; justify-content:center; color:#9a9aa2; cursor:pointer;"><svg width="16" height="16" viewBox="0 0 18 18"><circle cx="9" cy="9" r="2.4" fill="currentColor"></circle><path d="M9 1.5v2.2M9 14.3v2.2M1.5 9h2.2M14.3 9h2.2M3.6 3.6l1.6 1.6M12.8 12.8l1.6 1.6M14.4 3.6l-1.6 1.6M5.2 12.8l-1.6 1.6" stroke="currentColor" stroke-width="1.4"></path></svg></div>
</div>
<!-- METRICS BAND -->
<div style="flex:none; display:flex; gap:12px; padding:14px 20px;">
<div style="flex:1.4; border-radius:11px; background:#0d0d10; border:1px solid rgba(94,200,216,.22); padding:11px 14px; display:flex; align-items:center; gap:12px;">
<div style="min-width:0;">
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72;">THROUGHPUT</div>
<div style="display:flex; align-items:baseline; gap:5px; margin-top:4px;"><span style="font-size:22px; font-weight:700; color:#5ec8d8; letter-spacing:-.01em;">{{ selTok }}</span><span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">tok/min</span></div>
</div>
<span style="flex:1;"></span>
<span style="display:flex; align-items:flex-end; gap:2px; height:30px;">
<span style="width:3px; background:#2c5a64; height:40%; border-radius:1px;"></span>
<span style="width:3px; background:#3a7e8c; height:68%; border-radius:1px;"></span>
<span style="width:3px; background:#4aa3b8; height:52%; border-radius:1px; transform-origin:bottom; {{ barAnim }}"></span>
<span style="width:3px; background:#5ec8d8; height:88%; border-radius:1px; transform-origin:bottom; {{ barAnim2 }}"></span>
<span style="width:3px; background:#4aa3b8; height:64%; border-radius:1px;"></span>
<span style="width:3px; background:#3a7e8c; height:46%; border-radius:1px;"></span>
<span style="width:3px; background:#5ec8d8; height:76%; border-radius:1px;"></span>
</span>
</div>
<div style="flex:1; border-radius:11px; background:#0d0d10; border:1px solid rgba(255,255,255,.06); padding:11px 14px;">
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72;">SPEND</div>
<div style="display:flex; align-items:baseline; gap:5px; margin-top:4px;"><span style="font-size:22px; font-weight:700; color:#e8b465; letter-spacing:-.01em;">{{ selSpend }}</span><span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">/hr</span></div>
</div>
<div style="flex:1; border-radius:11px; background:#0d0d10; border:1px solid rgba(255,255,255,.06); padding:11px 14px;">
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72;">LOOPS</div>
<div style="display:flex; align-items:baseline; gap:5px; margin-top:4px;"><span style="font-size:22px; font-weight:700; color:#5fd08a; letter-spacing:-.01em;">{{ selLoops }}</span><span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">running</span></div>
</div>
<div style="flex:1; border-radius:11px; background:#0d0d10; border:1px solid rgba(255,255,255,.06); padding:11px 14px;">
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#6a6a72;">MEMORY</div>
<div style="display:flex; align-items:baseline; gap:5px; margin-top:4px;"><span style="font-size:22px; font-weight:700; color:#cfcfd5; letter-spacing:-.01em;">{{ selMem }}</span><span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">notes</span></div>
</div>
<div style="flex:1; border-radius:11px; background:#0d0d10; border:1px solid {{ doorsBorder }}; padding:11px 14px;">
<div style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:{{ doorsLabelColor }};">DOORS</div>
<div style="display:flex; align-items:baseline; gap:5px; margin-top:4px;"><span style="font-size:22px; font-weight:700; color:{{ doorsValColor }}; letter-spacing:-.01em;">{{ doorsLive }}</span><span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#6a6a72;">pending</span></div>
</div>
</div>
<!-- THREE COLUMNS -->
<div style="flex:1; min-height:0; display:flex; gap:14px; padding:0 20px 16px;">
<!-- LIVE COLUMN -->
<div style="flex:1.15; min-width:0; display:flex; flex-direction:column; gap:12px; overflow-y:auto;">
<div style="flex:none; border-radius:13px; border:1px solid rgba(94,200,216,.24); background:linear-gradient(180deg,#0c1416,#0b0e0f); overflow:hidden;">
<div style="display:flex; align-items:center; gap:8px; padding:11px 14px; border-bottom:1px solid rgba(255,255,255,.05);">
<span style="width:7px; height:7px; border-radius:50%; background:#5ec8d8; {{ blinkAnim }}"></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.12em; color:#5ec8d8;">WORKING ON NOW</span>
<span style="flex:1;"></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#6a6a72;">{{ taskElapsed }}</span>
</div>
<div style="padding:13px 14px;">
<div style="font-size:15px; font-weight:600; margin-bottom:8px; line-height:1.35;">{{ taskTitle }} <span style="font-family:'JetBrains Mono',monospace; color:#ff8a7a;">{{ taskCode }}</span></div>
<div style="display:flex; flex-direction:column; gap:5px; font-family:'JetBrains Mono',monospace; font-size:11px; line-height:1.6;">
<sc-for list="{{ taskSteps }}" as="st" hint-placeholder-count="3">
<div style="color:#7a7a82;"><span style="color:{{ st.iconColor }};">{{ st.icon }}</span> {{ st.text }} <span style="color:#5a5a62;">{{ st.meta }}</span><sc-if value="{{ st.caret }}" hint-placeholder-val="{{ false }}"><span style="display:inline-block; width:6px; height:12px; background:#5ec8d8; margin-left:2px; vertical-align:-2px; {{ caretAnim }}"></span></sc-if></div>
</sc-for>
</div>
</div>
</div>
<div style="flex:1; min-height:160px; border-radius:13px; border:1px solid rgba(255,255,255,.08); background:#070708; overflow:hidden; display:flex; flex-direction:column;">
<div style="flex:none; display:flex; align-items:center; gap:8px; padding:10px 13px; border-bottom:1px solid rgba(255,255,255,.06);">
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.12em; color:#5fd08a;">▌ REASONING STREAM</span>
<span style="flex:1;"></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#5a5a62;">{{ selModelShort }} · {{ reasoningTime }}</span>
</div>
<div style="flex:1; min-height:0; overflow-y:auto; padding:12px 14px; font-family:'JetBrains Mono',monospace; font-size:11px; line-height:1.7;">
<sc-for list="{{ reasoning }}" as="r" hint-placeholder-count="4">
<div style="color:#9a9aa2; margin-bottom:7px;"><span style="color:{{ r.tagColor }};">{{ r.tag }}</span> {{ r.text }}<sc-if value="{{ r.caret }}" hint-placeholder-val="{{ false }}"><span style="display:inline-block; width:6px; height:12px; background:#5fd08a; margin-left:3px; vertical-align:-2px; {{ caretAnim }}"></span></sc-if></div>
</sc-for>
</div>
</div>
<div style="flex:none; border-radius:13px; border:1px solid rgba(255,255,255,.07); background:#0d0d10; padding:13px 14px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:11px;">
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.12em; color:#5fd08a;">● ACTIVITY · LIVE</span>
<span style="flex:1;"></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#6a6a72;">{{ selCommits }} commits · {{ selHot }} hot</span>
</div>
<div style="display:flex; align-items:flex-end; gap:3px; height:46px;">
<span style="flex:1; background:#1e3a2a; border-radius:2px; height:30%;"></span>
<span style="flex:1; background:#2e6e44; border-radius:2px; height:55%;"></span>
<span style="flex:1; background:#3a9457; border-radius:2px; height:42%;"></span>
<span style="flex:1; background:#46c46a; border-radius:2px; height:78%;"></span>
<span style="flex:1; background:#e8b465; border-radius:2px; height:62%; transform-origin:bottom; {{ barAnim }}"></span>
<span style="flex:1; background:#2e6e44; border-radius:2px; height:48%;"></span>
<span style="flex:1; background:#3a9457; border-radius:2px; height:70%;"></span>
<span style="flex:1; background:#46c46a; border-radius:2px; height:90%; transform-origin:bottom; {{ barAnim2 }}"></span>
<span style="flex:1; background:#2e6e44; border-radius:2px; height:36%;"></span>
<span style="flex:1; background:#3a9457; border-radius:2px; height:58%;"></span>
<span style="flex:1; background:#1e3a2a; border-radius:2px; height:44%;"></span>
<span style="flex:1; background:#46c46a; border-radius:2px; height:66%;"></span>
</div>
</div>
</div>
<!-- BRAIN COLUMN -->
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:12px; overflow-y:auto;">
<div style="flex:none; border-radius:13px; background:#0d0d10; border:1px solid rgba(255,111,97,.22); overflow:hidden;">
<div style="display:flex; align-items:center; gap:8px; padding:12px 13px; cursor:pointer;" onClick="{{ togglePrompt }}">
<span style="width:18px; height:18px; border-radius:5px; background:rgba(255,111,97,.16); display:flex; align-items:center; justify-content:center;"><svg width="11" height="11" viewBox="0 0 16 16"><path d="M4 2h6l2 2v10H4z" fill="none" stroke="#ff6f61" stroke-width="1.3" stroke-linejoin="round"></path><path d="M6 6h4M6 9h4" stroke="#ff6f61" stroke-width="1.2"></path></svg></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.08em; color:#ff8a7a;">SYSTEM PROMPT</span>
<span style="flex:1;"></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5a5a62;">{{ promptChars }} ch</span>
<span style="font-family:'JetBrains Mono',monospace; font-size:11px; color:#ff8a7a;">{{ promptChevron }}</span>
</div>
<sc-if value="{{ promptOpen }}" hint-placeholder-val="{{ false }}">
<div style="padding:0 14px 13px; animation:cm-fade .2s ease;">
<div style="max-height:148px; overflow-y:auto; padding:11px 12px; border-radius:9px; background:#08080a; border:1px solid rgba(255,255,255,.06); font-family:'JetBrains Mono',monospace; font-size:11px; line-height:1.7; color:#9a9aa2; white-space:pre-wrap;">{{ promptText }}</div>
</div>
</sc-if>
</div>
<div style="flex:none; border-radius:13px; background:#0d0d10; border:1px solid rgba(201,138,240,.22); padding:12px 13px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
<span style="width:18px; height:18px; border-radius:5px; background:rgba(201,138,240,.16); display:flex; align-items:center; justify-content:center;"><svg width="11" height="11" viewBox="0 0 18 18"><circle cx="9" cy="6" r="3.4" fill="#c98af0"></circle><path d="M3 16c0-3.3 2.7-6 6-6s6 2.7 6 6" fill="#c98af0"></path></svg></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.08em; color:#c98af0;">PERSONALITY</span>
</div>
<div style="display:flex; flex-wrap:wrap; gap:5px;">
<sc-for list="{{ personality }}" as="p" hint-placeholder-count="3">
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#cfcfd5; padding:4px 9px; border-radius:6px; background:rgba(201,138,240,.1); border:1px solid rgba(201,138,240,.18);">{{ p }}</span>
</sc-for>
</div>
</div>
<div style="flex:none; border-radius:13px; background:#0d0d10; border:1px solid rgba(255,111,97,.18); padding:12px 13px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
<span style="width:18px; height:18px; border-radius:5px; background:rgba(255,111,97,.16); display:flex; align-items:center; justify-content:center;"><svg width="11" height="11" viewBox="0 0 18 18"><path d="M10 1 L3 10 H8 L7 17 L15 7 H9 Z" fill="#ff6f61"></path></svg></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.08em; color:#ff8a7a;">SKILLS</span>
<span style="flex:1;"></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:#5a5a62;">{{ skillsCount }}</span>
</div>
<div style="display:flex; flex-wrap:wrap; gap:5px;">
<sc-for list="{{ skills }}" as="sk" hint-placeholder-count="4">
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#cfcfd5; padding:4px 9px; border-radius:6px; background:rgba(255,255,255,.05);">{{ sk }}</span>
</sc-for>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#8a8a92; padding:4px 9px; border-radius:6px; background:rgba(255,255,255,.03);">{{ skillsMore }}</span>
</div>
</div>
<div style="flex:none; border-radius:13px; background:#0d0d10; border:1px solid rgba(232,196,106,.2); padding:12px 13px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
<span style="width:18px; height:18px; border-radius:5px; background:rgba(232,196,106,.16); display:flex; align-items:center; justify-content:center;"><svg width="11" height="11" viewBox="0 0 18 18"><rect x="3" y="3" width="12" height="12" rx="2.5" fill="none" stroke="#e8b465" stroke-width="1.5"></rect><rect x="6.5" y="6.5" width="5" height="5" rx="1" fill="#e8b465"></rect></svg></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.08em; color:#e8b465;">CAPABILITIES</span>
</div>
<div style="display:flex; flex-wrap:wrap; gap:5px;">
<sc-for list="{{ capabilities }}" as="c" hint-placeholder-count="3">
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#cfcfd5; padding:4px 9px; border-radius:6px; background:rgba(255,255,255,.05);">{{ c }}</span>
</sc-for>
</div>
</div>
</div>
<!-- SURFACE COLUMN -->
<div style="flex:1; min-width:0; display:flex; flex-direction:column; gap:12px; overflow-y:auto;">
<div style="flex:none; border-radius:13px; background:#0d0d10; border:1px solid rgba(94,200,216,.22); padding:12px 13px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
<span style="width:18px; height:18px; border-radius:5px; background:rgba(94,200,216,.16); display:flex; align-items:center; justify-content:center;"><svg width="11" height="11" viewBox="0 0 18 18"><path d="M4 3v12M4 4h8l-2 3 2 3H4" fill="none" stroke="#5ec8d8" stroke-width="1.5" stroke-linejoin="round"></path></svg></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.08em; color:#5ec8d8;">TOOLS · DOORS</span>
</div>
<div style="display:flex; flex-direction:column; gap:6px; font-family:'JetBrains Mono',monospace; font-size:11px;">
<sc-for list="{{ tools }}" as="t" hint-placeholder-count="4">
<div style="display:flex; align-items:center;"><span style="flex:1; color:{{ t.nameColor }};">{{ t.name }}</span><span style="color:{{ t.stateColor }};">{{ t.state }}</span></div>
</sc-for>
</div>
<sc-if value="{{ showDoor }}" hint-placeholder-val="{{ true }}">
<div style="margin-top:11px; border-radius:10px; border:1px solid rgba(232,196,106,.4); background:linear-gradient(180deg,#1a1408,#12100a); overflow:hidden; animation:cm-fade .2s ease;">
<div style="height:3px; background:linear-gradient(90deg,#e8b465,#ff6f61);"></div>
<div style="padding:11px 12px;">
<div style="display:flex; align-items:center; gap:7px; margin-bottom:8px;">
<span style="font-family:'JetBrains Mono',monospace; font-size:9px; letter-spacing:.1em; color:#e8b465;">§15 DOOR · APPROVAL</span>
</div>
<div style="font-size:13px; font-weight:600; margin-bottom:3px;">{{ doorTitle }}</div>
<div style="font-family:'JetBrains Mono',monospace; font-size:10px; color:#8a8a92; line-height:1.6; margin-bottom:11px;">{{ doorDetail }}</div>
<div style="display:flex; gap:7px;">
<div style="flex:1; display:flex; align-items:center; justify-content:center; height:31px; border-radius:8px; background:linear-gradient(135deg,#ffb27a,#e8b465); color:#2a1d05; font-size:12px; font-weight:700; cursor:pointer;" onClick="{{ approveDoor }}">Approve</div>
<div style="flex:1; display:flex; align-items:center; justify-content:center; height:31px; border-radius:8px; border:1px solid rgba(255,255,255,.14); color:#cfcfd5; font-size:12px; font-weight:600; cursor:pointer;">Review</div>
<div style="width:34px; display:flex; align-items:center; justify-content:center; height:31px; border-radius:8px; border:1px solid rgba(255,255,255,.1); color:#8a8a92; cursor:pointer;" onClick="{{ approveDoor }}">✕</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ doorClear }}" hint-placeholder-val="{{ false }}">
<div style="margin-top:11px; display:flex; align-items:center; gap:7px; padding:9px 11px; border-radius:9px; background:rgba(95,208,138,.07); border:1px solid rgba(95,208,138,.22); font-family:'JetBrains Mono',monospace; font-size:10px; color:#5fd08a;">✓ no doors awaiting approval</div>
</sc-if>
</div>
<div style="flex:none; border-radius:13px; background:#0d0d10; border:1px solid rgba(95,208,138,.2); padding:12px 13px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
<span style="width:18px; height:18px; border-radius:5px; background:rgba(95,208,138,.16); display:flex; align-items:center; justify-content:center;"><svg width="11" height="11" viewBox="0 0 18 18"><rect x="3" y="3" width="12" height="12" rx="2" fill="none" stroke="#5fd08a" stroke-width="1.6"></rect><path d="M6 7h6M6 11h4" stroke="#5fd08a" stroke-width="1.4"></path></svg></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.08em; color:#5fd08a;">MEMORY</span>
</div>
<div style="display:flex; flex-direction:column; gap:6px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#9a9aa2;">
<sc-for list="{{ memory }}" as="m" hint-placeholder-count="3">
<div style="display:flex;"><span style="flex:1;">{{ m.k }}</span><span style="color:#cfcfd5;">{{ m.v }}</span></div>
</sc-for>
</div>
</div>
<div style="flex:none; border-radius:13px; background:#0d0d10; border:1px solid rgba(111,208,192,.2); padding:12px 13px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
<span style="width:18px; height:18px; border-radius:5px; background:rgba(111,208,192,.16); display:flex; align-items:center; justify-content:center;"><svg width="11" height="11" viewBox="0 0 18 18"><path d="M9 2l5 2v4c0 4-2.5 6.5-5 8-2.5-1.5-5-4-5-8V4z" fill="none" stroke="#6fd0c0" stroke-width="1.5" stroke-linejoin="round"></path></svg></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.08em; color:#6fd0c0;">SAFETY · §15</span>
</div>
<div style="display:flex; flex-direction:column; gap:6px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#9a9aa2;">
<sc-for list="{{ safety }}" as="sf" hint-placeholder-count="3">
<div style="display:flex;"><span style="flex:1;">{{ sf.k }}</span><span style="color:{{ sf.color }};">{{ sf.v }}</span></div>
</sc-for>
</div>
</div>
</div>
</div>
</div>
<!-- COMPUTER PULLOUT PANEL -->
<sc-if value="{{ showComputer }}" hint-placeholder-val="{{ true }}">
<div style="width:344px; flex:none; border-left:1px solid rgba(255,255,255,.06); background:#0b0b0e; display:flex; flex-direction:column; min-height:0; animation:cm-fade .25s ease;">
<div style="flex:none; display:flex; align-items:center; gap:9px; padding:13px 14px 11px; border-bottom:1px solid rgba(255,255,255,.06);">
<span style="width:7px; height:7px; border-radius:50%; background:#ff6f61;"></span>
<span style="font-size:13px; font-weight:700;">{{ selName }}'s Computer</span>
<span style="flex:1;"></span>
<div style="display:flex; align-items:center; gap:8px; color:#5a5a62;">
<svg width="15" height="15" viewBox="0 0 18 18" style="cursor:pointer; color:#ff6f61;"><path d="M3 4a1.5 1.5 0 011.5-1.5h9A1.5 1.5 0 0115 4v6a1.5 1.5 0 01-1.5 1.5H7l-4 3.5V4z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"></path></svg>
<svg width="15" height="15" viewBox="0 0 18 18" style="cursor:pointer;"><rect x="2" y="3" width="14" height="9" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.3"></rect><path d="M6 15h6" stroke="currentColor" stroke-width="1.3"></path></svg>
<svg width="15" height="15" viewBox="0 0 18 18" style="cursor:pointer;"><rect x="4" y="2.5" width="10" height="13" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.3"></rect></svg>
<svg width="15" height="15" viewBox="0 0 18 18" style="cursor:pointer;"><rect x="5.5" y="2" width="7" height="14" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.3"></rect><path d="M8 13.5h2" stroke="currentColor" stroke-width="1.3"></path></svg>
<span style="width:1px; height:14px; background:rgba(255,255,255,.1);"></span>
<svg width="14" height="14" viewBox="0 0 16 16" style="cursor:pointer;"><path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="1.4"></path></svg>
</div>
</div>
<div style="flex:none; display:flex; align-items:center; gap:7px; padding:9px 16px 0; font-family:'JetBrains Mono',monospace; font-size:10px; color:#5ec8d8;">
<span style="width:6px; height:6px; border-radius:50%; background:#5ec8d8; {{ blinkAnim }}"></span>sandbox live · no network
</div>
<div style="flex:1; overflow-y:auto; padding:16px;">
<div style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.12em; color:#5a5a62; margin-bottom:13px;">APPS</div>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:14px 6px; margin-bottom:22px;">
<div style="display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;"><div style="width:48px; height:48px; border-radius:13px; background:#fff; display:flex; align-items:center; justify-content:center; box-shadow:0 3px 10px rgba(0,0,0,.4);"><svg width="26" height="26" viewBox="0 0 26 26"><circle cx="13" cy="13" r="11" fill="#fff" stroke="#e0e0e0"></circle><circle cx="13" cy="13" r="4.4" fill="#4a90e2"></circle><path d="M13 8.6 H24" stroke="#ea4335" stroke-width="3.6"></path><path d="M9.2 11 L4 3.4" stroke="#34a853" stroke-width="3.6"></path><path d="M13 17.4 L7.5 22.5" stroke="#fbbc05" stroke-width="3.6"></path></svg></div><span style="font-size:10px; color:#b5b5bd;">Browser</span></div>
<div style="display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;"><div style="width:48px; height:48px; border-radius:13px; background:#fff; display:flex; align-items:center; justify-content:center; box-shadow:0 3px 10px rgba(0,0,0,.4);"><svg width="22" height="22" viewBox="0 0 22 22"><rect x="9" y="2" width="4" height="11" rx="2" fill="#36c5f0"></rect><rect x="9" y="13" width="4" height="7" rx="2" fill="#2eb67d"></rect><rect x="2" y="9" width="11" height="4" rx="2" fill="#ecb22e"></rect><rect x="9" y="9" width="11" height="4" rx="2" fill="#e01e5a"></rect></svg></div><span style="font-size:10px; color:#b5b5bd;">Slack</span></div>
<div style="display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;"><div style="width:48px; height:48px; border-radius:13px; background:#070708; border:1px solid rgba(255,255,255,.1); display:flex; align-items:center; justify-content:center;"><svg width="22" height="22" viewBox="0 0 22 22"><rect x="2" y="3" width="18" height="15" rx="2" fill="none" stroke="#5fd08a" stroke-width="1.4"></rect><path d="M5 8l3 2.5-3 2.5M10.5 14h5" stroke="#5fd08a" stroke-width="1.4" fill="none"></path></svg></div><span style="font-size:10px; color:#b5b5bd;">Terminal</span></div>
<div style="display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;"><div style="width:48px; height:48px; border-radius:13px; background:linear-gradient(135deg,#c98af0,#7a4ad8); display:flex; align-items:center; justify-content:center; box-shadow:0 3px 10px rgba(0,0,0,.4);"><svg width="22" height="22" viewBox="0 0 22 22"><path d="M11 2 L18 8 L13 20 L6 16 L4 8 Z" fill="#fff" opacity=".92"></path><path d="M11 2 L13 20 M4 8 L13 20" stroke="#c98af0" stroke-width="1" fill="none"></path></svg></div><span style="font-size:10px; color:#b5b5bd;">Obsidian</span></div>
<div style="display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;"><div style="width:48px; height:48px; border-radius:13px; background:#070708; border:1px solid rgba(255,255,255,.1); display:flex; align-items:center; justify-content:center;"><svg width="22" height="22" viewBox="0 0 22 22"><path d="M3 5a2 2 0 012-2h12a2 2 0 012 2v8a2 2 0 01-2 2H8l-4 4v-4H5a2 2 0 01-2-2z" fill="none" stroke="#ff6f61" stroke-width="1.4" stroke-linejoin="round"></path></svg></div><span style="font-size:10px; color:#b5b5bd;">Chat</span></div>
<div style="display:flex; flex-direction:column; align-items:center; gap:6px;"><div style="width:48px; height:48px; border-radius:13px; border:1.5px dashed rgba(255,255,255,.18); display:flex; align-items:center; justify-content:center; color:#ff6f61; font-size:22px; font-weight:300; cursor:pointer;">+</div><span style="font-size:10px; color:#8a8a92;">Add Apps</span></div>
</div>
<div style="font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.12em; color:#5a5a62; margin-bottom:11px;">NOW RUNNING</div>
<sc-for list="{{ running }}" as="r" hint-placeholder-count="2">
<div style="padding:10px 11px; border-radius:10px; background:#101014; border:1px solid rgba(255,255,255,.06); margin-bottom:8px;">
<div style="display:flex; align-items:center; gap:7px; margin-bottom:6px;">
<span style="width:6px; height:6px; border-radius:50%; background:{{ r.dot }}; {{ r.dotAnim }}"></span>
<span style="font-size:12px; font-weight:600; color:#e6e6ea;">{{ r.name }}</span>
<span style="flex:1;"></span>
<span style="font-family:'JetBrains Mono',monospace; font-size:9px; color:{{ r.metaColor }};">{{ r.meta }}</span>
</div>
<sc-if value="{{ r.hasBar }}" hint-placeholder-val="{{ false }}">
<div style="height:4px; border-radius:2px; background:rgba(255,255,255,.08); overflow:hidden;"><div style="width:{{ r.bar }}; height:100%; background:linear-gradient(90deg,#5ec8d8,#4aa3b8);"></div></div>
</sc-if>
</div>
</sc-for>
</div>
<div style="flex:none; margin:0 12px 14px; padding:12px; border-radius:14px; background:#121216; border:1px solid rgba(255,255,255,.07); display:flex; justify-content:space-around;">
<div style="display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;"><div style="width:40px; height:40px; border-radius:11px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); display:flex; align-items:center; justify-content:center;"><svg width="18" height="18" viewBox="0 0 18 18"><path d="M10 1 L3 10 H8 L7 17 L15 7 H9 Z" fill="#fff"></path></svg></div><span style="font-size:10px; color:#b5b5bd;">Skills</span></div>
<div style="display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;"><div style="width:40px; height:40px; border-radius:11px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); display:flex; align-items:center; justify-content:center;"><svg width="18" height="18" viewBox="0 0 18 18"><path d="M2 5a1.5 1.5 0 011.5-1.5H7l1.5 1.5h6A1.5 1.5 0 0116 6.5v7A1.5 1.5 0 0114.5 15h-11A1.5 1.5 0 012 13.5z" fill="#fff"></path></svg></div><span style="font-size:10px; color:#b5b5bd;">Files</span></div>
<div style="display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;"><div style="width:40px; height:40px; border-radius:11px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); display:flex; align-items:center; justify-content:center;"><svg width="18" height="18" viewBox="0 0 18 18"><path d="M9 2a5 5 0 00-5 5c0 4-1.5 5-1.5 5h13S14 11 14 7a5 5 0 00-5-5z" fill="#fff"></path><path d="M7.5 15a1.5 1.5 0 003 0" fill="#fff"></path></svg></div><span style="font-size:10px; color:#ff8a7a;">Routines</span></div>
<div style="display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;"><div style="width:40px; height:40px; border-radius:11px; background:linear-gradient(135deg,#ff8a7a,#ff5f57); display:flex; align-items:center; justify-content:center;"><svg width="18" height="18" viewBox="0 0 18 18"><circle cx="9" cy="9" r="2.6" fill="#fff"></circle><path d="M9 1.5v2M9 14.5v2M1.5 9h2M14.5 9h2M3.7 3.7l1.4 1.4M12.9 12.9l1.4 1.4M14.3 3.7l-1.4 1.4M5.1 12.9l-1.4 1.4" stroke="#fff" stroke-width="1.5"></path></svg></div><span style="font-size:10px; color:#b5b5bd;">Settings</span></div>
</div>
</div>
</sc-if>
</div>
<!-- STATUS BAR -->
<div style="height:28px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-top:1px solid rgba(255,255,255,.06); background:#0a0a0c; font-family:'JetBrains Mono',monospace; font-size:10px; color:#5a5a62;">
<span style="color:#5fd08a;">● durable runner ok</span>
<span>checkpoint 3s ago</span>
<span style="flex:1;"></span>
<span>§15 sandbox: isolated</span>
<span style="color:{{ doorsValColor }};">{{ doorsStatusText }}</span>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;liveStream&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;},&quot;showComputer&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;},&quot;defaultPromptOpen&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
state = { selected: 'morpheus', promptOpen: null, doorApproved: false };
renderVals() {
const s = this.state;
const live = this.props.liveStream ?? true;
const showComputer = this.props.showComputer ?? true;
const AGENTS = {
morpheus: {
name: 'Morpheus', role: 'Project Manager', spec: 'Rust 2024 Specialist',
initial: 'M', grad: 'linear-gradient(135deg,#ff8a7a,#ff5f57)', ink: '#2a0d0a',
model: 'Claude Sonnet 4.5', modelShort: 'claude-sonnet', status: 'working', statusColor: '#5ec8d8',
action: '▸ reviewing PR #214',
tok: '38.4k', spend: '$0.42', loops: 3, mem: '2,418', doors: 1,
commits: '9,105', hot: '11',
taskTitle: 'Review PR #214 — borrow-checker fix in', taskCode: 'cm-runtime', taskElapsed: 'task · 02:14 elapsed',
taskSteps: [
{ icon: '✓', iconColor: '#5fd08a', text: 'read cm-runtime/src/sandbox.rs', meta: '· 3 hunks' },
{ icon: '✓', iconColor: '#5fd08a', text: 'ran cargo clippy --workspace', meta: '· clean' },
{ icon: '▸', iconColor: '#5ec8d8', text: "checking lifetime on &'a mut Guard across await", meta: '', caret: true },
],
reasoningTime: '18:16:09',
reasoning: [
{ tag: 'think', tagColor: '#c98af0', text: 'The original holds the MutexGuard across .await — the std::sync::Mutex-across-await footgun my rules forbid.' },
{ tag: 'note', tagColor: '#9a9aa2', text: 'The fix clones under a scoped lock, dropping the guard before the await point. Sound.' },
{ tag: 'tool', tagColor: '#5ec8d8', text: 'github.review.comment → door check…' },
{ tag: 'door', tagColor: '#e8b465', text: 'post review on PR #214 needs approval', caret: true },
],
personality: ['Analytical', 'Concise', 'Skeptical', 'Direct'],
skillsCount: 7, skills: ['Web research', 'Summarize', 'Data viz', 'Code review'], skillsMore: '+3',
capabilities: ['File mgmt', 'Scheduling', 'Code exec'],
tools: [
{ name: 'Email', nameColor: '#cfcfd5', state: 'gated ✓', stateColor: '#5fd08a' },
{ name: 'Slack', nameColor: '#cfcfd5', state: 'gated ✓', stateColor: '#5fd08a' },
{ name: 'Browser', nameColor: '#cfcfd5', state: 'gated ✓', stateColor: '#5fd08a' },
{ name: 'Shell', nameColor: '#7a7a82', state: 'blocked ⨯', stateColor: '#e8b465' },
],
memory: [
{ k: 'Long-term', v: '2,418 notes' },
{ k: 'Recent context', v: '18 msgs' },
{ k: 'Pinned facts', v: '4' },
],
safety: [
{ k: 'Sandbox', v: 'isolated', color: '#6fd0c0' },
{ k: 'Network', v: 'none', color: '#6fd0c0' },
{ k: 'Egress', v: 'gated', color: '#e8b465' },
],
doorTitle: 'Post code review on PR #214',
doorDetail: 'github.review.comment → external egress · drafts 1 comment',
running: [
{ name: 'Q3 competitor scan', dot: '#5ec8d8', blink: true, meta: 'loop · step 14', metaColor: '#5ec8d8', bar: '62%' },
{ name: 'Daily digest', dot: '#e8b465', blink: false, meta: 'cron · 08:00', metaColor: '#e8b465', bar: '' },
],
promptText: 'You are Morpheus, a senior engineering project manager for the Growth Team.\n\nYou review code with a focus on correctness, concurrency safety, and Rust 2024 idioms. You are concise and direct — flag the issue, propose the fix, cite the rule.\n\nNever hold a std::sync::Mutex guard across an .await point. Prefer scoped locks. Always run cargo clippy before approving.',
},
smith: {
name: 'Smith', role: 'Research Specialist', spec: 'Source Intelligence',
initial: 'S', grad: 'linear-gradient(135deg,#6fd0c0,#4aa3b8)', ink: '#06201f',
model: 'Claude Sonnet 4.5', modelShort: 'claude-sonnet', status: 'running', statusColor: '#5fd08a',
action: '▸ crawling 12 sources',
tok: '22.1k', spend: '$0.28', loops: 2, mem: '1,290', doors: 0,
commits: '4,602', hot: '6',
taskTitle: 'Crate audit — scan dependency tree in', taskCode: 'cargo audit', taskElapsed: 'task · 01:02 elapsed',
taskSteps: [
{ icon: '✓', iconColor: '#5fd08a', text: 'fetched advisory-db', meta: '· 184 crates' },
{ icon: '✓', iconColor: '#5fd08a', text: 'ran cargo audit --json', meta: '· 2 advisories' },
{ icon: '▸', iconColor: '#5ec8d8', text: 'summarizing RUSTSEC-2025-00xx (tokio)', meta: '', caret: true },
],
reasoningTime: '18:16:21',
reasoning: [
{ tag: 'search', tagColor: '#5ec8d8', text: 'crawling 12 sources for tokio CVE context and patched versions.' },
{ tag: 'note', tagColor: '#9a9aa2', text: 'Cross-referencing RUSTSEC + GHSA advisories; deduping by CVE id.' },
{ tag: 'tool', tagColor: '#5ec8d8', text: 'cargo.audit → running, no door required (read-only).' },
{ tag: 'result', tagColor: '#5fd08a', text: '2 advisories found · drafting summary for Morpheus', caret: true },
],
personality: ['Thorough', 'Curious', 'Literal'],
skillsCount: 6, skills: ['Web crawl', 'Summarize', 'Citations', 'Dedup'], skillsMore: '+2',
capabilities: ['Web fetch', 'Indexing', 'Export'],
tools: [
{ name: 'Browser', nameColor: '#cfcfd5', state: 'gated ✓', stateColor: '#5fd08a' },
{ name: 'Slack', nameColor: '#cfcfd5', state: 'gated ✓', stateColor: '#5fd08a' },
{ name: 'Email', nameColor: '#7a7a82', state: 'blocked ⨯', stateColor: '#e8b465' },
{ name: 'Shell', nameColor: '#7a7a82', state: 'blocked ⨯', stateColor: '#e8b465' },
],
memory: [
{ k: 'Long-term', v: '1,290 notes' },
{ k: 'Recent context', v: '11 msgs' },
{ k: 'Pinned facts', v: '2' },
],
safety: [
{ k: 'Sandbox', v: 'isolated', color: '#6fd0c0' },
{ k: 'Network', v: 'read-only', color: '#e8b465' },
{ k: 'Egress', v: 'none', color: '#6fd0c0' },
],
doorTitle: '', doorDetail: '',
running: [
{ name: 'Dependency watch', dot: '#5ec8d8', blink: true, meta: 'loop · step 7', metaColor: '#5ec8d8', bar: '48%' },
{ name: 'Nightly digest', dot: '#5fd08a', blink: false, meta: 'done · 02:00', metaColor: '#5fd08a', bar: '' },
],
promptText: 'You are Smith, a research specialist for the Growth Team.\n\nYou crawl, read, and synthesize sources into tightly-cited summaries. You never assert a fact without a source. You dedupe aggressively and prefer primary advisories (RUSTSEC, GHSA, NVD) over secondary reporting.\n\nYou are read-only by default — surface findings, do not act on them.',
},
};
const a = AGENTS[s.selected] || AGENTS.morpheus;
const order = ['morpheus', 'smith'];
const agents = order.map(id => {
const ag = AGENTS[id];
const sel = id === s.selected;
return {
id, name: ag.name, role: ag.role, initial: ag.initial, grad: ag.grad, ink: ag.ink,
action: ag.action, statusColor: ag.statusColor,
selected: sel,
rowBg: sel ? 'rgba(255,111,97,.1)' : 'transparent',
rowBorder: sel ? 'rgba(255,111,97,.25)' : 'transparent',
labelColor: sel ? '#ffffff' : '#eaeaee',
roleColor: sel ? '#ff8a7a' : '#6a6a72',
onSelect: () => this.setState({ selected: id, doorApproved: false }),
};
});
const defaultOpen = !!(this.props.defaultPromptOpen ?? false);
const promptOpen = s.promptOpen ?? defaultOpen;
const doorsLive = (a.doors > 0 && !s.doorApproved) ? a.doors : 0;
const showDoor = a.doors > 0 && !s.doorApproved;
const doorClear = a.doors === 0 || s.doorApproved;
const anim = (css) => live ? css : '';
return {
selName: a.name, selRole: a.role, selSpec: a.spec, selInitial: a.initial,
selGrad: a.grad, selInk: a.ink, selModel: a.model, selModelShort: a.modelShort,
selStatusLabel: a.status, selTok: a.tok, selSpend: a.spend, selLoops: a.loops, selMem: a.mem,
selCommits: a.commits, selHot: a.hot,
agents,
taskTitle: a.taskTitle, taskCode: a.taskCode, taskElapsed: a.taskElapsed, taskSteps: a.taskSteps,
reasoning: a.reasoning, reasoningTime: a.reasoningTime,
personality: a.personality,
skills: a.skills, skillsCount: a.skillsCount, skillsMore: a.skillsMore,
capabilities: a.capabilities,
tools: a.tools, memory: a.memory, safety: a.safety,
promptOpen, promptText: a.promptText,
promptChars: a.promptText.length.toLocaleString(),
promptChevron: promptOpen ? '▾' : '▸',
togglePrompt: () => this.setState({ promptOpen: !promptOpen }),
showDoor, doorClear, doorTitle: a.doorTitle, doorDetail: a.doorDetail,
approveDoor: () => this.setState({ doorApproved: true }),
doorsLive,
doorsBorder: doorsLive > 0 ? 'rgba(232,196,106,.3)' : 'rgba(255,255,255,.06)',
doorsLabelColor: doorsLive > 0 ? '#e8b465' : '#6a6a72',
doorsValColor: doorsLive > 0 ? '#e8b465' : '#5fd08a',
doorsStatusText: doorsLive > 0 ? (doorsLive + ' door awaiting approval') : 'no doors pending',
showComputer,
running: a.running.map(r => ({
name: r.name, meta: r.meta, metaColor: r.metaColor, dot: r.dot,
dotAnim: (live && r.blink) ? 'animation:cm-blink 1.4s infinite;' : '',
hasBar: !!r.bar, bar: r.bar,
})),
blinkAnim: anim('animation:cm-blink 1.4s infinite;'),
haloAnim: anim('animation:cm-halo 2s ease-out infinite;'),
caretAnim: anim('animation:cm-type 1s steps(1) infinite;'),
barAnim: anim('animation:cm-bar 1.6s ease-in-out infinite;'),
barAnim2: anim('animation:cm-bar 1.9s ease-in-out infinite;'),
};
}
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+86
View File
@@ -122,6 +122,76 @@ async fn doors_pending(pool: &PgPool, ws: WorkspaceId) -> i64 {
.unwrap_or(0) .unwrap_or(0)
} }
/// Live per-agent metrics for the agent command center's metric band.
#[derive(Default)]
struct AgentTele {
tokens_per_min: i64,
cost_per_hr: f64,
loops: i64,
doors: i64,
}
/// Per-agent telemetry for every agent in the workspace, in 4 grouped queries
/// (not N×4): tokens over the last minute, credits over the last hour, active
/// routines, and pending approvals — all keyed by agent id.
async fn agent_telemetry(pool: &PgPool, ws: WorkspaceId) -> std::collections::HashMap<String, AgentTele> {
let mut m: std::collections::HashMap<String, AgentTele> = std::collections::HashMap::new();
let id_of = |r: &sqlx::postgres::PgRow| r.get::<uuid::Uuid, _>("agent_id").to_string();
// tokens in the last minute ≈ tokens/min.
for r in sqlx::query(
"SELECT agent_id, SUM(tokens_in + tokens_out)::bigint AS v FROM usage_events
WHERE workspace_id = $1 AND agent_id IS NOT NULL AND created_at > now() - interval '1 minute'
GROUP BY agent_id",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default()
{
m.entry(id_of(&r)).or_default().tokens_per_min = r.get("v");
}
// credits spent in the last hour ≈ spend/hr.
for r in sqlx::query(
"SELECT agent_id, SUM(credits)::float8 AS v FROM usage_events
WHERE workspace_id = $1 AND agent_id IS NOT NULL AND created_at > now() - interval '1 hour'
GROUP BY agent_id",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default()
{
m.entry(id_of(&r)).or_default().cost_per_hr = r.get("v");
}
// active routines = loops.
for r in sqlx::query(
"SELECT r.agent_id AS agent_id, count(*)::bigint AS v FROM routines r
JOIN agents a ON a.id = r.agent_id
WHERE a.workspace_id = $1 AND r.status = 'active' GROUP BY r.agent_id",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default()
{
m.entry(id_of(&r)).or_default().loops = r.get("v");
}
// pending approvals = doors.
for r in sqlx::query(
"SELECT requested_by_agent AS agent_id, count(*)::bigint AS v FROM approvals
WHERE workspace_id = $1 AND status = 'pending' GROUP BY requested_by_agent",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default()
{
m.entry(id_of(&r)).or_default().doors = r.get("v");
}
m
}
/// `GET /api/world/live` — the taxonomy SSE feed. /// `GET /api/world/live` — the taxonomy SSE feed.
pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) -> impl IntoResponse { pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) -> impl IntoResponse {
let pool = state.pool.clone(); let pool = state.pool.clone();
@@ -212,6 +282,22 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
} }
} }
// Per-agent telemetry → the command-center metric band (real data:
// usage_events tokens/credits, active routines, pending approvals).
let tele = agent_telemetry(&pool, ws).await;
for a in &roster {
let id = a.id.to_string();
let t = tele.get(&id);
yield sse("telemetry", json!({
"agentId": id,
"tokensPerMin": t.map(|x| x.tokens_per_min).unwrap_or(0),
"costPerHr": t.map(|x| x.cost_per_hr).unwrap_or(0.0),
"loops": t.map(|x| x.loops).unwrap_or(0),
"doorsPending": t.map(|x| x.doors).unwrap_or(0),
}));
}
// Workspace-wide telemetry (top-bar pills / Observe system strip).
yield sse( yield sse(
"telemetry", "telemetry",
json!({ "doorsPending": doors_pending(&pool, ws).await, "loops": runs.len() }), json!({ "doorsPending": doors_pending(&pool, ws).await, "loops": runs.len() }),
@@ -0,0 +1,286 @@
"use client";
// The Agent Command Center — the close-up operator surface for one agent.
// Identity shrinks to a header strip; live state + controls sit above the fold:
// a 5-tile metric band over three independently-scrolling columns (LIVE · BRAIN ·
// SURFACE). Read on the left/center, act on the right (the computer pullout).
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Activity, Brain, Camera, Cpu, Database, Drama, Settings, ShieldCheck, Wrench, Zap } from "lucide-react";
import type { DemoAgent } from "@/lib/dashboard-demo";
import { useAgentTelemetry, useLiveEvent } from "@/lib/live/useClawmatesLive";
import { AnatomyCard, PersonalityBody, SystemPromptCard, mono, tag, type RawBrain } from "./anatomy-cards";
import { AvatarModal } from "./AvatarModal";
import { AddToolModal } from "./AddToolModal";
const fmt = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : `${n}`);
// ── Metric band ──────────────────────────────────────────────────────────────
function Sparkline({ values, color }: { values: number[]; color: string }) {
const max = Math.max(1, ...values);
const pts = values.map((v, i) => `${(i / Math.max(1, values.length - 1)) * 100},${26 - (v / max) * 24}`).join(" ");
return (
<svg width="50" height="26" viewBox="0 0 100 26" preserveAspectRatio="none" style={{ marginLeft: "auto", opacity: 0.85 }} aria-hidden>
<polyline points={pts} fill="none" stroke={color} strokeWidth="2" vectorEffect="non-scaling-stroke" />
</svg>
);
}
function MetricTile({ label, value, unit, tint, spark }: { label: string; value: string; unit?: string; tint?: string; spark?: number[] }) {
return (
<div style={{ flex: 1, minWidth: 0, borderRadius: 12, background: "#0d0d10", border: `1px solid ${tint ? `${tint}45` : "rgba(255,255,255,.07)"}`, padding: "10px 13px" }}>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#5a5a62", marginBottom: 5 }}>{label}</div>
<div style={{ display: "flex", alignItems: "baseline", gap: 5 }}>
<span style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, color: tint ?? "#f3f3f5" }}>{value}</span>
{unit ? <span style={{ fontFamily: mono, fontSize: 10, color: "#6a6a72" }}>{unit}</span> : null}
{spark && spark.length > 1 ? <Sparkline values={spark} color={tint ?? "#5ec8d8"} /> : null}
</div>
</div>
);
}
// ── LIVE column ──────────────────────────────────────────────────────────────
type Step = { label: string; state: "done" | "active" | "pending" };
function WorkingOnNow({ agentId }: { agentId: string }) {
const [task, setTask] = useState<{ title: string; steps: Step[] } | null>(null);
useLiveEvent("agent.task.update", (d) => {
if (d.agentId === agentId) setTask({ title: d.title, steps: d.steps ?? [] });
});
const stepColor = (s: Step["state"]) => (s === "done" ? "#5fd08a" : s === "active" ? "#5ec8d8" : "#5a5a62");
return (
<AnatomyCard tint="#5ec8d8" label="WORKING ON NOW" icon={<Activity size={15} />}>
{!task ? (
<span style={{ fontFamily: mono, fontSize: 12.5, color: "#6a6a72" }}>idle — no active task. Live work streams here as the agent runs.</span>
) : (
<div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "#e6e6ea", marginBottom: task.steps.length ? 9 : 0 }}>{task.title}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 5, fontFamily: mono, fontSize: 12 }}>
{task.steps.map((s, i) => (
<div key={i} style={{ display: "flex", gap: 7, color: stepColor(s.state) }}>
<span>{s.state === "done" ? "✓" : s.state === "active" ? "▸" : "·"}</span>
<span style={{ color: s.state === "pending" ? "#6a6a72" : "#cfcfd5" }}>
{s.label}
{s.state === "active" ? <span className="cm-blink" style={{ color: "#5ec8d8" }}> ▋</span> : null}
</span>
</div>
))}
</div>
</div>
)}
</AnatomyCard>
);
}
type Think = { id: number; text: string; channel: "think" | "say" | "tool" };
const CHAN_COLOR: Record<Think["channel"], string> = { think: "#c98af0", say: "#cfcfd5", tool: "#5ec8d8" };
function ReasoningStream({ agentId }: { agentId: string }) {
const buf = useRef<Think[]>([]);
const seq = useRef(0);
const [lines, setLines] = useState<Think[]>([]);
useLiveEvent("agent.reasoning.delta", (d) => {
if (d.agentId !== agentId) return;
const t = d.text.trim();
if (t) buf.current = [...buf.current, { id: seq.current++, text: t, channel: d.channel ?? "think" }].slice(-80);
});
useLiveEvent("agent.tool.call", (d) => {
if (d.agentId !== agentId) return;
buf.current = [...buf.current, { id: seq.current++, text: `→ ${d.tool}${d.target ? ` ${d.target}` : ""}`, channel: "tool" as const }].slice(-80);
});
useEffect(() => {
const id = setInterval(() => setLines(buf.current.slice()), 300);
return () => clearInterval(id);
}, []);
return (
<AnatomyCard tint="#c98af0" label="REASONING STREAM" icon={<Brain size={15} />}>
<div style={{ background: "#08080a", borderRadius: 8, border: "1px solid rgba(255,255,255,.05)", padding: "10px 12px", maxHeight: 320, overflowY: "auto", fontFamily: mono, fontSize: 11.5, lineHeight: 1.55 }}>
{lines.length === 0 ? (
<span style={{ color: "#3a3a40" }}>no reasoning yet — runs stream here.</span>
) : (
lines.map((l, i) => (
<div key={l.id} style={{ display: "flex", gap: 7 }}>
<span style={{ flex: "none", width: 34, color: CHAN_COLOR[l.channel], opacity: 0.8 }}>{l.channel}</span>
<span style={{ color: "#cfcfd5", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{l.text}
{i === lines.length - 1 ? <span className="cm-blink" style={{ color: "#c98af0" }}> ▋</span> : null}
</span>
</div>
))
)}
</div>
</AnatomyCard>
);
}
function ActivityBars({ agentId }: { agentId: string }) {
// Bucket tool-call counts into ~20 rolling time slots (one tick/sec).
const SLOTS = 22;
const buckets = useRef<number[]>(new Array(SLOTS).fill(0));
const [bars, setBars] = useState<number[]>(() => new Array(SLOTS).fill(0));
useLiveEvent("agent.tool.call", (d) => {
if (d.agentId === agentId) buckets.current[SLOTS - 1] += 1;
});
useEffect(() => {
const id = setInterval(() => {
buckets.current = [...buckets.current.slice(1), 0];
setBars(buckets.current.slice());
}, 1000);
return () => clearInterval(id);
}, []);
const max = Math.max(1, ...bars);
return (
<AnatomyCard tint="#5ec8d8" label="ACTIVITY · LIVE" icon={<Activity size={15} />}>
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 64 }}>
{bars.map((b, i) => (
<div key={i} style={{ flex: 1, height: `${Math.max(3, (b / max) * 100)}%`, borderRadius: 2, background: b > 0 ? "linear-gradient(180deg,#5ec8d8,#3a8694)" : "rgba(255,255,255,.06)", transition: "height .3s ease" }} />
))}
</div>
<div style={{ fontFamily: mono, fontSize: 9.5, color: "#5a5a62", marginTop: 6 }}>tool calls · last {SLOTS}s</div>
</AnatomyCard>
);
}
// Throughput tile with a rolling sparkline — keyed by agent so it resets on switch.
function ThroughputTile({ value }: { value: number }) {
const buf = useRef<number[]>([]);
const [spark, setSpark] = useState<number[]>([]);
useEffect(() => {
buf.current = [...buf.current, value].slice(-24);
}, [value]);
useEffect(() => {
const id = setInterval(() => setSpark(buf.current.slice()), 1000);
return () => clearInterval(id);
}, []);
return <MetricTile label="THROUGHPUT" value={fmt(value)} unit="tok/min" tint="#5ec8d8" spark={spark} />;
}
// ── Command center ───────────────────────────────────────────────────────────
export function ClawCommandCenter({
agent,
teamName,
avatarUrl,
onToolsChanged,
brain,
onEditBrain,
}: {
agent: DemoAgent;
teamName: string;
avatarUrl?: string;
onToolsChanged?: () => void;
brain?: RawBrain;
onEditBrain?: () => void;
}) {
const c = agent.compartments;
const router = useRouter();
const [imgUrl, setImgUrl] = useState<string | null>(null);
const [seenImgId, setSeenImgId] = useState(agent.id);
if (seenImgId !== agent.id) { setSeenImgId(agent.id); setImgUrl(null); }
const [avatarOpen, setAvatarOpen] = useState(false);
const [addToolOpen, setAddToolOpen] = useState(false);
const shownAvatar = imgUrl ?? avatarUrl ?? null;
const tele = useAgentTelemetry(agent.id);
const doors = tele?.doorsPending ?? 0;
const skillsShown = c.skills.slice(0, 6);
const skillsRest = c.skills.length - skillsShown.length;
const row = (a: string, b: string, bc = "#cfcfd5") => (
<div style={{ display: "flex" }}><span style={{ flex: 1 }}>{a}</span><span style={{ color: bc }}>{b}</span></div>
);
const colStyle: React.CSSProperties = { flex: "1 1 0", minWidth: 0, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", gap: 14, paddingBottom: 8 };
return (
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", background: "#08080a" }}>
{/* Identity header strip — compact. */}
<div style={{ flex: "none", display: "flex", alignItems: "center", gap: 15, padding: "15px 22px", borderBottom: "1px solid rgba(255,255,255,.06)" }}>
<div style={{ position: "relative", width: 62, height: 62, flex: "none" }}>
<div style={{ position: "absolute", inset: -5, borderRadius: "50%", border: "1.5px solid rgba(255,111,97,.35)" }} />
<div style={{ position: "absolute", inset: 0, borderRadius: "50%", background: "rgba(255,111,97,.3)", animation: "cm-halo 2s ease-out infinite" }} />
<button type="button" onClick={() => setAvatarOpen(true)} title="Click to edit image" style={{ position: "relative", width: 62, height: 62, borderRadius: "50%", border: 0, padding: 0, cursor: "pointer", background: shownAvatar ? `center/cover no-repeat url(${shownAvatar})` : agent.grad, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24, fontWeight: 700, color: agent.ink, boxShadow: "0 0 32px rgba(255,111,97,.45)" }}>{shownAvatar ? null : agent.initial}</button>
<span aria-hidden style={{ position: "absolute", right: -1, bottom: -1, width: 22, height: 22, borderRadius: "50%", background: "#15151a", border: "1.5px solid #08080a", display: "flex", alignItems: "center", justifyContent: "center", color: "#cfcfd5", pointerEvents: "none" }}><Camera size={11} /></span>
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 20, fontWeight: 800, color: "#f3f3f5", letterSpacing: "-.02em" }}>{agent.name}</div>
<div style={{ fontFamily: mono, fontSize: 11.5, letterSpacing: ".06em", color: "#ff8a7a", marginTop: 2 }}>{agent.role}<span style={{ color: "#5a5a62" }}> · part of {teamName}</span></div>
</div>
<span style={{ flex: 1 }} />
<span style={{ fontFamily: mono, fontSize: 11, color: "#9a9aa2", padding: "5px 11px", borderRadius: 999, background: "rgba(255,255,255,.04)", border: "1px solid rgba(255,255,255,.1)" }}>{agent.model || "model —"}</span>
<button type="button" onClick={onEditBrain} style={{ display: "inline-flex", alignItems: "center", gap: 6, fontFamily: mono, fontSize: 11.5, color: "#ff8a7a", padding: "6px 13px", borderRadius: 8, background: "rgba(255,111,97,.08)", border: "1px solid rgba(255,111,97,.4)", cursor: "pointer" }}>Edit brain →</button>
<button type="button" aria-label="Settings" style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: 8, color: "#9a9aa2", background: "transparent", border: "1px solid rgba(255,255,255,.1)", cursor: "pointer" }}><Settings size={16} /></button>
</div>
{/* Metric band — per-agent. */}
<div style={{ flex: "none", display: "flex", gap: 10, padding: "12px 22px" }}>
<ThroughputTile key={agent.id} value={tele?.tokensPerMin ?? 0} />
<MetricTile label="SPEND" value={(tele?.costPerHr ?? 0).toFixed(2)} unit="cr/hr" tint="#e8b465" />
<MetricTile label="LOOPS" value={`${tele?.loops ?? 0}`} unit="active" tint="#5fd08a" />
<MetricTile label="MEMORY" value={fmt(brain?.stats.memories ?? 0)} unit="notes" />
<MetricTile label="DOORS" value={`${doors}`} unit={doors > 0 ? "pending" : "clear"} tint={doors > 0 ? "#e8b465" : "#5fd08a"} />
</div>
{/* Three independently-scrolling columns. */}
<div style={{ flex: "1 1 0", minHeight: 0, display: "flex", gap: 14, padding: "4px 22px 18px" }}>
{/* LIVE */}
<div style={{ ...colStyle, flex: "1.15 1 0" }}>
<WorkingOnNow key={`won-${agent.id}`} agentId={agent.id} />
<ReasoningStream key={`rs-${agent.id}`} agentId={agent.id} />
<ActivityBars key={`act-${agent.id}`} agentId={agent.id} />
</div>
{/* BRAIN */}
<div style={colStyle}>
<SystemPromptCard prompt={agent.systemPrompt} />
<AnatomyCard tint="#c98af0" label="PERSONALITY" icon={<Drama size={15} />} collapsible>
<PersonalityBody raw={brain?.personality} fallback={c.personality} />
</AnatomyCard>
<AnatomyCard tint="#ff6f61" label="SKILLS" count={String(c.skills.length)} icon={<Zap size={15} />}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
{skillsShown.map((s) => (<span key={s} style={tag("#ff6f61")}>{s}</span>))}
{skillsRest > 0 ? <span style={tag("#9a9aa2", true)}>+{skillsRest}</span> : null}
</div>
</AnatomyCard>
<AnatomyCard tint="#e8b465" label="CAPABILITIES" icon={<Cpu size={15} />}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>{c.capabilities.map((p) => (<span key={p} style={tag("#e8b465")}>{p}</span>))}</div>
</AnatomyCard>
</div>
{/* SURFACE */}
<div style={colStyle}>
<AnatomyCard tint="#5ec8d8" label="TOOLS · DOORS" icon={<Wrench size={15} />}>
{doors > 0 ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 9, padding: "8px 11px", borderRadius: 8, background: "rgba(232,180,101,.08)", border: "1px solid rgba(232,180,101,.4)", fontFamily: mono, fontSize: 11.5, color: "#e8b465" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#e8b465" }} className="cm-blink" />
§15 · {doors} door{doors === 1 ? "" : "s"} awaiting approval
</div>
) : null}
<div style={{ display: "flex", flexDirection: "column", gap: 6, fontFamily: mono, fontSize: 12.5 }}>
{c.tools.map((t) => row(t.name, t.state === "gated" ? "gated ✓" : "blocked ⨯", t.state === "gated" ? "#5fd08a" : "#e8b465"))}
</div>
<button type="button" onClick={() => setAddToolOpen(true)} style={{ marginTop: 10, width: "100%", padding: "8px 0", borderRadius: 7, border: "1px dashed rgba(94,200,216,.4)", background: "transparent", color: "#5ec8d8", fontSize: 12.5, fontFamily: mono, cursor: "pointer" }}>+ Add tool</button>
</AnatomyCard>
<AnatomyCard tint="#5fd08a" label="MEMORY" count={brain ? String(brain.stats.memories) : undefined} icon={<Database size={15} />}>
{brain && brain.memory.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 5, fontFamily: mono, fontSize: 12.5, color: "#9a9aa2", maxHeight: 200, overflowY: "auto" }}>
{brain.memory.slice(0, 8).map((m, i) => (<div key={i} title={m} style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m}</div>))}
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6, fontFamily: mono, fontSize: 12.5, color: "#9a9aa2" }}>{brain ? <span style={{ color: "#6a6a72" }}>no memories yet — chat with this agent and they appear here.</span> : (<>{row("Long-term", c.memory.long)}{row("Recent ctx", c.memory.recent)}</>)}</div>
)}
</AnatomyCard>
<AnatomyCard tint="#6fd0c0" label="SAFETY · §15" icon={<ShieldCheck size={15} />}>
<div style={{ display: "flex", flexDirection: "column", gap: 6, fontFamily: mono, fontSize: 12.5, color: "#9a9aa2" }}>{row("Sandbox", c.safety.sandbox, "#6fd0c0")}{row("Network", c.safety.network, "#6fd0c0")}</div>
</AnatomyCard>
</div>
</div>
{avatarOpen ? (
<AvatarModal clawId={agent.id} clawName={agent.name} current={shownAvatar} onClose={() => setAvatarOpen(false)} onSaved={(url) => { setImgUrl(url); router.refresh(); }} />
) : null}
{addToolOpen ? (
<AddToolModal clawId={agent.id} clawName={agent.name} onClose={() => setAddToolOpen(false)} onAdded={() => onToolsChanged?.()} />
) : null}
</div>
);
}
+9 -258
View File
@@ -13,7 +13,7 @@ import { useEffect, useRef, useState, type CSSProperties } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import { Activity, Bot, Brain, Camera, Cpu, Database, Drama, History, MessageSquare, Monitor, PanelRight, ScrollText, ShieldCheck, Trash2, Users, Wrench, X, Zap } from "lucide-react"; import { Bot, Brain, History, MessageSquare, Monitor, PanelRight, Trash2, Users, Wrench, X } from "lucide-react";
import type { DemoAgent, DemoCompany, DemoOrg, DemoTeam } from "@/lib/dashboard-demo"; import type { DemoAgent, DemoCompany, DemoOrg, DemoTeam } from "@/lib/dashboard-demo";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
@@ -29,11 +29,7 @@ import { InfraNav, FleetConsole, FleetStatusBar, FleetPill } from "./fleet/Fleet
import { NodeMonitor } from "./fleet/NodeMonitor"; import { NodeMonitor } from "./fleet/NodeMonitor";
import { ConnectHostWizard } from "./ConnectHostWizard"; import { ConnectHostWizard } from "./ConnectHostWizard";
import { INFRA_CATALOG } from "@/components/computer/catalogs/infra"; import { INFRA_CATALOG } from "@/components/computer/catalogs/infra";
import { ClawChatSection } from "./ClawChatSection"; import { ClawCommandCenter } from "./ClawCommandCenter";
import { ResizableSplit } from "./ResizableSplit";
import { VitalsCard } from "./VitalsCard";
import { AvatarModal } from "./AvatarModal";
import { AddToolModal } from "./AddToolModal";
import { MasterPlannerModal } from "./MasterPlannerModal"; import { MasterPlannerModal } from "./MasterPlannerModal";
import { BrainRegistryPanel } from "./BrainRegistryPanel"; import { BrainRegistryPanel } from "./BrainRegistryPanel";
import { BrainHistoryModal } from "./BrainHistoryModal"; import { BrainHistoryModal } from "./BrainHistoryModal";
@@ -718,22 +714,11 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
<BrainRegistryPanel clawId={clawAgent.id} clawName={clawAgent.name} onApplied={() => setEnrichBump((b) => b + 1)} onClose={() => setRegistryOpen(false)} /> <BrainRegistryPanel clawId={clawAgent.id} clawName={clawAgent.name} onApplied={() => setEnrichBump((b) => b + 1)} onClose={() => setRegistryOpen(false)} />
</div> </div>
) : null} ) : null}
{/* Left region: anatomy profile + native chat, condensed by the {/* Left region: the full-height agent command center (identity strip →
computer's width (and the registry rail). The two are a resizable metrics band → LIVE/BRAIN/SURFACE columns). Chat lives in the computer's
split — drag the divider to grow the chat. */} Chat app now. Condensed by the computer's width + the registry rail. */}
<div style={{ position: "absolute", top: 0, bottom: 0, left: registryOpen && device !== "full" ? 510 : 0, right: "var(--computer-width)", transition: "left var(--duration-normal) var(--ease-app), right var(--duration-normal) var(--ease-app)" }}> <div style={{ position: "absolute", top: 0, bottom: 0, left: registryOpen && device !== "full" ? 510 : 0, right: "var(--computer-width)", transition: "left var(--duration-normal) var(--ease-app), right var(--duration-normal) var(--ease-app)" }}>
{chatMin ? ( <ClawCommandCenter agent={richAgent} brain={richBrain} teamName={team.name} avatarUrl={clawAgent.avatar || undefined} onToolsChanged={() => setEnrichBump((b) => b + 1)} onEditBrain={() => setRegistryOpen(true)} />
// Chat minimized → anatomy takes the whole left region.
<ClawAnatomyCanvas agent={richAgent} brain={richBrain} teamName={team.name} avatarUrl={clawAgent.avatar || undefined} onToolsChanged={() => setEnrichBump((b) => b + 1)} />
) : (
<ResizableSplit
defaultRatio={0.55}
minTop={44}
minBottom={180}
top={<ClawAnatomyCanvas agent={richAgent} brain={richBrain} teamName={team.name} avatarUrl={clawAgent.avatar || undefined} onToolsChanged={() => setEnrichBump((b) => b + 1)} />}
bottom={<ClawChatSection agent={clawAgent} onOpenComputerApp={(a) => setParams({ app: a })} onMinimize={() => setChatMin(true)} />}
/>
)}
</div> </div>
{/* Right: the original device computer (phone / tablet / desktop). */} {/* Right: the original device computer (phone / tablet / desktop). */}
<DevicePanel agent={clawAgent} /> <DevicePanel agent={clawAgent} />
@@ -758,12 +743,10 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
<div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)", width: 4, height: 46, borderRadius: 3, background: resizing ? "#ff8a7a" : "rgba(255,255,255,.22)" }} /> <div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)", width: 4, height: 46, borderRadius: 3, background: resizing ? "#ff8a7a" : "rgba(255,255,255,.22)" }} />
</div> </div>
) : null} ) : null}
{/* Top-right launchers: chat (left) + computer (right), each a coral {/* Top-right launchers: chat shortcut (opens the computer's Chat app) +
icon when collapsed; controls show when the computer is open. */} computer; controls show when the computer is open. */}
<div style={{ position: "absolute", top: 14, right: 16, zIndex: 50, display: "flex", alignItems: "center", gap: 8 }}> <div style={{ position: "absolute", top: 14, right: 16, zIndex: 50, display: "flex", alignItems: "center", gap: 8 }}>
{chatMin ? ( <button type="button" aria-label="Open chat" title="Chat" onClick={() => setParams({ app: "chat", device: device === "full" || device === "tablet" ? device : "phone" })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer" }}><MessageSquare aria-hidden size={19} /></button>
<button type="button" aria-label="Open chat" title="Chat" onClick={() => setChatMin(false)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer" }}><MessageSquare aria-hidden size={19} /></button>
) : null}
{computerOpen ? ( {computerOpen ? (
<> <>
<DeviceSizeToggle value={device} onChange={(d) => { setParams({ device: d }); setCustomWidth(null); }} /> <DeviceSizeToggle value={device} onChange={(d) => { setParams({ device: d }); setCustomWidth(null); }} />
@@ -833,235 +816,3 @@ function EmptyStage({ label }: { label: string }) {
); );
} }
// ── Deploy wizard modal ─────────────────────────────────────────────────────
// ── Claw tier: the full-profile anatomy stage (sidebar is the all-claws tree) ──
// Inline markdown: **bold** and `code` → styled spans (everything else verbatim).
function renderInline(text: string, kp: string): React.ReactNode[] {
return text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).filter(Boolean).map((p, i) => {
if (p.startsWith("**") && p.endsWith("**")) return <strong key={`${kp}-${i}`} style={{ color: "#fff", fontWeight: 700 }}>{p.slice(2, -2)}</strong>;
if (p.startsWith("`") && p.endsWith("`")) return <code key={`${kp}-${i}`} style={{ fontFamily: mono, fontSize: "0.9em", background: "rgba(255,255,255,.06)", padding: "1px 5px", borderRadius: 4, color: "#e6e6ea" }}>{p.slice(1, -1)}</code>;
return <span key={`${kp}-${i}`}>{p}</span>;
});
}
// A tiny, dependency-free Markdown renderer: headings (#/##/###), bullet lists
// (- / *), bold/code, and paragraphs. Turns the raw prompt into readable prose
// instead of showing literal `##`.
function MarkdownText({ text, baseSize = 14 }: { text: string; baseSize?: number }) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const blocks: React.ReactNode[] = [];
let para: string[] = [];
let list: string[] = [];
const flushPara = () => {
if (para.length) { const k = `p${blocks.length}`; blocks.push(<p key={k} style={{ margin: "0 0 10px", fontSize: baseSize, lineHeight: 1.65, color: "#d6d6dc" }}>{renderInline(para.join(" "), k)}</p>); para = []; }
};
const flushList = () => {
if (list.length) { const k = `u${blocks.length}`; blocks.push(<ul key={k} style={{ margin: "0 0 10px", paddingLeft: 18, display: "flex", flexDirection: "column", gap: 5 }}>{list.map((li, i) => <li key={i} style={{ fontSize: baseSize, lineHeight: 1.55, color: "#d6d6dc" }}>{renderInline(li, `${k}-${i}`)}</li>)}</ul>); list = []; }
};
for (const raw of lines) {
const line = raw.replace(/\s+$/, "");
const h = line.match(/^(#{1,6})\s+(.*)$/);
const li = line.match(/^\s*[-*]\s+(.*)$/);
if (h) { flushPara(); flushList(); const lvl = h[1].length; const size = lvl <= 1 ? baseSize + 5 : lvl === 2 ? baseSize + 2 : baseSize + 1; const k = `h${blocks.length}`; blocks.push(<div key={k} style={{ fontSize: size, fontWeight: 700, color: "#fff", letterSpacing: "-.01em", margin: blocks.length ? "15px 0 7px" : "0 0 7px" }}>{renderInline(h[2], k)}</div>); }
else if (li) { flushPara(); list.push(li[1]); }
else if (line.trim() === "") { flushPara(); flushList(); }
else { flushList(); para.push(line.replace(/^\s*>\s?/, "")); }
}
flushPara(); flushList();
return <div>{blocks}</div>;
}
function SystemPromptCard({ prompt }: { prompt: string }) {
const [open, setOpen] = useState(true);
return (
<div style={{ borderRadius: 12, background: "#0f0f13", border: "1px solid rgba(255,111,97,.3)", padding: "16px 18px", boxShadow: "0 8px 22px rgba(0,0,0,.4)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: open ? 13 : 0 }}>
<span style={{ width: 26, height: 26, flex: "none", borderRadius: 7, background: "rgba(255,111,97,.18)", border: "1px solid rgba(255,111,97,.35)", display: "inline-flex", alignItems: "center", justifyContent: "center", color: "#ff8a7a" }}><ScrollText size={15} /></span>
<span style={{ fontFamily: mono, fontSize: 12.5, fontWeight: 600, letterSpacing: ".05em", color: "#ff8a7a" }}>SYSTEM PROMPT</span>
<span style={{ flex: 1 }} />
<CollapseTick open={open} color="#ff8a7a" onClick={() => setOpen((o) => !o)} />
</div>
{open ? (prompt?.trim() ? <MarkdownText text={prompt} baseSize={14} /> : <p style={{ fontSize: 13, color: "#6a6a72", margin: 0 }}>No system prompt set.</p>) : null}
</div>
);
}
// A same-colored triangle toggle: points down when open, left when closed.
function CollapseTick({ open, color, onClick }: { open: boolean; color: string; onClick: () => void }) {
return (
<button type="button" onClick={onClick} aria-label={open ? "Collapse" : "Expand"} aria-expanded={open}
style={{ flex: "none", border: 0, background: "transparent", cursor: "pointer", color, padding: 4, display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ display: "inline-block", fontSize: 11, lineHeight: 1, transform: open ? "rotate(0deg)" : "rotate(90deg)", transition: "transform .15s ease" }}>▼</span>
</button>
);
}
// Personality is often stored as JSON (traits/tone/…). Render it as labeled
// sections instead of dumping raw JSON; fall back to tags / text otherwise.
function PersonalityBody({ raw, fallback }: { raw: string | null | undefined; fallback: string[] }) {
const tags = (arr: string[]) => <div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>{arr.map((p, i) => <span key={i} style={tag("#c98af0")}>{p}</span>)}</div>;
const trimmed = (raw ?? "").trim();
let parsed: unknown;
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try { parsed = JSON.parse(trimmed); } catch { parsed = undefined; }
}
if (Array.isArray(parsed)) return tags(parsed.map((x) => String(x)));
if (parsed && typeof parsed === "object") {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
{Object.entries(parsed as Record<string, unknown>).map(([k, v]) => (
<div key={k}>
<div style={{ fontFamily: mono, fontSize: 10.5, letterSpacing: ".08em", color: "#b9a6e0", marginBottom: 5, textTransform: "uppercase" }}>{k.replace(/_/g, " ")}</div>
{Array.isArray(v)
? tags(v.map((x) => String(x)))
: v && typeof v === "object"
? <div style={{ display: "flex", flexDirection: "column", gap: 3, fontSize: 13, color: "#cfcfd5", lineHeight: 1.55 }}>{Object.entries(v as Record<string, unknown>).map(([kk, vv]) => <div key={kk}><span style={{ color: "#8a8a92" }}>{kk}:</span> {String(vv)}</div>)}</div>
: <div style={{ fontSize: 13.5, color: "#d6d6dc", lineHeight: 1.55 }}>{String(v)}</div>}
</div>
))}
</div>
);
}
if (fallback.length) return tags(fallback);
if (trimmed) return <div style={{ fontSize: 13.5, color: "#d6d6dc", lineHeight: 1.6 }}>{trimmed}</div>;
return <span style={{ fontSize: 12, color: "#6a6a72" }}>—</span>;
}
function AnatomyCard({ tint, label, count, icon, collapsible, children }: { tint: string; label: string; count?: string; icon?: React.ReactNode; collapsible?: boolean; children: React.ReactNode }) {
const [open, setOpen] = useState(true);
const collapsed = !!collapsible && !open;
return (
<div style={{ width: "100%", height: collapsed ? "auto" : "100%" }}>
<div style={{ height: collapsed ? "auto" : "100%", boxSizing: "border-box", display: "flex", flexDirection: "column", borderRadius: 12, background: "#0f0f13", border: `1px solid ${tint}38`, padding: 12, boxShadow: "0 8px 22px rgba(0,0,0,.4)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: collapsed ? 0 : 11 }}>
<span style={{ width: 26, height: 26, flex: "none", borderRadius: 7, background: `${tint}26`, border: `1px solid ${tint}40`, display: "inline-flex", alignItems: "center", justifyContent: "center", color: tint }}>{icon}</span>
<span style={{ fontFamily: mono, fontSize: 12.5, fontWeight: 600, letterSpacing: ".05em", color: tint }}>{label}</span>
{count || collapsible ? <span style={{ flex: 1 }} /> : null}
{count ? <span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}>{count}</span> : null}
{collapsible ? <CollapseTick open={open} color={tint} onClick={() => setOpen((o) => !o)} /> : null}
</div>
{!collapsed ? <div style={{ flex: 1, minHeight: 0 }}>{children}</div> : null}
</div>
</div>
);
}
// A pill-shaped tag, tinted to its section's accent so it reads clearly as a
// tag. `dim` is the muted "+N more" variant.
const tag = (color = "#9a9aa2", dim = false): CSSProperties => ({ fontFamily: mono, fontSize: 12, color: dim ? "#8a8a92" : color, padding: "4px 10px", borderRadius: 7, background: dim ? "rgba(255,255,255,.04)" : `${color}1f`, border: `1px solid ${dim ? "rgba(255,255,255,.08)" : `${color}45`}` });
function ClawAnatomyCanvas({ agent, teamName, avatarUrl, onToolsChanged, brain }: { agent: DemoAgent; teamName: string; avatarUrl?: string; onToolsChanged?: () => void; brain?: RawBrain }) {
const c = agent.compartments;
const row = (a: string, b: string, bc = "#cfcfd5") => (<div style={{ display: "flex" }}><span style={{ flex: 1 }}>{a}</span><span style={{ color: bc }}>{b}</span></div>);
const skillsShown = c.skills.slice(0, 3);
const skillsRest = c.skills.length - skillsShown.length;
// Avatar: click opens the image modal (upload / generate / save). `imgUrl`
// holds a just-saved image for instant feedback; otherwise the persisted
// `avatarUrl` (agent.avatar) is shown. Both reset when the claw changes.
const [imgUrl, setImgUrl] = useState<string | null>(null);
const [seenImgId, setSeenImgId] = useState(agent.id);
if (seenImgId !== agent.id) { setSeenImgId(agent.id); setImgUrl(null); }
const [avatarOpen, setAvatarOpen] = useState(false);
const [addToolOpen, setAddToolOpen] = useState(false);
const shownAvatar = imgUrl ?? avatarUrl ?? null;
const router = useRouter();
return (
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", background: "radial-gradient(120% 90% at 50% 42%, #100e13 0%, #08080a 70%)" }}>
<div style={{ position: "relative", flex: 1, minHeight: 0, overflow: "auto", padding: "46px 20px 28px 44px" }}>
<div style={{ maxWidth: 920, margin: 0, display: "flex", flexDirection: "column", alignItems: "center", gap: 20 }}>
{/* Name (left) · avatar (center) · title (right), above the activity card. */}
<div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 18 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, width: "100%", padding: "0 2px" }}>
<div style={{ borderRadius: 13, background: "#0f0f13", border: "1px solid rgba(255,255,255,.1)", padding: "11px 22px", boxShadow: "0 8px 22px rgba(0,0,0,.4)" }}>
<div style={{ fontSize: 24, fontWeight: 800, color: "#f3f3f5", letterSpacing: "-.02em" }}>{agent.name}</div>
</div>
{/* Avatar — centered between the name and title. Click to edit. */}
<div style={{ position: "relative", width: 132, height: 132, flex: "none" }}>
<div style={{ position: "absolute", inset: -9, borderRadius: "50%", border: "1.5px solid rgba(255,111,97,.35)" }} />
<div style={{ position: "absolute", inset: 0, borderRadius: "50%", background: "rgba(255,111,97,.3)", animation: "cm-halo 2s ease-out infinite" }} />
<button type="button" onClick={() => setAvatarOpen(true)} title="Click to edit image" style={{ position: "relative", width: 132, height: 132, borderRadius: "50%", border: 0, padding: 0, cursor: "pointer", background: shownAvatar ? `center/cover no-repeat url(${shownAvatar})` : agent.grad, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 48, fontWeight: 700, color: agent.ink, boxShadow: "0 0 56px rgba(255,111,97,.5)" }}>{shownAvatar ? null : agent.initial}</button>
<span aria-hidden style={{ position: "absolute", right: 2, bottom: 2, width: 32, height: 32, borderRadius: "50%", background: "#15151a", border: "1.5px solid #08080a", display: "flex", alignItems: "center", justifyContent: "center", color: "#cfcfd5", pointerEvents: "none" }}><Camera size={16} /></span>
</div>
<div style={{ borderRadius: 999, background: "rgba(255,111,97,.08)", border: "1px solid rgba(255,111,97,.3)", padding: "7px 18px" }}>
<span style={{ fontFamily: mono, fontSize: 12, letterSpacing: ".08em", color: "#ff8a7a" }}>{agent.role}</span>
</div>
</div>
<VitalsCard seed={agent.id} />
</div>
<div style={{ width: "100%" }}><SystemPromptCard prompt={agent.systemPrompt} /></div>
{/* Card data mappings (intended real sources for live wiring):
NOW RUNNING = every active task this agent is currently working on.
SKILLS = the skills assigned to this agent (installed_skills).
PERSONALITY = the agent's persona.md / soul.md from the backend.
TOOLS = all tools granted to this agent; the "+ Add tool"
button installs more from the catalog (/api/skills). */}
{/* 3×3 grid: row1 Personality/Skills/Tools, row2 Memory/Capabilities/
Safety, row3 the full-width NOW RUNNING live log. */}
{/* PERSONALITY as its own full-width card under the system prompt. */}
<div style={{ width: "100%" }}>
<AnatomyCard tint="#c98af0" label="PERSONALITY" icon={<Drama size={15} />} collapsible><PersonalityBody raw={brain?.personality} fallback={c.personality} /></AnatomyCard>
</div>
{/* Skills · Tools · Memory across; Capabilities · Safety side by side;
then the full-width live log. */}
<div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 14 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 14, alignItems: "stretch" }}>
<AnatomyCard tint="#ff6f61" label="SKILLS" count={String(c.skills.length)} icon={<Zap size={15} />}><div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>{skillsShown.map((s) => (<span key={s} style={tag("#ff6f61")}>{s}</span>))}{skillsRest > 0 ? <span style={tag("#9a9aa2", true)}>+{skillsRest}</span> : null}</div></AnatomyCard>
<AnatomyCard tint="#5ec8d8" label="TOOLS · DOORS" icon={<Wrench size={15} />}>
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 6, fontFamily: mono, fontSize: 12.5 }}>{c.tools.map((t) => row(t.name, t.state === "gated" ? "gated ✓" : "blocked ⨯", t.state === "gated" ? "#5fd08a" : "#e8b465"))}</div>
<button type="button" onClick={() => setAddToolOpen(true)} style={{ marginTop: "auto", width: "100%", padding: "8px 0", borderRadius: 7, border: "1px dashed rgba(94,200,216,.4)", background: "transparent", color: "#5ec8d8", fontSize: 12.5, fontFamily: mono, cursor: "pointer" }}>+ Add tool</button>
</div>
</AnatomyCard>
<AnatomyCard tint="#5fd08a" label="MEMORY" count={brain ? String(brain.stats.memories) : undefined} icon={<Database size={15} />}>
{brain && brain.memory.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 5, fontFamily: mono, fontSize: 12.5, color: "#9a9aa2", maxHeight: 150, overflowY: "auto" }}>
{brain.memory.slice(0, 6).map((m, i) => (<div key={i} title={m} style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m}</div>))}
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6, fontFamily: mono, fontSize: 12.5, color: "#9a9aa2" }}>{brain ? <span style={{ color: "#6a6a72" }}>no memories yet — chat with this agent and they appear here.</span> : (<>{row("Long-term", c.memory.long)}{row("Recent ctx", c.memory.recent)}</>)}</div>
)}
</AnatomyCard>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 14, alignItems: "stretch" }}>
<AnatomyCard tint="#e8b465" label="CAPABILITIES" icon={<Cpu size={15} />}><div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>{c.capabilities.map((p) => (<span key={p} style={tag("#e8b465")}>{p}</span>))}</div></AnatomyCard>
<AnatomyCard tint="#6fd0c0" label="SAFETY · §15" icon={<ShieldCheck size={15} />}><div style={{ display: "flex", flexDirection: "column", gap: 6, fontFamily: mono, fontSize: 12.5, color: "#9a9aa2" }}>{row("Sandbox", c.safety.sandbox, "#6fd0c0")}{row("Network", c.safety.network, "#6fd0c0")}</div></AnatomyCard>
</div>
{/* Full-width live work log. */}
<div>
<AnatomyCard tint="#5ec8d8" label="NOW RUNNING · LIVE" count={agent.nowRunning.length ? `${agent.nowRunning.length} active` : "idle"} icon={<Activity size={15} />}>
<div style={{ minHeight: 200, maxHeight: 340, overflowY: "auto" }}>
{agent.nowRunning.length === 0 ? (
<span style={{ fontFamily: mono, fontSize: 12.5, color: "#6a6a72" }}>idle — no active tasks. The agent&apos;s live work log will stream here as it runs.</span>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
{agent.nowRunning.map((t, i) => (
<div key={i}>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: t.progress != null ? 5 : 0 }}>
<span className={t.kind === "loop" ? "cm-blink" : ""} style={{ width: 6, height: 6, borderRadius: "50%", background: t.kind === "loop" ? "#5ec8d8" : "#e8b465" }} />
<span style={{ fontSize: 13.5, fontWeight: 600, color: "#e6e6ea" }}>{t.name}</span>
<span style={{ flex: 1 }} />
<span style={{ fontFamily: mono, fontSize: 11, color: t.kind === "loop" ? "#5ec8d8" : "#e8b465" }}>{t.detail}</span>
</div>
{t.progress != null ? (
<div style={{ height: 4, borderRadius: 2, background: "rgba(255,255,255,.08)", overflow: "hidden" }}><div style={{ width: `${t.progress}%`, height: "100%", background: "linear-gradient(90deg,#5ec8d8,#4aa3b8)" }} /></div>
) : null}
</div>
))}
</div>
)}
</div>
</AnatomyCard>
</div>
</div>
<span style={{ textAlign: "center", fontFamily: mono, fontSize: 10, color: "#3a3a40" }}>part of {teamName} · the computer panel runs this agent&apos;s apps &amp; routines</span>
</div>
</div>
{avatarOpen ? (
<AvatarModal clawId={agent.id} clawName={agent.name} current={shownAvatar} onClose={() => setAvatarOpen(false)} onSaved={(url) => { setImgUrl(url); router.refresh(); }} />
) : null}
{addToolOpen ? (
<AddToolModal clawId={agent.id} clawName={agent.name} onClose={() => setAddToolOpen(false)} onAdded={() => onToolsChanged?.()} />
) : null}
</div>
);
}
@@ -0,0 +1,134 @@
"use client";
// Shared anatomy-card primitives — extracted so the agent command center
// (ClawCommandCenter) reuses the exact cards/styling. Behavior copied verbatim
// from Dashboard.tsx's in-file helpers.
import { useState, type CSSProperties } from "react";
import { ScrollText } from "lucide-react";
export const mono = "'JetBrains Mono', ui-monospace, monospace";
// `GET /api/claws/{id}/brain` — the claw's .brain (cm-brain / ClawhDF5).
export type RawBrain = {
exists: boolean;
system_prompt: string | null;
personality: string | null;
skills: { name: string; body: string }[];
tools: { name: string; state: string }[];
memory: string[];
stats: { skills: number; tools: number; memories: number };
} | null;
// Inline markdown: **bold** and `code` → styled spans (everything else verbatim).
function renderInline(text: string, kp: string): React.ReactNode[] {
return text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).filter(Boolean).map((p, i) => {
if (p.startsWith("**") && p.endsWith("**")) return <strong key={`${kp}-${i}`} style={{ color: "#fff", fontWeight: 700 }}>{p.slice(2, -2)}</strong>;
if (p.startsWith("`") && p.endsWith("`")) return <code key={`${kp}-${i}`} style={{ fontFamily: mono, fontSize: "0.9em", background: "rgba(255,255,255,.06)", padding: "1px 5px", borderRadius: 4, color: "#e6e6ea" }}>{p.slice(1, -1)}</code>;
return <span key={`${kp}-${i}`}>{p}</span>;
});
}
// A tiny, dependency-free Markdown renderer: headings, bullet lists, bold/code,
// paragraphs. Turns the raw prompt into readable prose instead of literal `##`.
export function MarkdownText({ text, baseSize = 14 }: { text: string; baseSize?: number }) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const blocks: React.ReactNode[] = [];
let para: string[] = [];
let list: string[] = [];
const flushPara = () => {
if (para.length) { const k = `p${blocks.length}`; blocks.push(<p key={k} style={{ margin: "0 0 10px", fontSize: baseSize, lineHeight: 1.65, color: "#d6d6dc" }}>{renderInline(para.join(" "), k)}</p>); para = []; }
};
const flushList = () => {
if (list.length) { const k = `u${blocks.length}`; blocks.push(<ul key={k} style={{ margin: "0 0 10px", paddingLeft: 18, display: "flex", flexDirection: "column", gap: 5 }}>{list.map((li, i) => <li key={i} style={{ fontSize: baseSize, lineHeight: 1.55, color: "#d6d6dc" }}>{renderInline(li, `${k}-${i}`)}</li>)}</ul>); list = []; }
};
for (const raw of lines) {
const line = raw.replace(/\s+$/, "");
const h = line.match(/^(#{1,6})\s+(.*)$/);
const li = line.match(/^\s*[-*]\s+(.*)$/);
if (h) { flushPara(); flushList(); const lvl = h[1].length; const size = lvl <= 1 ? baseSize + 5 : lvl === 2 ? baseSize + 2 : baseSize + 1; const k = `h${blocks.length}`; blocks.push(<div key={k} style={{ fontSize: size, fontWeight: 700, color: "#fff", letterSpacing: "-.01em", margin: blocks.length ? "15px 0 7px" : "0 0 7px" }}>{renderInline(h[2], k)}</div>); }
else if (li) { flushPara(); list.push(li[1]); }
else if (line.trim() === "") { flushPara(); flushList(); }
else { flushList(); para.push(line.replace(/^\s*>\s?/, "")); }
}
flushPara(); flushList();
return <div>{blocks}</div>;
}
// A same-colored triangle toggle: points down when open, left when closed.
export function CollapseTick({ open, color, onClick }: { open: boolean; color: string; onClick: () => void }) {
return (
<button type="button" onClick={onClick} aria-label={open ? "Collapse" : "Expand"} aria-expanded={open}
style={{ flex: "none", border: 0, background: "transparent", cursor: "pointer", color, padding: 4, display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ display: "inline-block", fontSize: 11, lineHeight: 1, transform: open ? "rotate(0deg)" : "rotate(90deg)", transition: "transform .15s ease" }}>▼</span>
</button>
);
}
export function SystemPromptCard({ prompt }: { prompt: string }) {
const [open, setOpen] = useState(true);
return (
<div style={{ borderRadius: 12, background: "#0f0f13", border: "1px solid rgba(255,111,97,.3)", padding: "16px 18px", boxShadow: "0 8px 22px rgba(0,0,0,.4)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: open ? 13 : 0 }}>
<span style={{ width: 26, height: 26, flex: "none", borderRadius: 7, background: "rgba(255,111,97,.18)", border: "1px solid rgba(255,111,97,.35)", display: "inline-flex", alignItems: "center", justifyContent: "center", color: "#ff8a7a" }}><ScrollText size={15} /></span>
<span style={{ fontFamily: mono, fontSize: 12.5, fontWeight: 600, letterSpacing: ".05em", color: "#ff8a7a" }}>SYSTEM PROMPT</span>
<span style={{ flex: 1 }} />
<CollapseTick open={open} color="#ff8a7a" onClick={() => setOpen((o) => !o)} />
</div>
{open ? (prompt?.trim() ? <MarkdownText text={prompt} baseSize={14} /> : <p style={{ fontSize: 13, color: "#6a6a72", margin: 0 }}>No system prompt set.</p>) : null}
</div>
);
}
// Personality is often stored as JSON (traits/tone/…). Render it as labeled
// sections instead of dumping raw JSON; fall back to tags / text otherwise.
export function PersonalityBody({ raw, fallback }: { raw: string | null | undefined; fallback: string[] }) {
const tags = (arr: string[]) => <div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>{arr.map((p, i) => <span key={i} style={tag("#c98af0")}>{p}</span>)}</div>;
const trimmed = (raw ?? "").trim();
let parsed: unknown;
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try { parsed = JSON.parse(trimmed); } catch { parsed = undefined; }
}
if (Array.isArray(parsed)) return tags(parsed.map((x) => String(x)));
if (parsed && typeof parsed === "object") {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
{Object.entries(parsed as Record<string, unknown>).map(([k, v]) => (
<div key={k}>
<div style={{ fontFamily: mono, fontSize: 10.5, letterSpacing: ".08em", color: "#b9a6e0", marginBottom: 5, textTransform: "uppercase" }}>{k.replace(/_/g, " ")}</div>
{Array.isArray(v)
? tags(v.map((x) => String(x)))
: v && typeof v === "object"
? <div style={{ display: "flex", flexDirection: "column", gap: 3, fontSize: 13, color: "#cfcfd5", lineHeight: 1.55 }}>{Object.entries(v as Record<string, unknown>).map(([kk, vv]) => <div key={kk}><span style={{ color: "#8a8a92" }}>{kk}:</span> {String(vv)}</div>)}</div>
: <div style={{ fontSize: 13.5, color: "#d6d6dc", lineHeight: 1.55 }}>{String(v)}</div>}
</div>
))}
</div>
);
}
if (fallback.length) return tags(fallback);
if (trimmed) return <div style={{ fontSize: 13.5, color: "#d6d6dc", lineHeight: 1.6 }}>{trimmed}</div>;
return <span style={{ fontSize: 12, color: "#6a6a72" }}>—</span>;
}
export function AnatomyCard({ tint, label, count, icon, collapsible, children }: { tint: string; label: string; count?: string; icon?: React.ReactNode; collapsible?: boolean; children: React.ReactNode }) {
const [open, setOpen] = useState(true);
const collapsed = !!collapsible && !open;
return (
<div style={{ width: "100%" }}>
<div style={{ boxSizing: "border-box", display: "flex", flexDirection: "column", borderRadius: 12, background: "#0f0f13", border: `1px solid ${tint}38`, padding: 12, boxShadow: "0 8px 22px rgba(0,0,0,.4)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: collapsed ? 0 : 11 }}>
<span style={{ width: 26, height: 26, flex: "none", borderRadius: 7, background: `${tint}26`, border: `1px solid ${tint}40`, display: "inline-flex", alignItems: "center", justifyContent: "center", color: tint }}>{icon}</span>
<span style={{ fontFamily: mono, fontSize: 12.5, fontWeight: 600, letterSpacing: ".05em", color: tint }}>{label}</span>
{count || collapsible ? <span style={{ flex: 1 }} /> : null}
{count ? <span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}>{count}</span> : null}
{collapsible ? <CollapseTick open={open} color={tint} onClick={() => setOpen((o) => !o)} /> : null}
</div>
{!collapsed ? <div>{children}</div> : null}
</div>
</div>
);
}
// A pill-shaped tag, tinted to its section's accent. `dim` is the muted "+N" variant.
export const tag = (color = "#9a9aa2", dim = false): CSSProperties => ({ fontFamily: mono, fontSize: 12, color: dim ? "#8a8a92" : color, padding: "4px 10px", borderRadius: 7, background: dim ? "rgba(255,255,255,.04)" : `${color}1f`, border: `1px solid ${dim ? "rgba(255,255,255,.08)" : `${color}45`}` });
+8 -3
View File
@@ -43,8 +43,9 @@ export interface TaxonomyEvents {
nodes?: { id: string; tier: NodeTier; label?: string; parentId?: string }[]; nodes?: { id: string; tier: NodeTier; label?: string; parentId?: string }[];
edges?: { from: string; to: string; kind?: "parent" | "peer" | "flow" }[]; edges?: { from: string; to: string; kind?: "parent" | "peer" | "flow" }[];
}; };
/** Top-bar pills + Observe System telemetry strip. */ /** Top-bar pills + Observe System telemetry strip. With `agentId` it's the
telemetry: { tokensPerMin?: number; costPerHr?: number; loops?: number; doorsPending?: number }; * per-agent slice for the command-center metric band; without, workspace-wide. */
telemetry: { agentId?: string; tokensPerMin?: number; costPerHr?: number; loops?: number; doorsPending?: number };
/** Observe System → ROUTINES & LOOPS; the scheduler view. */ /** Observe System → ROUTINES & LOOPS; the scheduler view. */
"routine.update": { "routine.update": {
routineId: string; routineId: string;
@@ -97,5 +98,9 @@ export function stateKey<T extends TaxonomyType>(type: T, d: TaxonomyPayload<T>)
return `${type}:${(d as TaxonomyEvents["agent.status"]).agentId}`; return `${type}:${(d as TaxonomyEvents["agent.status"]).agentId}`;
if (type === "node.activity") return `${type}:${(d as TaxonomyEvents["node.activity"]).nodeId}`; if (type === "node.activity") return `${type}:${(d as TaxonomyEvents["node.activity"]).nodeId}`;
if (type === "routine.update") return `${type}:${(d as TaxonomyEvents["routine.update"]).routineId}`; if (type === "routine.update") return `${type}:${(d as TaxonomyEvents["routine.update"]).routineId}`;
return type; // telemetry, topology.update if (type === "telemetry") {
const a = (d as TaxonomyEvents["telemetry"]).agentId;
return a ? `telemetry:${a}` : "telemetry"; // per-agent slice vs workspace-wide
}
return type; // topology.update
} }
+15
View File
@@ -186,6 +186,21 @@ export function useLiveState<T extends TaxonomyType, S>(
return state; return state;
} }
/** The per-agent telemetry slice for the command-center metric band. Re-subscribes
* when the agent changes so the replayed last-known value paints immediately. */
export function useAgentTelemetry(agentId: string | null): TaxonomyPayload<"telemetry"> | null {
const client = useClawmatesLive();
const [t, setT] = useState<TaxonomyPayload<"telemetry"> | null>(null);
useEffect(() => {
setT(null);
if (!agentId) return;
return client.on("telemetry", (d) => {
if (d.agentId === agentId) setT(d);
});
}, [client, agentId]);
return t;
}
// --- synthetic generator (no backend) — ports the demo loop so the World/Observe // --- synthetic generator (no backend) — ports the demo loop so the World/Observe
// views breathe even with nothing wired. Stops the moment a real feed connects. -- // views breathe even with nothing wired. Stops the moment a real feed connects. --
type Emit = <T extends TaxonomyType>(type: T, data: TaxonomyPayload<T>) => void; type Emit = <T extends TaxonomyType>(type: T, data: TaxonomyPayload<T>) => void;