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 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-05 00:11:14 +10:00
parent 80363d3e68
commit bf58b624a3
5 changed files with 82 additions and 7 deletions

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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; }

View File

@@ -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);
}
}