From bf58b624a3bcf444d0a52cafd61392032b9f099b Mon Sep 17 00:00:00 2001 From: root Date: Fri, 5 Jun 2026 00:11:14 +1000 Subject: [PATCH] feat(ui): hybrid sidebar (sectioned + active pill + agent dots) + agent profile viewer in Settings Sidebar: Spaces / Agents / Navigate sections, accent pill on active item, status dots on agents. Settings Agents rows expand to show the agent's persona (soul) + capabilities/scopes via GET /api/agents/:id/profile. Co-Authored-By: Claude Opus 4.8 --- lib/ai/personas/index.js | 6 ++++++ lib/api/routes/agents.js | 12 ++++++++++++ public/components/sidebar.js | 9 ++++++--- public/style.css | 24 +++++++++++++++++++++++ public/views/settings.js | 38 ++++++++++++++++++++++++++++++++---- 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/lib/ai/personas/index.js b/lib/ai/personas/index.js index de29045..3fa0475 100644 --- a/lib/ai/personas/index.js +++ b/lib/ai/personas/index.js @@ -19,3 +19,9 @@ You have tools, and you use them rather than guessing: export function personaFor(slug) { return PERSONAS[slug] || PERSONAS.companion; } + +// Like personaFor but no fallback — for display (returns null if the agent has +// no defined persona, e.g. a config-only external agent). +export function getPersona(slug) { + return PERSONAS[slug] || null; +} diff --git a/lib/api/routes/agents.js b/lib/api/routes/agents.js index f4df869..e9f2cca 100644 --- a/lib/api/routes/agents.js +++ b/lib/api/routes/agents.js @@ -4,6 +4,7 @@ import * as repo from '../../db/repos/agents.js'; import { validate } from '../validate.js'; import { requireOwner } from '../cap.js'; import { NotFoundError, ValidationError, asyncWrap } from '../errors.js'; +import { getPersona } from '../../ai/personas/index.js'; const KINDS = ['claude', 'ollama', 'mastra', 'mcp-client', 'external']; @@ -60,6 +61,17 @@ router.get('/:id', }) ); +// The "files behind the agent" — its persona (soul) + capabilities/scopes config. +// Void 2 agents are persona-in-code + DB config (no separate memory files like v1). +router.get('/:id/profile', + validate({ params: idParams }), + asyncWrap(async (req, res) => { + const a = await repo.getById(req.params.id); + if (!a) throw new NotFoundError('agent not found'); + res.json({ slug: a.slug, name: a.name, kind: a.kind, model: a.model, capabilities: a.capabilities, scopes: a.scopes, persona: getPersona(a.slug) }); + }) +); + router.patch('/:id/capabilities', validate({ params: idParams, body: capsSchema }), asyncWrap(async (req, res) => { diff --git a/public/components/sidebar.js b/public/components/sidebar.js index ef488e5..4b06a75 100644 --- a/public/components/sidebar.js +++ b/public/components/sidebar.js @@ -19,6 +19,7 @@ function navItem(label, hash, opts = {}) { }, opts.icon ? el('span', { class: 'caret' }, opts.icon) : null, el('span', { style: { flex: 1 } }, label), + opts.dot ? el('span', { class: 'sb-dot ' + opts.dot }) : null, opts.badge !== undefined && opts.badge !== null ? el('span', { class: 'badge' }, String(opts.badge)) : null ); } @@ -87,12 +88,14 @@ export function renderSidebar(root) { el('div', { class: 'sb-title' }, 'Spaces'), spacesContainer ), - el('hr'), + el('div', { class: 'sb-section' }, + el('div', { class: 'sb-title' }, 'Agents'), + navItem('Sentinel', '/sentinel', { dot: 'ok' }), + navItem('Little Blue', '/little-blue', { dot: 'lb' }) + ), el('div', { class: 'sb-section' }, el('div', { class: 'sb-title' }, 'Navigate'), navItem('Sacred Valley', '/sacred-valley'), - navItem('Sentinel', '/sentinel'), - navItem('Little Blue', '/little-blue'), navItem('Terminal', '/terminal'), navItem('Search', '/search'), inboxItem, diff --git a/public/style.css b/public/style.css index fe27cfc..d397934 100644 --- a/public/style.css +++ b/public/style.css @@ -262,6 +262,28 @@ button.ghost:hover { color: var(--text); border-color: var(--accent-dim); } .token-reveal { background: var(--accent-soft); border: 1px solid var(--accent-dim); border-radius: 5px; padding: 8px 10px; font-size: 13px; margin: 8px 0; word-break: break-all; } .token-reveal code { color: var(--accent); font-family: var(--font-mono); } +/* ── Sidebar (hybrid: sectioned headers + active pill + agent dots) ── */ +.sb-section { padding: 4px 10px 10px; } +.sb-title { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.18em; color: var(--accent); text-transform: uppercase; padding: 8px 6px; opacity: 0.85; border-bottom: 1px solid var(--border); margin-bottom: 5px; } +.sb-item { padding: 7px 11px; border-radius: 6px; margin: 1px 2px; font-size: 14px; transition: background .12s, color .12s, box-shadow .12s; } +.sb-item:hover { background: var(--panel-2); color: var(--text); } +.sb-item.active { background: var(--accent-soft); color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); font-weight: 500; } +.sb-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; } +.sb-dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); } +.sb-dot.lb { background: var(--lb, #7dd3d8); box-shadow: 0 0 6px var(--lb, #7dd3d8); } + +/* Agent profile expander (Settings) */ +.agent-row { display: block; padding: 8px 0; border-bottom: 1px solid var(--border); } +.agent-row:last-child { border-bottom: none; } +.agent-row-hd { display: flex; align-items: center; gap: 10px; cursor: pointer; } +.agent-row-hd:hover .agent-nm { color: var(--accent); } +.agent-nm { color: var(--text); font-size: 14px; min-width: 120px; } +.agent-row-chev { color: var(--muted); transition: transform .18s; margin-left: auto; } +.agent-row.open .agent-row-chev { transform: rotate(90deg); color: var(--accent); } +.agent-body { margin: 8px 0 2px; padding-left: 4px; } +.agent-file-label { font-family: var(--font-display); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin: 10px 0 4px; } +.agent-file-content { background: var(--bg); border: 1px solid var(--border); border-radius: 5px; padding: 10px 12px; font-size: 12.5px; line-height: 1.5; white-space: pre-wrap; color: var(--text); max-height: 280px; overflow: auto; } + /* modal */ .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; } .modal { @@ -516,3 +538,5 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; } .sv-tray-chip { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); border-radius: 14px; padding: 4px 10px; font-family: var(--font-ui); font-size: 12px; cursor: pointer; } .sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); } + +.hidden { display: none !important; } diff --git a/public/views/settings.js b/public/views/settings.js index 0662203..82876e6 100644 --- a/public/views/settings.js +++ b/public/views/settings.js @@ -51,6 +51,11 @@ async function renderTokens(c) { paint(); } +function fileBlock(parent, label, content) { + parent.appendChild(el('div', { class: 'agent-file-label' }, label)); + parent.appendChild(el('div', { class: 'agent-file-content' }, content)); +} + async function renderAgents(c) { c.replaceChildren(el('div', { class: 'muted' }, 'Loading…')); let agents = []; @@ -59,10 +64,35 @@ async function renderAgents(c) { if (!agents.length) { c.appendChild(el('div', { class: 'muted' }, 'No agents.')); return; } for (const a of agents) { const caps = Object.entries(a.capabilities || {}).filter(([, v]) => v).map(([k]) => k).join(', ') || '—'; - const scope = a.scopes?.space_id ? 'space-scoped' : ''; - c.appendChild(el('div', { class: 'settings-row' }, - el('span', { class: 'settings-label' }, a.name), - el('span', { class: 'settings-value muted' }, `${a.slug} · ${a.kind} · caps: ${caps}${scope ? ' · ' + scope : ''}`))); + const row = el('div', { class: 'agent-row' }); + const body = el('div', { class: 'agent-body hidden' }); + let loaded = false; + const hd = el('div', { + class: 'agent-row-hd', + onclick: async () => { + const open = row.classList.toggle('open'); + body.classList.toggle('hidden', !open); + if (open && !loaded) { + loaded = true; + body.replaceChildren(el('div', { class: 'muted' }, 'Loading…')); + try { + const p = await api.get('/api/agents/' + a.id + '/profile'); + body.replaceChildren(); + if (p.persona) fileBlock(body, 'Soul · persona', p.persona); + else body.appendChild(el('div', { class: 'muted' }, 'No persona defined (config-only agent).')); + fileBlock(body, 'Capabilities', JSON.stringify(p.capabilities || {}, null, 2)); + if (p.scopes && Object.keys(p.scopes).length) fileBlock(body, 'Scopes', JSON.stringify(p.scopes, null, 2)); + body.appendChild(el('div', { class: 'muted', style: { fontSize: '11px', marginTop: '8px' } }, + 'Void 2 agents are persona-in-code + DB config — no separate memory files (unlike Void 1).')); + } catch (e) { body.replaceChildren(el('div', { class: 'err' }, 'Error: ' + e.message)); } + } + } + }, + el('span', { class: 'agent-nm' }, a.name), + el('span', { class: 'settings-value muted' }, `${a.slug} · ${a.kind} · ${caps}`), + el('span', { class: 'agent-row-chev' }, '›')); + row.append(hd, body); + c.appendChild(row); } }