feat(ui): sidebar + topbar + rightrail components

Sidebar: Spaces tree with lazy-expand to projects on caret click; bottom
Navigate section with Sacred Valley / Search / Inbox + placeholders for
Agents and Resources greyed out as later. Inbox item carries a
pending-count badge that wires to state.js so the topbar bell and the
sidebar share one poll.

Topbar: brand, + Capture button (modal stub for Plan 3 capture queue),
global search input (Enter -> /search?q=), pending Inbox bell with
matching badge, Owner toggle (stub for agent-switching post-Plan-2).

Rightrail remains the T17 collapsible companion placeholder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-01 02:14:44 +10:00
parent 59ad86425d
commit e5b2181ee3
2 changed files with 148 additions and 15 deletions

View File

@@ -1,12 +1,112 @@
// T17 stub — full implementation lands in T18.
// For now: brand only, so the shell renders without errors.
import { el, mount } from '../dom.js';
// Spaces tree (lazy-expand to projects) + global links section.
// Drag-reorder is deferred — static order for now.
export function renderSidebar(root) {
mount(root,
el('div', { class: 'sb-section' },
el('div', { class: 'sb-title' }, 'Void'),
el('div', { class: 'sb-item muted' }, 'Sidebar loads in T18')
)
import { api } from '../api.js';
import { el, mount, clear } from '../dom.js';
import { navigate, current } from '../router.js';
import { on } from '../state.js';
const expanded = new Set(); // space ids expanded in the tree
function navItem(label, hash, opts = {}) {
const active = current().hash === hash.replace(/^#/, '');
return el('a', {
class: 'sb-item' + (active ? ' active' : ''),
href: '#' + hash.replace(/^#/, ''),
onclick: (e) => {
if (opts.onclick) { e.preventDefault(); opts.onclick(e); return; }
}
},
opts.icon ? el('span', { class: 'caret' }, opts.icon) : null,
el('span', { style: { flex: 1 } }, label),
opts.badge !== undefined && opts.badge !== null ? el('span', { class: 'badge' }, String(opts.badge)) : null
);
}
async function loadProjects(space_id) {
try {
return await api.get(`/api/spaces/${space_id}/projects`);
} catch { return []; }
}
async function renderSpaceTree(container) {
let spaces;
try { spaces = await api.get('/api/spaces'); }
catch { spaces = []; }
clear(container);
if (!spaces.length) {
container.appendChild(el('div', { class: 'sb-item muted' }, 'No spaces yet'));
return;
}
for (const s of spaces) {
const isOpen = expanded.has(s.id);
const childWrap = el('div', { class: 'sb-children' });
const header = el('a', {
class: 'sb-item',
href: '#/space/' + s.id,
onclick: async (e) => {
// Click on caret toggles; click on label navigates.
if (e.target.dataset.role === 'caret') {
e.preventDefault();
if (expanded.has(s.id)) { expanded.delete(s.id); clear(childWrap); }
else {
expanded.add(s.id);
const projects = await loadProjects(s.id);
clear(childWrap);
if (!projects.length) childWrap.appendChild(el('div', { class: 'sb-item muted' }, '(no projects)'));
for (const p of projects) {
childWrap.appendChild(el('a', { class: 'sb-item', href: '#/project/' + p.id }, p.name));
}
}
}
}
},
el('span', { class: 'caret', dataset: { role: 'caret' } }, isOpen ? '▾' : '▸'),
el('span', { style: { flex: 1 } }, s.name)
);
container.appendChild(header);
if (isOpen) {
loadProjects(s.id).then(projects => {
clear(childWrap);
if (!projects.length) childWrap.appendChild(el('div', { class: 'sb-item muted' }, '(no projects)'));
for (const p of projects) {
childWrap.appendChild(el('a', { class: 'sb-item', href: '#/project/' + p.id }, p.name));
}
});
}
container.appendChild(childWrap);
}
}
export function renderSidebar(root) {
const spacesContainer = el('div');
const inboxItem = navItem('Inbox', '/inbox');
mount(root,
el('div', { class: 'sb-section' },
el('div', { class: 'sb-title' }, 'Spaces'),
spacesContainer
),
el('hr'),
el('div', { class: 'sb-section' },
el('div', { class: 'sb-title' }, 'Navigate'),
navItem('Sacred Valley', '/sacred-valley'),
navItem('Search', '/search'),
inboxItem,
el('div', { class: 'sb-item muted', title: 'Ships post-Plan-2' }, 'Agents — later'),
el('div', { class: 'sb-item muted', title: 'Ships post-Plan-2' }, 'Resources — later')
)
);
renderSpaceTree(spacesContainer);
// Pending-count badge wiring
on('pending-count', (n) => {
const old = inboxItem.querySelector('.badge');
if (old) old.remove();
if (n > 0) inboxItem.appendChild(el('span', { class: 'badge' }, String(n)));
});
// Refresh tree on hashchange (active highlight) and on space creation.
window.addEventListener('hashchange', () => renderSpaceTree(spacesContainer));
}