diff --git a/migrate/sources/void1.js b/migrate/sources/void1.js index b443eb8..bf2004e 100644 --- a/migrate/sources/void1.js +++ b/migrate/sources/void1.js @@ -33,6 +33,12 @@ export async function importVoid1({ sqlitePath, spaceId, dryRun = false }) { if (existing) { projIds.set(pr.id, existing); continue; } n.projects++; if (dryRun) continue; const created = await projects.create({ space_id: spaceId, slug: slugify(pr.name), name: pr.name || 'Project', description: pr.description || null, status: pr.status === 'archived' ? 'archived' : 'active' }, SYS); + // Carry Void 1's Eithan research over (research_notes were not in the original mapping). + if (pr.research_notes || pr.last_researched_at) { + await pool.query( + `UPDATE projects SET research_notes=$1, last_researched_at=$2, research_status=$3 WHERE id=$4`, + [pr.research_notes || null, pr.last_researched_at ? new Date(pr.last_researched_at) : null, pr.research_notes ? 'done' : 'none', created.id]); + } projIds.set(pr.id, created.id); await map.record('void1', `projects:${pr.id}`, 'project', created.id); } diff --git a/public/app.js b/public/app.js index 47443ca..6e87beb 100644 --- a/public/app.js +++ b/public/app.js @@ -22,7 +22,7 @@ const VIEWS = { search: () => import('./views/search.js'), inbox: () => import('./views/inbox.js'), 'sacred-valley': () => import('./views/sacred_valley.js'), - sentinel: () => import('./views/sentinel.js'), + yerin: () => import('./views/sentinel.js'), 'little-blue': () => import('./views/little_blue.js'), terminal: () => import('./views/terminal.js'), settings: () => import('./views/settings.js'), diff --git a/public/components/project_card.js b/public/components/project_card.js index 3190a79..bce8ccd 100644 --- a/public/components/project_card.js +++ b/public/components/project_card.js @@ -51,6 +51,24 @@ export function projectCard(p, o) { if (open) { const panel = el('div', { class: 'proj-panel' }); + + // ---- Details ---- + panel.appendChild(el('div', { class: 'proj-section-h' }, 'Details')); + const det = el('div', { class: 'proj-details' }); + det.appendChild(p.description + ? el('div', { class: 'proj-detail-desc' }, p.description) + : el('div', { class: 'muted', style: { fontSize: '12px' } }, 'No description yet — Edit to add one.')); + const dl = el('dl', { class: 'proj-dl' }); + const addDL = (k, v) => { if (v) { dl.appendChild(el('dt', {}, k)); dl.appendChild(el('dd', {}, v)); } }; + addDL('Status', p.status); + addDL('Created', p.created_at ? new Date(p.created_at).toLocaleDateString() : null); + addDL('Updated', p.updated_at ? `${new Date(p.updated_at).toLocaleDateString()} (${ago(p.updated_at)})` : null); + if (p.started_at) addDL('Started', new Date(p.started_at).toLocaleDateString()); + if (p.completed_at) addDL('Completed', new Date(p.completed_at).toLocaleDateString()); + det.appendChild(dl); + panel.appendChild(det); + + // ---- Eithan research ---- panel.appendChild(el('div', { class: 'proj-section-h' }, 'Eithan research' + (p.last_researched_at ? ` · ${ago(p.last_researched_at)}` : ''))); if (busy) panel.appendChild(el('div', { class: 'muted' }, "Queued for Eithan — he'll fill this in once the agent ships.")); else if (p.research_notes) { const n = el('div', { class: 'md-preview' }); n.innerHTML = renderMarkdown(p.research_notes); panel.appendChild(n); } diff --git a/public/components/sidebar.js b/public/components/sidebar.js index 4b06a75..cf93931 100644 --- a/public/components/sidebar.js +++ b/public/components/sidebar.js @@ -90,7 +90,7 @@ export function renderSidebar(root) { ), el('div', { class: 'sb-section' }, el('div', { class: 'sb-title' }, 'Agents'), - navItem('Sentinel', '/sentinel', { dot: 'ok' }), + navItem('Yerin', '/yerin', { dot: 'yerin' }), navItem('Little Blue', '/little-blue', { dot: 'lb' }) ), el('div', { class: 'sb-section' }, diff --git a/public/router.js b/public/router.js index 0379547..edbf73d 100644 --- a/public/router.js +++ b/public/router.js @@ -21,7 +21,7 @@ const ROUTES = [ { name: 'search', re: /^\/search$/, keys: [] }, { name: 'inbox', re: /^\/inbox$/, keys: [] }, { name: 'sacred-valley', re: /^\/sacred-valley$/, keys: [] }, - { name: 'sentinel', re: /^\/sentinel$/, keys: [] }, + { name: 'yerin', re: /^\/(yerin|sentinel)$/, keys: [] }, { name: 'little-blue', re: /^\/little-blue$/, keys: [] }, { name: 'terminal', re: /^\/terminal$/, keys: [] }, { name: 'settings', re: /^\/settings$/, keys: [] }, diff --git a/public/style.css b/public/style.css index d397934..9ed5148 100644 --- a/public/style.css +++ b/public/style.css @@ -284,6 +284,35 @@ button.ghost:hover { color: var(--text); border-color: var(--accent-dim); } .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; } +/* Project cards — compact + responsive + details panel */ +.proj-head { padding: 7px 10px; gap: 8px; } +.proj-title { font-size: 14px; } +.proj-desc { font-size: 12px; } +.proj-status { font-size: 11px; padding: 2px 6px; } +.proj-btn { font-size: 11px; padding: 3px 8px; } +.proj-actions { flex-wrap: wrap; justify-content: flex-end; } +.proj-panel { padding: 2px 14px 12px 32px; } +.proj-details { margin-bottom: 4px; } +.proj-detail-desc { font-size: 13px; color: var(--text); line-height: 1.55; margin-bottom: 8px; } +.proj-dl { display: grid; grid-template-columns: max-content 1fr; gap: 3px 14px; margin: 0; font-size: 12px; } +.proj-dl dt { color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-size: 10px; align-self: center; } +.proj-dl dd { margin: 0; color: var(--text); } +.proj-panel .research-notes, .proj-panel .md-preview { font-size: 13px; } +@media (max-width: 620px) { + .proj-head { flex-wrap: wrap; } + .proj-actions { width: 100%; margin-top: 4px; } + .proj-panel { padding-left: 14px; } +} + +/* Yerin — classic red (Sage of the Endless Sword) */ +:root { --yerin: #d23b3b; } +.sb-dot.yerin { background: var(--yerin); box-shadow: 0 0 6px var(--yerin); } +.sentinel-chat { display: flex; flex-direction: column; height: 70vh; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); overflow: hidden; } +.sentinel-chat .sentinel-log { flex: 1; overflow-y: auto; padding: 14px; } +.yerin-view .view-h1 { color: var(--yerin); } +.yerin-view .view-sub { color: var(--yerin); opacity: .85; font-style: italic; } +.yerin-view .sentinel-chat { border-color: var(--yerin); box-shadow: inset 0 2px 0 rgba(210, 59, 59, .25); } + /* 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 { diff --git a/public/views/sentinel.js b/public/views/sentinel.js index d831332..af039a7 100644 --- a/public/views/sentinel.js +++ b/public/views/sentinel.js @@ -12,10 +12,10 @@ const YERIN_LABELS = { export async function render(main) { const log = el('div', { class: 'rail-log sentinel-log' }); const input = el('textarea', { class: 'rail-input', rows: 1, placeholder: 'Ask Yerin about the Void’s security…' }); - mount(main, - el('h1', { class: 'view-h1' }, '◆ Sentinel — Yerin'), - el('p', { class: 'view-sub' }, 'Read-only security & observability. She watches, reports, and warns — she never acts.'), - el('div', { class: 'sentinel-chat' }, log, el('div', { class: 'rail-inputwrap' }, input))); + mount(main, el('div', { class: 'yerin-view' }, + el('h1', { class: 'view-h1' }, '◆ Yerin'), + el('p', { class: 'view-sub' }, 'Sage of the Endless Sword — read-only security & observability. She watches, reports, and warns; she never acts.'), + el('div', { class: 'sentinel-chat' }, log, el('div', { class: 'rail-inputwrap' }, input)))); const chat = wireAgentChat({ logEl: log, inputEl: input, historyUrl: '/api/security/yerin', turnUrl: '/api/security/yerin/turn',