feat(ui): Settings view + per-space project cards (status/research/edit/delete) + theming pass

- Settings (#/settings): API tokens (mint/list/revoke), Agents list, Orthos Mode placeholder
- Per-space Projects: Void-1-style expandable cards — inline status, ↻ Research (Eithan stub),
  Edit/New modal, Delete-with-confirm; migration 019 adds research_status/notes/timestamps;
  POST /api/projects/:id/research stub; GET /api/agent-tokens list
- Global +1 font bump; themed scrollbars; larger/bolder themed topbar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-05 00:06:08 +10:00
parent 4a55c24700
commit 80363d3e68
14 changed files with 432 additions and 113 deletions

81
public/views/settings.js Normal file
View File

@@ -0,0 +1,81 @@
// #/settings — API tokens, agents, and a placeholder for Orthos Mode.
import { el, mount } from '../dom.js';
import { api } from '../api.js';
function section(title, sub, bodyEl) {
return el('div', { class: 'card settings-card' },
el('h3', {}, title),
sub ? el('p', { class: 'settings-sub muted' }, sub) : null,
bodyEl);
}
async function renderTokens(c) {
c.replaceChildren(el('div', { class: 'muted' }, 'Loading…'));
let tokens = [], agents = [];
try { tokens = await api.get('/api/agent-tokens'); } catch { /* */ }
try { agents = await api.get('/api/agents'); } catch { /* */ }
c.replaceChildren();
const sel = el('select', { class: 'pm-input', style: { maxWidth: '210px' } }, agents.map(a => el('option', { value: a.id }, `${a.name} (${a.slug})`)));
const label = el('input', { class: 'pm-input', placeholder: 'label (optional)', style: { maxWidth: '170px' } });
const out = el('div', { class: 'token-out' });
const mint = el('button', {
class: 'primary',
onclick: async () => {
if (!sel.value) { out.textContent = 'No agents available'; return; }
try {
const r = await api.post('/api/agents/' + sel.value + '/tokens', { label: label.value.trim() || undefined });
out.replaceChildren(el('div', { class: 'token-reveal' }, el('span', {}, 'New token — copy it now, it wont be shown again: '), el('code', {}, r.token)));
const list = await api.get('/api/agent-tokens'); tokens = list; paint();
} catch (e) { out.textContent = 'failed: ' + e.message; }
}
}, 'Mint token');
const listWrap = el('div', {});
function paint() {
listWrap.replaceChildren();
if (!tokens.length) { listWrap.appendChild(el('div', { class: 'muted' }, 'No tokens yet.')); return; }
for (const t of tokens) {
listWrap.appendChild(el('div', { class: 'settings-row' + (t.revoked_at ? ' revoked' : '') },
el('span', { class: 'settings-label' }, t.agent_name || t.agent_slug),
el('span', { class: 'settings-value muted' }, `${t.label || '—'} · ${t.last_used ? 'used ' + new Date(t.last_used).toLocaleDateString() : 'never used'}`),
t.revoked_at
? el('span', { class: 'muted' }, 'revoked')
: el('button', { class: 'proj-btn danger', onclick: async () => { if (!confirm('Revoke this token?')) return; try { await api.del('/api/agent-tokens/' + t.id); renderTokens(c); } catch (e) { alert(e.message); } } }, 'Revoke')));
}
}
c.appendChild(el('div', { class: 'settings-row settings-create' }, sel, label, mint));
c.appendChild(out);
c.appendChild(listWrap);
paint();
}
async function renderAgents(c) {
c.replaceChildren(el('div', { class: 'muted' }, 'Loading…'));
let agents = [];
try { agents = await api.get('/api/agents'); } catch { /* */ }
c.replaceChildren();
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 : ''}`)));
}
}
export async function render(main) {
const tokensBody = el('div', { class: 'settings-body' });
const agentsBody = el('div', { class: 'settings-body' });
mount(main,
el('h1', { class: 'view-h1' }, '◆ Settings'),
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
section('Orthos Mode', 'Local-first answering — Orthos answers first, Claude escalates when needed.',
el('div', { class: 'muted' }, 'Paused for a future project (arrives with the local-agent layer).'))
);
renderTokens(tokensBody);
renderAgents(agentsBody);
}

View File

@@ -1,14 +1,10 @@
// Space view — Projects + Open tasks side by side at the top, then a full
// pages & references table below (all pages; refs up to the API max).
// Space view — rich project cards (status, research, edit/delete) + open tasks,
// then a full pages & references table.
import { api } from '../api.js';
import { el, mount } from '../dom.js';
import { exportMenu } from '../components/export_menu.js';
function projItem(p) {
return el('li', {},
el('a', { href: '#/project/' + p.id }, p.name), ' ',
el('span', { class: 'status' + (p.status === 'done' ? ' ok' : p.status === 'paused' ? ' warn' : '') }, p.status));
}
import { projectCard } from '../components/project_card.js';
import { openProjectModal } from '../components/project_modal.js';
function taskItem(t) {
return el('li', {},
@@ -23,28 +19,32 @@ function tableRow(href, title, type) {
export async function render(main, ctx) {
const id = ctx.params.id;
mount(main,
el('h1', { class: 'view-h1' }, 'Space'),
el('p', { class: 'view-sub muted' }, 'Loading …')
);
mount(main, el('h1', { class: 'view-h1' }, 'Space'), el('p', { class: 'view-sub muted' }, 'Loading …'));
localStorage.setItem('last_space_id', id);
let space;
try { space = await api.get('/api/spaces/' + id); }
catch (e) {
mount(main,
el('h1', { class: 'view-h1' }, 'Space not found'),
el('p', { class: 'view-sub muted' }, e.message)
);
return;
}
catch (e) { mount(main, el('h1', { class: 'view-h1' }, 'Space not found'), el('p', { class: 'view-sub muted' }, e.message)); return; }
const [projects, tasks, pages, refs] = await Promise.all([
api.get(`/api/spaces/${id}/projects`).catch(() => []),
let projects = [];
const [tasks, pages, refs] = await Promise.all([
api.get(`/api/spaces/${id}/tasks?status=todo`).catch(() => []),
api.get(`/api/spaces/${id}/pages`).catch(() => []), // returns ALL pages
api.get(`/api/refs?space_id=${id}&limit=200`).catch(() => []) // 200 = API max
api.get(`/api/spaces/${id}/pages`).catch(() => []),
api.get(`/api/refs?space_id=${id}&limit=200`).catch(() => [])
]);
try { projects = await api.get(`/api/spaces/${id}/projects`); } catch { /* */ }
// ---- Projects section (rich cards) ----
const projWrap = el('div', { class: 'proj-list' });
function renderProjects() {
projWrap.replaceChildren();
projHead.textContent = `Projects${projects.length ? ` (${projects.length})` : ''}`;
if (!projects.length) { projWrap.appendChild(el('div', { class: 'muted', style: { padding: '4px 2px' } }, 'No projects yet.')); return; }
for (const p of projects) projWrap.appendChild(projectCard(p, { reload, rerender: renderProjects }));
}
async function reload() { try { projects = await api.get(`/api/spaces/${id}/projects`); } catch { /* */ } renderProjects(); }
const projHead = el('h3', {}, 'Projects');
renderProjects();
const rows = [
...pages.map(p => tableRow('#/page/' + p.id, p.title, 'page')),
@@ -65,33 +65,23 @@ export async function render(main, ctx) {
),
el('p', { class: 'view-sub' }, space.description || el('span', { class: 'muted' }, 'No description.')),
// Top: Projects + Open tasks, side by side.
el('div', { class: 'row' },
el('div', { class: 'card' },
el('h3', {}, `Projects${projects.length ? ` (${projects.length})` : ''}`),
projects.length
? el('ul', { class: 'plain' }, projects.map(projItem))
: el('p', { class: 'muted' }, 'None yet.')
),
el('div', { class: 'card' },
el('h3', {}, `Open tasks${tasks.length ? ` (${tasks.length})` : ''}`),
tasks.length
? el('ul', { class: 'plain' }, tasks.map(taskItem))
: el('p', { class: 'muted' }, 'Clear board.')
)
),
el('div', { class: 'card' },
el('div', { class: 'card-head' }, projHead,
el('button', { class: 'primary', onclick: () => openProjectModal(id, null, reload) }, '+ New')),
projWrap),
el('div', { class: 'card' },
el('h3', {}, `Open tasks${tasks.length ? ` (${tasks.length})` : ''}`),
tasks.length ? el('ul', { class: 'plain' }, tasks.map(taskItem)) : el('p', { class: 'muted' }, 'Clear board.')),
// Below: the full pages & references table.
el('div', { class: 'card' },
el('h3', {}, `Pages & references${rows.length ? ` (${pages.length + refs.length})` : ''}`),
rows.length
? el('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: '13px' } },
el('thead', {},
el('tr', {},
el('th', { class: 'muted', style: { textAlign: 'left', padding: '5px 8px', fontWeight: '500' } }, 'Title'),
el('th', { class: 'muted', style: { textAlign: 'left', padding: '5px 8px', width: '90px', fontWeight: '500' } }, 'Type'))),
el('tbody', {}, rows))
: el('p', { class: 'muted' }, 'Nothing here yet.')
)
el('thead', {}, el('tr', {},
el('th', { class: 'muted', style: { textAlign: 'left', padding: '5px 8px', fontWeight: '500' } }, 'Title'),
el('th', { class: 'muted', style: { textAlign: 'left', padding: '5px 8px', width: '90px', fontWeight: '500' } }, 'Type'))),
el('tbody', {}, rows))
: el('p', { class: 'muted' }, 'Nothing here yet.'))
);
}