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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user