diff --git a/.env.example b/.env.example index aeb76a3..a2b6921 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,11 @@ DATA_DIR=./data # --- LLM provider (optional; unset = no-AI, enrichment disabled) --- # CLI agent id for the subprocess provider (claude / codex / cursor-agent). # LLM_AGENT=claude -# Model name (HttpProvider or CLI). +# Model name (HttpProvider or CLI). Unset with LLM_AGENT=claude means Sonnet: the +# CLI would otherwise inherit whatever your INTERACTIVE default is (often Opus), +# which is slow and expensive for what is a fixed-schema extraction. Set this to +# override — e.g. `opus` for a richer read, `haiku` for a cheaper one. codex is +# left unpinned and uses its own default. # LLM_MODEL= # OpenAI-compatible base URL (HttpProvider). # LLM_BASE_URL=https://api.openai.com/v1 diff --git a/README.md b/README.md index 7719161..0ce466d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ the full annotated list). Empty/whitespace values are treated as unset. | `HOST` | `127.0.0.1` | Bind address. **Localhost-only by default** (see Security). | | `DATA_DIR` | `./data` | Persistent data root (SQLite DB + screenshots). | | `CHROME_PATH` | autodetect | System Chromium/Chrome binary; autodetected on Linux when unset. | -| `LLM_AGENT` / `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` | unset | LLM provider. **Unset = no-AI** (enrichment disabled). | +| `LLM_AGENT` / `LLM_MODEL` / `LLM_BASE_URL` / `LLM_API_KEY` | unset | LLM provider. **Unset = no-AI** (enrichment disabled). With `LLM_AGENT=claude` and no `LLM_MODEL`, Board asks the CLI for **Sonnet** rather than inheriting your interactive default. | | `CAPTURE_TIMEOUT_MS` | `180000` | Budget for one capture job — page capture **and** the AI read share it. A CLI agent routinely takes 90–100s; raise it for slow local models. | | `BOARD_API_TOKEN` | unset | Bearer token for the `/api/v1` capture API. **Unset = the v1 API is off** (fail-closed). See [Integrations](docs/integrations.md). | diff --git a/public/index.html b/public/index.html index 84e7d15..a006f00 100644 --- a/public/index.html +++ b/public/index.html @@ -2,7 +2,7 @@ - + Board +
@@ -1638,6 +2174,18 @@
+
+ + + +
@@ -1713,6 +2261,45 @@
+ + + + +
+ +
+
@@ -1769,7 +2356,7 @@ // null = unknown until /api/meta resolves; then boolean. Drives the add button label // ("Add" vs "Add with ") and the caret menu ("+ Add LLM"). let providerConfigured = null; - // The configured provider's human label from /api/meta (e.g. "Claude Code"), or null. + // The configured provider's human label from /api/meta (e.g. "Claude"), or null. let providerLabel = null; let collections = []; let activeCollection = 'inspiration'; @@ -1778,7 +2365,7 @@ let currentLibraryItem = null; const TIER_LABELS = { structural: 'Structural', polish: 'Polish', reference: 'Reference' }; - const ANALYSIS_AGENT_LABELS = { claude: 'Claude Code', codex: 'Codex' }; + const ANALYSIS_AGENT_LABELS = { claude: 'Claude', codex: 'Codex' }; const DESIGN_FIELDS = [ ['above_fold', 'Above the Fold'], ['nav_pattern', 'Navigation'], @@ -2040,6 +2627,243 @@ } catch { /* SSE unavailable — poll fallback (manual load) remains */ } } + // ══ Phone / vertical-tablet chrome (<=900px) ═══════════════════════════════════ + // The drawer HOSTS the desktop's own control nodes rather than copying them: at the + // breakpoint the real #collection-switcher, .filters, .header-right and tag clouds + // are appended into drawer slots, and appended back to their original parents above + // it. Every listener bound in this file keeps working, because listeners survive + // appendChild. Copying instead would mean duplicate ids and two sets of state. + + const MOBILE_MQ = window.matchMedia('(max-width: 900px)'); + const relocations = []; + let chromeIsMobile = null; + + function rememberHome(node) { + if (!node || relocations.some(r => r.node === node)) return; + relocations.push({ node, parent: node.parentNode, next: node.nextSibling }); + } + + /** Move the chrome into the drawer (mobile) or back where it came from (desktop). */ + function syncChrome() { + const mobile = MOBILE_MQ.matches; + if (mobile === chromeIsMobile) return; + chromeIsMobile = mobile; + + const moves = [ + ['collection-switcher', 'drawer-boards'], + ['tag-cloud', 'drawer-tags'], + ['library-topic-cloud', 'drawer-tags'], + ]; + const filtersEl = document.querySelector('.filters'); + const headerRight = document.querySelector('.header-right'); + const addBar = document.querySelector('.add-bar'); + + if (mobile) { + for (const [id, slot] of moves) { + const node = document.getElementById(id); + if (node) { rememberHome(node); document.getElementById(slot).appendChild(node); } + } + if (filtersEl) { rememberHome(filtersEl); document.getElementById('drawer-filters').appendChild(filtersEl); } + if (headerRight) { rememberHome(headerRight); document.getElementById('drawer-view').appendChild(headerRight); } + // The add bar docks beside the FAB rather than staying in the header. + if (addBar) { rememberHome(addBar); document.getElementById('add-dock').prepend(addBar); } + } else { + closeDrawer(true); + closeAddDock(); + setHeaderMode('title'); + for (const r of relocations) { + if (r.next && r.next.parentNode === r.parent) r.parent.insertBefore(r.node, r.next); + else r.parent.appendChild(r.node); + } + relocations.length = 0; + } + // The two modes order the switcher differently (see renderSwitcher), so rebuild it. + if (collections.length) renderSwitcher(); + updateDrawerChrome(); + } + + /** Keep the drawer's labels, counts and the filters-active dot honest. */ + function updateDrawerChrome() { + const col = collections.find(c => c.id === activeCollection); + const pill = document.getElementById('board-pill-name'); + if (pill) pill.textContent = col ? col.name : 'Board'; + const fh = document.getElementById('drawer-filters-head'); + if (fh) fh.textContent = col ? `Filter ${col.name}` : 'Filters'; + const count = document.getElementById('drawer-count'); + if (count) count.textContent = String(filtered.length); + + // A drawer can hide active filters, so the ☰ carries a mark when any are on — and + // the accessible name says so too, because a dot is not available to a screen reader. + const active = activeFilterCount(); + const dot = document.getElementById('filter-dot'); + const btn = document.getElementById('drawer-btn'); + if (dot) dot.hidden = active === 0; + if (btn) btn.setAttribute('aria-label', active ? `Boards and filters, ${active} active` : 'Boards and filters'); + } + + function activeFilterCount() { + let n = 0; + ['audience-filter', 'form-filter', 'domain-filter', 'library-type-filter'].forEach(id => { + const el = document.getElementById(id); + if (el && el.value) n++; + }); + n += activeTiers.size; + if (showFavoritesOnly) n++; + if (activeTag) n++; + if (libraryTopicFilter) n++; + n += Object.values(descriptorFacetState).filter(Boolean).length; + return n; + } + + // --- Header modes: title (rest) / search / add. One row, three jobs. --- + function setHeaderMode(mode) { + const header = document.querySelector('header'); + header.dataset.mode = mode; + if (mode === 'search') document.getElementById('search').focus(); + } + + // --- The add dock: + morphs to ✓ and the URL field slides out beside it. --- + function addDockIsOpen() { return document.body.classList.contains('add-open'); } + + function openAddDock() { + document.body.classList.add('add-open'); + const fab = document.getElementById('add-fab'); + fab.setAttribute('aria-expanded', 'true'); + fab.setAttribute('aria-label', 'Add this URL'); + // Focus SYNCHRONOUSLY, inside the same task as the tap. iOS Safari only opens the + // keyboard for a focus() that happens within the user gesture, so deferring this to + // rAF or a timeout would slide the field out and leave no keyboard. Reading + // offsetWidth first forces the style flush, so the field is already visible (and + // therefore focusable) by the time focus() runs. + const input = document.getElementById('add-input'); + void input.offsetWidth; + input.focus(); + } + + function closeAddDock() { + if (!addDockIsOpen()) return; + document.body.classList.remove('add-open'); + const fab = document.getElementById('add-fab'); + fab.setAttribute('aria-expanded', 'false'); + fab.setAttribute('aria-label', 'Add a URL'); + document.getElementById('add-input').blur(); + } + + /** + * Closed → open the field. Open → submit, by clicking the SAME #add-btn the desktop + * uses, so there is one add path and no second copy of the submit logic. An empty + * field just closes: the ✓ should never look like it failed. + */ + function toggleAddDock() { + if (!addDockIsOpen()) { openAddDock(); return; } + const input = document.getElementById('add-input'); + if (!input.value.trim()) { closeAddDock(); return; } + document.getElementById('add-btn').click(); + closeAddDock(); + } + + // --- Drawer open/close, with a focus trap and the background scroll locked. --- + let drawerLastFocused = null; + let drawerHideTimer = null; + const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; + + function drawerIsOpen() { + const d = document.getElementById('drawer'); + return d && !d.hidden; + } + + function openDrawer() { + const drawer = document.getElementById('drawer'); + const scrim = document.getElementById('scrim'); + if (!drawer || drawerIsOpen()) return; + drawerLastFocused = document.activeElement; + // A close scheduled moments ago would otherwise fire mid-open and hide the drawer + // we are opening right now (rapid switch → reopen). + if (drawerHideTimer) { clearTimeout(drawerHideTimer); drawerHideTimer = null; } + drawer.hidden = false; + scrim.hidden = false; + updateDrawerChrome(); + // Next frame, so the transform transition has a start value to animate FROM. + requestAnimationFrame(() => { drawer.classList.add('open'); scrim.classList.add('open'); }); + document.body.style.overflow = 'hidden'; + document.body.classList.add('drawer-open'); + document.getElementById('drawer-btn')?.setAttribute('aria-expanded', 'true'); + document.getElementById('board-pill')?.setAttribute('aria-expanded', 'true'); + (drawer.querySelector(FOCUSABLE) || drawer).focus(); + } + + function closeDrawer(immediate = false) { + const drawer = document.getElementById('drawer'); + const scrim = document.getElementById('scrim'); + if (!drawer || !drawerIsOpen()) return; + drawer.classList.remove('open'); + scrim.classList.remove('open'); + document.body.style.overflow = ''; + document.body.classList.remove('drawer-open'); + document.getElementById('drawer-btn')?.setAttribute('aria-expanded', 'false'); + document.getElementById('board-pill')?.setAttribute('aria-expanded', 'false'); + const finish = () => { drawer.hidden = true; scrim.hidden = true; drawerHideTimer = null; }; + if (drawerHideTimer) clearTimeout(drawerHideTimer); + if (immediate) { drawerHideTimer = null; finish(); } + else drawerHideTimer = setTimeout(finish, 220); + if (drawerLastFocused && typeof drawerLastFocused.focus === 'function') drawerLastFocused.focus(); + drawerLastFocused = null; + } + + function wireMobileChrome() { + document.getElementById('drawer-btn').addEventListener('click', () => drawerIsOpen() ? closeDrawer() : openDrawer()); + document.getElementById('board-pill').addEventListener('click', () => drawerIsOpen() ? closeDrawer() : openDrawer()); + document.getElementById('scrim').addEventListener('click', () => closeDrawer()); + document.getElementById('drawer-apply').addEventListener('click', () => closeDrawer()); + document.getElementById('drawer-clear').addEventListener('click', () => { + document.getElementById('clear-filters-btn')?.click(); + updateDrawerChrome(); + }); + document.getElementById('drawer-data').addEventListener('click', () => { + closeDrawer(); + document.querySelector('.data-fab')?.click(); + }); + document.getElementById('search-btn').addEventListener('click', () => setHeaderMode('search')); + document.getElementById('add-fab').addEventListener('click', toggleAddDock); + document.getElementById('hdr-back').addEventListener('click', () => setHeaderMode('title')); + // Enter in the docked field submits, same as the desktop add bar. + document.getElementById('add-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && addDockIsOpen()) { e.preventDefault(); toggleAddDock(); } + }); + // A tap on the board dismisses the dock — but never a tap inside it. + document.addEventListener('pointerdown', (e) => { + if (addDockIsOpen() && !e.target.closest('#add-dock')) closeAddDock(); + }); + + // Esc closes the drawer first, then leaves search/add mode. The modal's own Esc + // handler stays untouched — a drawer is never open behind a modal. + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + if (drawerIsOpen()) { closeDrawer(); return; } + if (addDockIsOpen()) { closeAddDock(); return; } + const header = document.querySelector('header'); + if (header.dataset.mode && header.dataset.mode !== 'title') setHeaderMode('title'); + return; + } + if (e.key !== 'Tab' || !drawerIsOpen()) return; + const drawer = document.getElementById('drawer'); + const items = [...drawer.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null); + if (!items.length) return; + const first = items[0], last = items[items.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + }); + + // A board switch is navigation: it answers what the drawer was opened for. + document.getElementById('drawer-boards').addEventListener('click', (e) => { + if (e.target.closest('.coll-btn[data-cid]')) closeDrawer(); + }); + + setHeaderMode('title'); + MOBILE_MQ.addEventListener('change', syncChrome); + syncChrome(); + } + function renderSwitcher() { let sw = document.getElementById('collection-switcher'); if (!sw) { @@ -2051,18 +2875,42 @@ } // Edit (⚙) only makes sense with an active board; the New-board (+) affordance is // ALWAYS present so a board can be created even from the empty/degraded states. - const hasActive = !!collections.find(c => c.id === activeCollection); - sw.innerHTML = collections.map(c => + const activeIndex = collections.findIndex(c => c.id === activeCollection); + const boardBtns = collections.map(c => `` - ).join('') - + (hasActive ? `` : '') - + ``; + ); + const editBtnHtml = activeIndex >= 0 + ? `` + : ''; + const newBtnHtml = ``; + if (chromeIsMobile) { + // In the drawer the boards are a vertical list, so "Edit THIS board" needs a + // referent: it sits directly under the active row, indented off an elbow, and + // reads as a child of the board it edits. Desktop keeps the flat trailing order. + const parts = [...boardBtns]; + if (editBtnHtml) parts.splice(activeIndex + 1, 0, editBtnHtml); + sw.innerHTML = parts.join('') + newBtnHtml; + } else { + sw.innerHTML = `
${boardBtns.join('')}
` + editBtnHtml + newBtnHtml; + } sw.querySelectorAll('.coll-btn[data-cid]').forEach(b => b.addEventListener('click', () => setActiveCollection(b.dataset.cid)) ); document.getElementById('new-board-btn').addEventListener('click', openComposeModal); const editBtn = document.getElementById('edit-board-btn'); if (editBtn) editBtn.addEventListener('click', () => openEditBoardModal(activeCollection)); + if (!chromeIsMobile) { + // A crowded header can leave the active board scrolled out of sight; bring it back. + sw.querySelector('.coll-scroll .coll-btn.active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + // If the switcher was created after syncChrome ran, it lands in the header. Re-home + // it so a first paint on a phone doesn't leave the board list stranded up there. + const boardsSlot = document.getElementById('drawer-boards'); + if (chromeIsMobile && boardsSlot && sw.parentNode !== boardsSlot) { + rememberHome(sw); + boardsSlot.appendChild(sw); + } + updateDrawerChrome(); } // --- Story 10.1/10.3 + Edit: New board (compose→preview→create) & Edit board (rename/delete) --- @@ -2075,16 +2923,47 @@ function showModalContent(html) { modalLastFocused = document.activeElement; document.getElementById('modal-content').innerHTML = html; - document.getElementById('modal-overlay').classList.add('open'); + openModalOverlay(); } - function fieldsPreviewHtml(descriptor) { + const FIELD_TYPE_ORDER = ['url', 'image', 'text', 'tags', 'enum', 'number', 'date']; + + /** One field: its name, its type, and (for an enum) the vocabulary it would accept. */ + function proposalFieldHtml(f) { + const values = f.type === 'enum' && (f.values || []).length + ? `
${esc(f.values.join(' · '))}
` + : ''; + return `
` + + `
${esc(f.label || f.key)}` + + `${esc(f.type)}
${values}
`; + } + + /** + * The fields, split by WHO fills them. That is the one distinction that decides + * whether a proposed board will actually work for you. Ordered by type so like sits + * with like. A
: a field name described by its type is exactly that, and it + * keeps reading order identical to visual order. + */ + function proposalFieldsHtml(descriptor) { const fields = (descriptor && descriptor.fields) || []; - return fields.map(f => - `
${esc(f.type)} ${esc(f.label)} ${esc(f.key)}${f.enrichable === false ? ' (yours)' : ''}
` - ).join('') || '
No fields
'; + if (!fields.length) return '
No fields yet. Add them after you create the board.
'; + const bySlot = (a, b) => FIELD_TYPE_ORDER.indexOf(a.type) - FIELD_TYPE_ORDER.indexOf(b.type); + const ai = fields.filter(f => f.enrichable !== false).sort(bySlot); + const mine = fields.filter(f => f.enrichable === false).sort(bySlot); + const group = (head, note, list) => list.length + ? `
${head} ${list.length} · ${note}
` + + `
${list.map(proposalFieldHtml).join('')}
` + : ''; + return group('Board fills these', 'from each capture', ai) + + group('You fill these', 'your own read', mine); } + const INGEST_LABEL = { + 'url-screenshot': 'screenshots the page', + 'url-readable': 'reads the article text', + 'manual-upload': 'takes files you upload', + }; + function openComposeModal() { showModalContent(` `); document.getElementById('modal-cancel').onclick = closeModal; document.getElementById('compose-go').onclick = runCompose; + composedProposal = null; // a fresh modal must not offer to create the LAST proposal + } + + /** + * Set the composer's status line. `.save-status` is opacity:0 until `.visible` is + * added — without it every message here is invisible, which is exactly how a 90s + * compose came to look like a dead modal. + */ + function setComposeStatus(text, tone) { + const el = document.getElementById('compose-status'); + if (!el) return; + el.textContent = text || ''; + el.classList.toggle('visible', !!text); + el.style.color = tone === 'error' ? 'var(--danger, #ef4444)' : ''; + } + + // Honest phase copy: what the composer is actually doing, in order. Nothing here + // knows how far along the model is, so there is no progress bar and no percentage — + // the elapsed clock is the only real number on screen. + const COMPOSE_PHASES = [ + 'Reading your description', + 'Choosing the fields worth keeping', + 'Picking how this board captures and reads', + 'Checking the proposal holds together', + ]; + const COMPOSE_PHASE_MS = 12_000; + let composeTicker = null; + + /** The waiting state: a live clock, the current phase, and a ghost of the board. */ + function startComposeWorking() { + const host = document.getElementById('compose-preview'); + host.innerHTML = ` +
+
+ + ${esc(COMPOSE_PHASES[0])} + 0s +
+
Usually under a minute. Nothing is saved until you accept.
+
+ ${ghostBar('42%', 0)}${ghostBar('88%', 90)}${ghostBar('66%', 180)}${ghostBar('78%', 270)}${ghostBar('50%', 360)} +
+
`; + const started = Date.now(); + stopComposeWorking(false); + composeTicker = setInterval(() => { + const secs = Math.floor((Date.now() - started) / 1000); + const elapsed = document.getElementById('compose-elapsed'); + const phase = document.getElementById('compose-phase'); + if (!elapsed || !phase) return stopComposeWorking(false); + elapsed.textContent = secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s`; + // Hold on the last phase rather than looping — a spinner that starts over reads + // as "stuck", and by then the only honest statement is "still working". + const i = Math.min(Math.floor((Date.now() - started) / COMPOSE_PHASE_MS), COMPOSE_PHASES.length - 1); + phase.textContent = COMPOSE_PHASES[i]; + }, 1000); + } + + function stopComposeWorking(clear = true) { + if (composeTicker) { clearInterval(composeTicker); composeTicker = null; } + if (clear) { + const host = document.getElementById('compose-preview'); + if (host) host.innerHTML = ''; + } } let composedProposal = null; async function runCompose() { const desc = document.getElementById('compose-desc').value.trim(); - const status = document.getElementById('compose-status'); const goBtn = document.getElementById('compose-go'); - if (!desc) { status.textContent = 'Describe the board first'; return; } - goBtn.disabled = true; status.textContent = 'Composing…'; + if (!desc) { setComposeStatus('Describe the board first', 'error'); return; } + goBtn.disabled = true; + goBtn.textContent = 'Composing…'; + setComposeStatus(''); + startComposeWorking(); try { const res = await fetch('/skills/compose-board', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: desc }), }); const data = await res.json(); - if (!res.ok) { status.textContent = data.error || 'Compose failed'; return; } + stopComposeWorking(); + if (!res.ok) { setComposeStatus(data.error || 'Compose failed', 'error'); return; } composedProposal = data; // { status, name, descriptor } - status.textContent = data.status === 'draft' ? 'Draft (review/edit below)' : 'Proposed'; - document.getElementById('compose-preview').innerHTML = ` -
-
Board name
- -
Fields (${(data.descriptor && data.descriptor.fields || []).length}) · ${esc(data.descriptor && data.descriptor.view || '')} view
- ${fieldsPreviewHtml(data.descriptor)} + renderProposal(data); + } catch { + stopComposeWorking(); + setComposeStatus('Network error', 'error'); + } finally { + goBtn.disabled = false; + goBtn.textContent = composedProposal ? 'Compose again' : 'Compose'; + goBtn.className = composedProposal ? 'btn btn-ghost' : 'btn btn-primary'; + } + } + + /** The proposal, as a board you can read at a glance rather than a list of rows. */ + function renderProposal(data) { + const d = data.descriptor || {}; + const fields = d.fields || []; + const draftNote = data.status === 'draft' + ? `
This one needs your hand. ${esc((data.errors || [])[0]?.message || 'The proposal did not validate.')} Create it anyway and shape the fields in the board editor.
` + : ''; + document.getElementById('compose-preview').innerHTML = ` +
+
Board name
+ +
+ ${esc(d.view || 'grid')} view + ${esc(INGEST_LABEL[d.ingest_mode] || d.ingest_mode || 'captures pages')}
- `; - document.getElementById('compose-create').onclick = createComposedBoard; - } catch { status.textContent = 'Network error'; } - finally { goBtn.disabled = false; } + ${d.enrichment_prompt ? `
The lens
${esc(d.enrichment_prompt)}
` : ''} + ${proposalFieldsHtml(d)} + ${draftNote} +
+ `; + document.getElementById('compose-create').onclick = createComposedBoard; } async function createComposedBoard() { if (!composedProposal) return; const name = (document.getElementById('compose-name').value || composedProposal.name || '').trim(); - const status = document.getElementById('compose-status'); const btn = document.getElementById('compose-create'); - if (!name) { status.textContent = 'Name is required'; return; } - btn.disabled = true; status.textContent = 'Creating…'; + if (!name) { setComposeStatus('Name is required', 'error'); return; } + btn.disabled = true; + btn.textContent = 'Creating…'; + setComposeStatus('Creating the board…'); try { const id = slugifyBoardId(name); const res = await fetch('/skills/create-board', { @@ -2145,11 +3112,21 @@ body: JSON.stringify({ id, name, descriptor: composedProposal.descriptor }), }); const data = await res.json(); - if (!res.ok) { status.textContent = data.error || 'Create failed'; btn.disabled = false; return; } + if (!res.ok) { + setComposeStatus(data.error || 'Create failed', 'error'); + btn.disabled = false; btn.textContent = 'Create board'; + return; + } closeModal(); + // Point the app at the new board BEFORE load(), so load() resolves it as active + // and doesn't subscribe to the outgoing board's status stream on the way past. + localStorage.setItem('board.activeCollection', id); await load(); await setActiveCollection(id); - } catch { status.textContent = 'Network error'; btn.disabled = false; } + } catch { + setComposeStatus('Network error', 'error'); + btn.disabled = false; btn.textContent = 'Create board'; + } } const BOARD_FIELD_TYPES = ['text', 'number', 'date', 'url', 'enum', 'tags', 'image']; @@ -2343,6 +3320,11 @@ async function setActiveCollection(cid) { activeCollection = cid; localStorage.setItem('board.activeCollection', cid); + // The status stream is BOARD-SCOPED on the server (/events?boardId=…), so a switch + // must re-subscribe or the new board receives nothing and its cards stay stuck in + // their skeleton until a manual refresh. Before the items fetch, so a transition + // that fires while they load isn't missed; subscribeToStatus is idempotent per board. + subscribeToStatus(); const helpers = window.collectionHelpers; const activeCol = collections.find(c => c.id === cid); @@ -2539,7 +3521,7 @@ const inFlight = (b) => window.collectionHelpers.isInFlight(b); const activeColObj = collections.find(c => c.id === activeCollection); - const isCustomBoard = !!activeColObj && !['inspiration', 'library', 'inbox'].includes(activeColObj.id); + const isCustomBoard = isComposedBoard(activeColObj); if (isCustomBoard) { // Composed board: filter via the descriptor-driven predicate (Story 8.2's @@ -2593,21 +3575,30 @@ } const sort = document.getElementById('sort-select').value; - const order = new Map(bookmarks.map((b, i) => [b.id, i])); - filtered.sort((a, b) => { - const cmp = String(a.added).localeCompare(String(b.added)); - if (cmp !== 0) return sort === 'oldest' ? cmp : -cmp; - const ai = order.get(a.id), bi = order.get(b.id); - return sort === 'oldest' ? ai - bi : bi - ai; - }); + // `filtered` preserves `bookmarks` order (created_at DESC), which is what the + // same-day tie-break reads as recency. See collections-ui.sortItems. + filtered = window.collectionHelpers.sortItems(filtered, sort); const noun = activeCollection === 'inspiration' ? 'site' : 'item'; document.getElementById('count').textContent = `${filtered.length} ${noun}${filtered.length !== 1 ? 's' : ''}`; + updateDrawerChrome(); render(); } function render() { + // Drives the full-width-grid vs centred-list rule above. + document.querySelector('main').dataset.view = activeView; const activeCol = collections.find(c => c.id === activeCollection); + // A COMPOSED board renders from its own descriptor. It can't use either seeded + // renderer: /api/collections derives type "inspiration" for any grid board, which + // sent composed boards into renderGrid() — hardcoded to meta.tags/design.steal_this + // — so their tiles came out as a title and nothing else. The seeded boards keep + // their bespoke renderers untouched (NFR-BC). + if (activeCol && isComposedBoard(activeCol)) { + if (activeView === 'grid') renderDescriptorGrid(activeCol); + else renderDescriptorList(activeCol); + return; + } if (activeCol && activeCol.type !== 'inspiration') { // Non-visual boards (e.g. Library) honor the grid/list toggle too — the grid is // just screenshot-less (the board's descriptor stores no images). @@ -2619,6 +3610,15 @@ else renderList(); } + // The seeded boards (bespoke renderers + fixed chrome) vs everything the composer + // made. Used by BOTH applyFilters (descriptor-driven predicate) and render + // (descriptor-driven card) so filtering and drawing can never disagree about which + // kind of board this is. + const SEEDED_BOARD_IDS = ['inspiration', 'library', 'inbox']; + function isComposedBoard(col) { + return !!col && !SEEDED_BOARD_IDS.includes(col.id); + } + const HEART_SVG = ``; // --- Capture lifecycle rendering --- @@ -2865,6 +3865,126 @@ }); } + // --- Composed-board rendering (descriptor-driven) --------------------------------- + // ONE pair of renderers for EVERY board the composer makes, with no per-board code: + // collectionHelpers.cardSummary picks the three card slots (category badge, lead + // line, chips) out of whatever fields that board declared. The tile chrome, the + // skeleton and the failure state are the SAME helpers the seeded boards use, so a + // composed board never looks like a second-class citizen of the app. + + /** Does this board carry images? url-readable boards are text-first (og:image only). */ + function boardShowsImages(col) { + return (col?.descriptor?.ingest_mode ?? 'url-screenshot') !== 'url-readable'; + } + + function summaryOf(b, col) { + const ch = window.collectionHelpers; + return ch?.cardSummary ? ch.cardSummary(b, col?.descriptor) : { badge: null, lead: null, tags: [] }; + } + + /** + * The filled (state === 'ready') body of a composed card: lead line + chip row. + * Both class names are passed in because the two shells lay their body out + * differently — .lib-card is a NAMED-AREA grid, so its chips must carry + * `lib-topics` or they auto-place into an implicit cell and land in the wrong slot. + */ + function descriptorCardBody(b, col, leadClass, tagsClass) { + const s = summaryOf(b, col); + const lead = s.lead ? `
${esc(s.lead.value)}
` : ''; + const tags = s.tags.length + ? `
${s.tags.map(t => `${esc(t)}`).join('')}
` + : ''; + return lead + tags; + } + + function renderDescriptorGrid(col) { + const gridEl = document.getElementById('grid-view'); + const listEl = document.getElementById('list-view'); + listEl.style.display = 'none'; + gridEl.style.display = ''; + if (!filtered.length) { gridEl.innerHTML = emptyState(bookmarks.length > 0); return; } + const withImages = boardShowsImages(col); + gridEl.innerHTML = filtered.map(b => { + const state = stateOf(b); + const inFlight = state === 'capturing' || state === 'reading'; + const badge = state === 'ready' + ? (summaryOf(b, col).badge ? `${esc(summaryOf(b, col).badge.value)}` : '') + : inFlight ? `` : ''; + // Same progressive reveal as Inspiration: the screenshot and title are whatever + // capture already produced, and only the AI fields stay ghosted. + const image = b.screenshot + ? `${esc(b.title || '')}` + : inFlight && withImages + ? `
` + : withImages ? `
No image
` : ''; + const title = b.title + ? `
${esc(b.title)}
` + : `
${ghostBar('72%')}
`; + const body = state === 'ready' + ? descriptorCardBody(b, col, 'card-lead', 'card-tags') + : `${stateNote(b, state)}${inFlight ? ghostBody() : ''}`; + return ` +
+ ${image} + + +
+
${title}${badge}
+ ${body} +
+
`; + }).join(''); + wireDescriptorCards(gridEl, '.grid-card'); + } + + function renderDescriptorList(col) { + const gridEl = document.getElementById('grid-view'); + const listEl = document.getElementById('list-view'); + gridEl.style.display = 'none'; + listEl.style.display = 'block'; + if (!filtered.length) { listEl.innerHTML = emptyState(bookmarks.length > 0); return; } + listEl.innerHTML = filtered.map(b => { + const state = stateOf(b); + const inFlight = state === 'capturing' || state === 'reading'; + const badge = state === 'ready' && summaryOf(b, col).badge + ? `${esc(summaryOf(b, col).badge.value)}` : ''; + const body = state === 'ready' + ? descriptorCardBody(b, col, 'lib-summary', 'lib-topics') + : `${stateNote(b, state)}${inFlight ? `
${ghostBar('100%')}${ghostBar('88%', 90)}${ghostBar('42%', 180)}
` : ''}`; + return ` + `; + }).join(''); + wireDescriptorCards(listEl, '.lib-card'); + } + + /** Shared wiring for both composed renderers: open the generic modal, fav, ⋯ menu. */ + function wireDescriptorCards(root, cardSel) { + bindRetryButtons(root); + root.querySelectorAll(cardSel).forEach(card => { + card.addEventListener('click', (e) => { + if (!e.target.closest('.fav-btn') && !e.target.closest('.more-btn') && !e.target.closest('.more-btn-list')) { + openModal(card.dataset.id); + } + }); + }); + root.querySelectorAll('.fav-btn').forEach(btn => { + btn.addEventListener('click', (e) => { e.stopPropagation(); openFavPopover(btn.dataset.id, btn); }); + }); + root.querySelectorAll('.more-btn, .more-btn-list').forEach(btn => { + btn.addEventListener('click', (e) => { e.stopPropagation(); openCtxMenu(btn.dataset.id, btn); }); + }); + } + function renderLibraryTopicCloud() { const cloud = document.getElementById('library-topic-cloud'); if (!cloud || !window.collectionHelpers?.topicCounts) return; @@ -3012,7 +4132,7 @@ content.querySelectorAll('.modal-panel').forEach(p => p.classList.toggle('active', p.dataset.panel === target)); }); }); - document.getElementById('modal-overlay').classList.add('open'); + openModalOverlay(); } function collectItemPatch() { @@ -3115,7 +4235,7 @@ `; document.getElementById('modal-cancel').onclick = closeModal; document.getElementById('lib-notes-save').onclick = saveLibraryNotes; - document.getElementById('modal-overlay').classList.add('open'); + openModalOverlay(); } async function saveLibraryNotes() { @@ -3333,7 +4453,7 @@ }); }); - document.getElementById('modal-overlay').classList.add('open'); + openModalOverlay(); } async function saveReflection() { @@ -3367,8 +4487,28 @@ } } + /** + * Open the shared modal overlay and LOCK the page behind it. Without the lock, a + * touch drag over a modal scrolls the board underneath and the modal drifts out of + * reach. The overlay itself keeps `overflow-y: auto`, so a modal taller than the + * viewport still scrolls — that is the one scroll that stays live. + */ + function openModalOverlay() { + // A modal supersedes both transient surfaces. "New board" and "Edit this board" + // are reached FROM the drawer, so without this the modal stacks on top of an open + // drawer and closing it drops you back into a menu you are done with. + closeAddDock(); + closeDrawer(true); + document.getElementById('modal-overlay').classList.add('open'); + document.body.classList.add('modal-open'); + document.body.style.overflow = 'hidden'; + } + function closeModal() { + stopComposeWorking(false); // the composer's elapsed clock must not tick on forever document.getElementById('modal-overlay').classList.remove('open'); + document.body.classList.remove('modal-open'); + if (!drawerIsOpen()) document.body.style.overflow = ''; document.getElementById('modal').classList.remove('modal--welcome'); currentBookmark = null; currentLibraryItem = null; @@ -4125,13 +5265,14 @@

The analysis lens

// load() is called by the module script below after collectionHelpers are initialized diff --git a/src/add.test.ts b/src/add.test.ts index 616739b..16a61e8 100644 --- a/src/add.test.ts +++ b/src/add.test.ts @@ -60,8 +60,11 @@ test("validateAnalysis rejects invalid taxonomy and array sizes", () => { ); }); -test("resolveAnalysisAgent defaults to Claude Code", () => { - assert.deepEqual(resolveAnalysisAgent(undefined, {}), { id: "claude", model: null }); +test("resolveAnalysisAgent defaults to Claude Code on Sonnet", () => { + // No --model means `claude -p` inherits the operator's interactive default (Opus on a + // subscription box) for what is a schema-shaped extraction. Pin Sonnet, same as the + // server path (llm/select-provider resolveCliAgent), so both headless callers agree. + assert.deepEqual(resolveAnalysisAgent(undefined, {}), { id: "claude", model: "sonnet" }); }); test("resolveAnalysisAgent supports requested agents and env model overrides", () => { diff --git a/src/add.ts b/src/add.ts index 618b9a6..74fd334 100644 --- a/src/add.ts +++ b/src/add.ts @@ -183,6 +183,9 @@ export function assertAnalysisAgentId(value: unknown): AnalysisAgentId { throw new Error(`Invalid analysis agent: ${String(value)}. Expected one of: ${ANALYSIS_AGENT_IDS.join(", ")}`); } +/** The model Board asks the claude CLI for when the operator hasn't named one. */ +export const DEFAULT_CLAUDE_CLI_MODEL = "sonnet"; + export function resolveAnalysisAgent( requested?: unknown, env: NodeJS.ProcessEnv = process.env @@ -191,8 +194,13 @@ export function resolveAnalysisAgent( ? assertAnalysisAgentId(env.BOARD_ANALYSIS_AGENT || "claude") : assertAnalysisAgentId(requested); + // Unset means `claude -p` inherits the operator's INTERACTIVE default, which on a + // subscription box is Opus — minutes and real money for a fixed-schema extraction. + // Pin Sonnet (floating alias, so it tracks the current Sonnet). codex is left + // unpinned: it has its own catalog and default. Mirrors resolveCliAgent in + // llm/select-provider.ts, which is the server's headless path. const model = - id === "claude" ? env.BOARD_CLAUDE_MODEL || null : + id === "claude" ? env.BOARD_CLAUDE_MODEL || DEFAULT_CLAUDE_CLI_MODEL : env.BOARD_CODEX_MODEL || null; return { id, model }; diff --git a/src/collections-ui.js b/src/collections-ui.js index c3226a7..071660f 100644 --- a/src/collections-ui.js +++ b/src/collections-ui.js @@ -411,3 +411,96 @@ export function isInFlight(item) { const state = itemRenderState(item); return state === "capturing" || state === "reading"; } + +// --- Descriptor-driven card summary ---------------------------------------------- +// A tile has room for three things; a composed board can declare fifteen fields, and +// the descriptor carries no display hint (see descriptor/render-map.js). So the card +// picks its own headline from the board's content model: a category badge, a lead +// line, and a chip row. This is what Inspiration's hardcoded card always showed +// (tier badge / steal_this / tags) — expressed generically, so ANY board gets it. +// +// The selection rules deliberately MIRROR the detail modal (openItemModal): the +// user's own fields (enrichable:false) belong to the modal's edit section, and the +// lead is the first long-form text. A card and the modal it opens must never +// disagree about what this item's headline is. + +/** Below this, a `text` field reads as a label (e.g. "vite.dev"), not as a summary. */ +const LEAD_MIN_CHARS = 50; +const DEFAULT_MAX_TAGS = 6; + +/** Fields that are never card TEXT: the url is the card's link, the image its shot. */ +const CARD_SKIP_TYPES = new Set(["url", "image"]); + +function cardSlotsFrom(fields, item) { + let badge = null; + let longLead = null; + let shortLead = null; + const tags = []; + for (const field of fields) { + if (CARD_SKIP_TYPES.has(field.type)) continue; + const value = getFieldValue(item, field.key); + if (!hasValue(value)) continue; + if (field.type === "tags") { + for (const t of Array.isArray(value) ? value : [value]) { + if (hasValue(t)) tags.push(String(t)); + } + } else if (field.type === "enum") { + if (!badge) badge = { key: field.key, label: field.label, value: String(value) }; + } else if (field.type === "text") { + const text = String(value); + if (!longLead && text.length >= LEAD_MIN_CHARS) longLead = { key: field.key, label: field.label, value: text }; + else if (!shortLead) shortLead = { key: field.key, label: field.label, value: text }; + } + } + return { badge, lead: longLead ?? shortLead, tags }; +} + +function cardSlotsEmpty(slots) { + return !slots.badge && !slots.lead && slots.tags.length === 0; +} + +/** + * The card's headline for one item, chosen from its board's descriptor. + * + * @returns {{ badge: {key,label,value}|null, lead: {key,label,value}|null, tags: string[] }} + * + * AI-filled fields are preferred; a board with NO enrichable fields (an all-manual + * collection) falls back to the user's own fields rather than rendering a blank tile. + */ +export function cardSummary(item, descriptor, opts = {}) { + const empty = { badge: null, lead: null, tags: [] }; + if (!item || !descriptor || !Array.isArray(descriptor.fields)) return empty; + + const enrichable = descriptor.fields.filter((f) => f.enrichable !== false); + let slots = cardSlotsFrom(enrichable, item); + // Nothing AI-filled to show — fall back to the whole descriptor so an all-manual + // board (or one whose enrichment hasn't landed) still gets a card with content. + if (cardSlotsEmpty(slots)) slots = cardSlotsFrom(descriptor.fields, item); + + const maxTags = Number.isFinite(opts.maxTags) ? opts.maxTags : DEFAULT_MAX_TAGS; + return { badge: slots.badge, lead: slots.lead, tags: slots.tags.slice(0, maxTags) }; +} + +// --- Sort --------------------------------------------------------------------------- +/** + * Order a board's items by recency. `added` carries only a DATE, so everything captured + * on the same day compares equal and the TIE-BREAK decides the order of most of a + * board. Incoming order is authoritative for that tie-break: the items API returns + * `created_at DESC`, so index 0 is the newest item. + * + * The previous inline comparator broke the tie the wrong way round (it returned + * `bi - ai` for "newest"), which put the oldest same-day item first — so on a board + * where everything was added today, "Recently added" listed it backwards. + * + * Returns a NEW array; the caller's list order is the recency reference and must survive. + */ +export function sortItems(items, sort) { + const oldestFirst = sort === "oldest"; + const index = new Map(items.map((item, i) => [item, i])); + return [...items].sort((a, b) => { + const byDate = String(a.added ?? "").localeCompare(String(b.added ?? "")); + if (byDate !== 0) return oldestFirst ? byDate : -byDate; + const ai = index.get(a), bi = index.get(b); + return oldestFirst ? bi - ai : ai - bi; + }); +} diff --git a/src/collections-ui.test.ts b/src/collections-ui.test.ts index d5a69d5..3477ecf 100644 --- a/src/collections-ui.test.ts +++ b/src/collections-ui.test.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { resolveActiveCollection, + cardSummary, + sortItems, itemsUrl, itemUrl, addUrl, @@ -523,3 +525,159 @@ test("safeErrorReason reads either payload spelling", () => { // hydrate ships snake_case; applySseEvent writes camelCase. assert.equal(safeErrorReason({ errorReason: "timed out" }), "timed out"); }); + +// --- Descriptor-driven CARD summary ----------------------------------------------- +// The tile can't show 15 fields and the descriptor carries no display hint, so the +// card picks three slots from the board's own content model: a category badge, a lead +// line, and tag chips. Mirrors the detail modal's selection rules so a card and the +// modal it opens can never disagree about what this item's "headline" is. + +// The composed board from the bug report (Reference Wall / Radiator), trimmed. +const CARD_DESCRIPTOR = { + view: "grid", + fields: [ + { key: "source_url", label: "Source", type: "url", enrichable: true }, + { key: "site_name", label: "Site", type: "text", enrichable: true }, + { key: "surface", label: "Surface", type: "tags", enrichable: true }, + { key: "layout_move", label: "The move", type: "text", enrichable: true }, + { key: "palette", label: "Palette", type: "tags", enrichable: true }, + { key: "density", label: "Density", type: "enum", values: ["Dense", "Balanced"], enrichable: true }, + { key: "verdict", label: "Verdict", type: "enum", values: ["Steal", "Pass"], enrichable: false }, + { key: "what_works", label: "What works", type: "text", enrichable: false }, + { key: "pull", label: "Pull", type: "number", enrichable: false }, + ], +}; + +const CARD_ITEM = { + id: "i1", + title: "Vite | Next Generation Frontend Tooling", + status: "done", + fields: { + source_url: "https://vite.dev/", + site_name: "vite.dev", + surface: ["Open-source project homepage", "Docs landing page"], + layout_move: + "A single centered axis scrolled as alternating claim-then-proof bands: every capability section opens with one short centered line and resolves into a multi-up card grid.", + palette: ["violet-indigo brand primary", "gold/amber accent"], + density: "Balanced", + }, +}; + +test("cardSummary picks a badge, a lead line and tags from the board's own fields", () => { + const s = cardSummary(CARD_ITEM, CARD_DESCRIPTOR); + assert.equal(s.badge?.value, "Balanced", "first AI-filled enum becomes the category badge"); + assert.equal(s.lead?.key, "layout_move", "the long text field leads, not the short site_name"); + assert.deepEqual( + s.tags, + ["Open-source project homepage", "Docs landing page", "violet-indigo brand primary", "gold/amber accent"], + "every tags field flattens into the chip row, in descriptor order", + ); +}); + +test("cardSummary leaves the user's own fields to the modal's edit section", () => { + const item = { + ...CARD_ITEM, + fields: { ...CARD_ITEM.fields, verdict: "Steal", what_works: "The proof bands." }, + }; + const s = cardSummary(item, CARD_DESCRIPTOR); + assert.equal(s.badge?.value, "Balanced", "an enrichable enum still wins over the user's verdict"); + assert.equal(s.lead?.key, "layout_move", "the user's what_works does not take the lead slot"); +}); + +test("cardSummary falls back to short text when the board has no long-form field", () => { + const d = { fields: [{ key: "brand", label: "Brand", type: "text", enrichable: true }] }; + const s = cardSummary({ fields: { brand: "Aesop" } }, d); + assert.equal(s.lead?.value, "Aesop", "a short text line beats no line at all"); +}); + +test("cardSummary falls back to the user's fields on an all-manual board", () => { + const d = { + fields: [ + { key: "note", label: "Note", type: "text", enrichable: false }, + { key: "shelf", label: "Shelf", type: "tags", enrichable: false }, + ], + }; + const s = cardSummary({ fields: { note: "Bought in Kyoto.", shelf: ["ceramics"] } }, d); + assert.equal(s.lead?.value, "Bought in Kyoto.", "a board with no AI fields still fills its cards"); + assert.deepEqual(s.tags, ["ceramics"]); +}); + +test("cardSummary never puts url/image/number fields in the text slots", () => { + const d = { + fields: [ + { key: "source_url", label: "Source", type: "url", enrichable: true }, + { key: "shot", label: "Shot", type: "image", enrichable: true }, + { key: "pull", label: "Pull", type: "number", enrichable: true }, + ], + }; + const s = cardSummary({ fields: { source_url: "https://x.dev", shot: "/a.png", pull: 4 } }, d); + assert.equal(s.lead, null, "the URL is the card's own link and the image is its screenshot"); + assert.equal(s.badge, null); + assert.deepEqual(s.tags, []); +}); + +test("cardSummary caps the chip row so one talkative item can't outgrow the grid", () => { + const d = { fields: [{ key: "t", label: "T", type: "tags", enrichable: true }] }; + const many = Array.from({ length: 20 }, (_, i) => `tag-${i}`); + assert.equal(cardSummary({ fields: { t: many } }, d).tags.length, 6); + assert.equal(cardSummary({ fields: { t: many } }, d, { maxTags: 2 }).tags.length, 2); +}); + +test("cardSummary is safe on a missing descriptor or an empty item", () => { + assert.deepEqual(cardSummary({ fields: {} }, null), { badge: null, lead: null, tags: [] }); + assert.deepEqual(cardSummary(null, CARD_DESCRIPTOR), { badge: null, lead: null, tags: [] }); +}); + +test("cardSummary reads the nested prototype shape too (meta.tags / design.steal_this)", () => { + // The seeded boards hydrate nested, composed boards hydrate flat — getFieldValue + // bridges both, and the card must not care which board it is rendering. + const d = { + fields: [ + { key: "meta.tier", label: "Tier", type: "enum", enrichable: true }, + { key: "design.steal_this", label: "Steal this", type: "text", enrichable: true }, + { key: "meta.tags", label: "Tags", type: "tags", enrichable: true }, + ], + }; + const item = { meta: { tier: "reference", tags: ["editorial"] }, design: { steal_this: "Lead with proof." } }; + const s = cardSummary(item, d); + assert.equal(s.badge?.value, "reference"); + assert.equal(s.lead?.value, "Lead with proof."); + assert.deepEqual(s.tags, ["editorial"]); +}); + +// --- Sort order --------------------------------------------------------------------- +// `added` is a DATE ("2026-08-19"), so every item captured on the same day ties. The +// tie-break therefore decides the order of most of a board, not some rare edge. + +test("sortItems puts the newest first for 'newest', including same-day ties", () => { + // The API returns newest-first (created_at DESC), so index order IS recency. + const items = [ + { id: "c", added: "2026-08-19" }, + { id: "b", added: "2026-08-19" }, + { id: "a", added: "2026-08-18" }, + ]; + assert.deepEqual(sortItems(items, "newest").map(i => i.id), ["c", "b", "a"]); +}); + +test("sortItems reverses to oldest-first for 'oldest', ties included", () => { + const items = [ + { id: "c", added: "2026-08-19" }, + { id: "b", added: "2026-08-19" }, + { id: "a", added: "2026-08-18" }, + ]; + assert.deepEqual(sortItems(items, "oldest").map(i => i.id), ["a", "b", "c"]); +}); + +test("sortItems is exactly reversible when every item shares a date", () => { + const items = [{ id: "c" }, { id: "b" }, { id: "a" }].map(i => ({ ...i, added: "2026-08-19" })); + assert.deepEqual(sortItems(items, "newest").map(i => i.id), ["c", "b", "a"]); + assert.deepEqual(sortItems(items, "oldest").map(i => i.id), ["a", "b", "c"]); +}); + +test("sortItems does not mutate its input and tolerates a missing date", () => { + const items = [{ id: "b", added: "2026-08-19" }, { id: "a" }]; + const before = items.map(i => i.id); + sortItems(items, "newest"); + assert.deepEqual(items.map(i => i.id), before, "sorts a copy"); + assert.equal(sortItems(items, "newest").length, 2); +}); diff --git a/src/llm/select-provider.test.ts b/src/llm/select-provider.test.ts index 630fff3..1c6d97a 100644 --- a/src/llm/select-provider.test.ts +++ b/src/llm/select-provider.test.ts @@ -6,7 +6,7 @@ import { loadConfig } from '../config.js'; import { disabledLlm, EnrichmentDisabledError } from '../skills/types.js'; import { HttpProvider } from './http-provider.js'; import { CliProvider } from './cli-provider.js'; -import { selectProvider, describeProvider } from './select-provider.js'; +import { selectProvider, describeProvider, resolveCliAgent } from './select-provider.js'; describe('selectProvider (Story 4.4)', () => { // AC 1/3/6 — no provider config → disabledLlm (the C10 no-AI default) @@ -59,7 +59,10 @@ describe('describeProvider', () => { assert.equal(describeProvider(loadConfig({})), null); }); it('labels a claude CLI agent', () => { - assert.deepEqual(describeProvider(loadConfig({ LLM_AGENT: 'claude' })), { kind: 'cli', agent: 'claude', label: 'Claude Code' }); + // "Claude", not "Claude Code": this label is interpolated into the add button + // ("Add with Claude") and the provider menu ("Using Claude"), where the extra word + // buys nothing and costs header width the board switcher needs. + assert.deepEqual(describeProvider(loadConfig({ LLM_AGENT: 'claude' })), { kind: 'cli', agent: 'claude', label: 'Claude' }); }); it('labels a codex CLI agent', () => { assert.deepEqual(describeProvider(loadConfig({ LLM_AGENT: 'codex' })), { kind: 'cli', agent: 'codex', label: 'Codex' }); @@ -75,3 +78,43 @@ describe('describeProvider', () => { assert.equal(describeProvider(loadConfig({ LLM_BASE_URL: 'http://x/v1' })), null); }); }); + +// The CLI agent's model. Left unset, `claude -p` inherits whatever the operator's +// interactive default is (Opus on this box) — expensive and slow for a structured +// extraction. Board pins Sonnet for the headless read; an explicit LLM_MODEL wins. +describe('resolveCliAgent (headless model default)', () => { + it('defaults the claude CLI agent to sonnet when no model is configured', () => { + assert.deepEqual(resolveCliAgent(loadConfig({ LLM_AGENT: 'claude' }).provider), { + id: 'claude', + model: 'sonnet', + }); + }); + + it('honors an explicit LLM_MODEL over the default', () => { + assert.deepEqual(resolveCliAgent(loadConfig({ LLM_AGENT: 'claude', LLM_MODEL: 'opus' }).provider), { + id: 'claude', + model: 'opus', + }); + }); + + it('honors the legacy BOARD_CLAUDE_MODEL alias over the default', () => { + assert.deepEqual( + resolveCliAgent(loadConfig({ LLM_AGENT: 'claude', BOARD_CLAUDE_MODEL: 'haiku' }).provider), + { id: 'claude', model: 'haiku' }, + ); + }); + + // codex has its own model catalog — Board does not pick one for it. + it('leaves codex unpinned (no claude default leaks across agents)', () => { + assert.deepEqual(resolveCliAgent(loadConfig({ LLM_AGENT: 'codex' }).provider), { + id: 'codex', + model: null, + }); + }); + + // The default must NOT enable HTTP: selectProvider gates HTTP on baseUrl && model, + // so a base-URL-only install has to stay disabled rather than resolve to a guess. + it('does not enable an HTTP provider that has a base-URL but no model', () => { + assert.equal(selectProvider(loadConfig({ LLM_BASE_URL: 'http://x/v1' })), disabledLlm); + }); +}); diff --git a/src/llm/select-provider.ts b/src/llm/select-provider.ts index 0960926..99ba649 100644 --- a/src/llm/select-provider.ts +++ b/src/llm/select-provider.ts @@ -1,7 +1,13 @@ -import type { Config } from '../config.js'; +import type { Config, ProviderConfig } from '../config.js'; import { disabledLlm, type LLMProvider } from '../skills/types.js'; import { HttpProvider } from './http-provider.js'; -import { CliProvider } from './cli-provider.js'; +import { CliProvider, type CliAgent } from './cli-provider.js'; +// The claude-CLI model default. Shared with add.ts's `resolveAnalysisAgent` (the +// `npm run add` path) so BOTH headless callers ask for the same model — `claude -p` +// with no `--model` would otherwise inherit the operator's interactive default (Opus +// on a subscription box): minutes and real money for a fixed-schema extraction. +// A floating alias, not a dated id, so it tracks the current Sonnet. +import { DEFAULT_CLAUDE_CLI_MODEL } from '../add.js'; // Story 4.4 — pick the LLM transport from config, with a NO-AI DEFAULT. // @@ -24,12 +30,27 @@ export function selectProvider(config: Config): LLMProvider { } if (p.agent === 'claude' || p.agent === 'codex') { - return new CliProvider({ agent: { id: p.agent, model: p.model } }); + return new CliProvider({ agent: resolveCliAgent(p) as CliAgent }); } return disabledLlm; } +/** + * Resolve the CLI agent (id + model) for `CliProvider`. Split out from + * `selectProvider` so the model default is testable on its own and — critically — + * lands ONLY on the CLI path: defaulting `provider.model` in `loadConfig` would make + * a base-URL-only install satisfy `baseUrl && model` above and silently point an + * HttpProvider at a model that host has never heard of. + * + * `codex` is left unpinned: it has its own model catalog and its own default. + */ +export function resolveCliAgent(p: ProviderConfig): { id: 'claude' | 'codex'; model: string | null } { + const id = p.agent as 'claude' | 'codex'; + if (p.model) return { id, model: p.model }; + return { id, model: id === 'claude' ? DEFAULT_CLAUDE_CLI_MODEL : null }; +} + export interface ProviderInfo { kind: 'cli' | 'http'; agent?: 'claude' | 'codex'; @@ -37,8 +58,11 @@ export interface ProviderInfo { label: string; } +// The user-facing provider name, interpolated into "Add with {label}" and +// "Using {label}". Kept to the product name alone — the extra "Code" bought nothing +// in either sentence and cost header width the board switcher needs. const CLI_AGENT_LABELS: Record<'claude' | 'codex', string> = { - claude: 'Claude Code', + claude: 'Claude', codex: 'Codex', };