@leftoff/cli 0.8.9 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/app.js CHANGED
@@ -2,9 +2,9 @@
2
2
  // /v1 API on the same origin (auth rides the cookie set at GET /).
3
3
  import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
4
  import { icon } from './icons.js'
5
- import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey } from './picker.js'
5
+ import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet } from './picker.js'
6
6
  import { SETTINGS, settingsKeyFor } from './settings-nav.js'
7
- import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches } from './repos.js'
7
+ import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel } from './repos.js'
8
8
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
9
9
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
10
10
  import { mdInline, renderSection } from './changelog-md.js'
@@ -165,7 +165,7 @@ function where(s) {
165
165
  if (s.agent) return (s.project ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(s.repoLabel) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc(s.repoLabel + ' · show only this project') + '">' + esc(s.repoLabel) + '</button>' : '') + agentChip(s.agent)
166
166
  const branch = s.repo && s.repo.branch
167
167
  const label = s.repoLabel || s.repoName || 'no repo'
168
- return '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
168
+ return projChip(s) + '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
169
169
  (multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<button type="button" class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '" data-action="machine-emoji" data-machine="' + esc(s.machine) + '"><span class="memo">' + esc(mEmoji(s.machine)) + '</span> ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</button>' })() : '') + modelChip(s)
170
170
  }
171
171
 
@@ -420,7 +420,7 @@ function copyResumeIcon(s) {
420
420
  }
421
421
  // items: [{ action, label, icon, title, attrs }]
422
422
  function moreMenu(s, items) {
423
- items = items.filter(Boolean)
423
+ items = items.concat([projectMenuItem(s)]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
424
424
  if (!items.length) return ''
425
425
  return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '" aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
426
426
  '<template class="more-items">' + items.map((it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
@@ -438,11 +438,13 @@ function openCardMenu(btn) {
438
438
  for (const a of b.attributes) if (a.name.startsWith('data-') || a.name === 'title') attrs[a.name] = a.value
439
439
  return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs }
440
440
  })
441
+ lastMenuAnchor = btn
441
442
  openActionMenu(btn, items, { title })
442
443
  }
443
444
  const sessionMenuItems = (s) => s.isLocal ? [
444
445
  { action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus' },
445
446
  { action: 'pin', label: s.pinned ? 'Unpin' : 'Pin', icon: s.pinned ? 'pin-off' : 'pin' },
447
+ { action: 'title-auto', label: 'Regenerate title', icon: 'refresh-cw', title: 'A new title from the latest messages (like /cp-name --auto)' },
446
448
  { action: 'archive', label: 'Archive', icon: 'archive' },
447
449
  ] : []
448
450
  const roNote = (t) => '<span class="ro" title="' + esc(t) + '">' + esc(t) + '</span>'
@@ -490,8 +492,9 @@ function boardFilter() {
490
492
  return f
491
493
  }
492
494
  function setBoardFilter(next) {
493
- const f = { repos: next.repos || [], status: next.status || null }
495
+ const f = { repos: next.repos !== undefined ? next.repos || [] : ((state.boardFilter || {}).repos || []), status: next.status !== undefined ? next.status || null : ((state.boardFilter || {}).status || null), project: next.project !== undefined ? next.project || null : ((state.boardFilter || {}).project || null) }
494
496
  saveRepos(window.localStorage, f.repos)
497
+ saveProject(window.localStorage, f.project)
495
498
  history.replaceState(null, '', hashFor(f))
496
499
  state.route = parseRoute()
497
500
  return render()
@@ -532,10 +535,93 @@ function chipBar(chips, selected, allCount, emptyNote) {
532
535
  return '<div class="repos" role="group" aria-label="Filter by repo"><div class="track">' + all + row.map(chip).join('') + '</div>' + menu + '</div>'
533
536
  }
534
537
 
538
+ // ------------------------------------------------------------ projects (0.9.0)
539
+ // A project is a named group of repos (projects.json in the synced folder).
540
+ // Read everywhere; changed on a Mac's own board (the relay's phone view only reads).
541
+ const projectsUi = { list: [], repos: [], mayEdit: false, at: 0 }
542
+ async function loadProjects({ fresh = false } = {}) {
543
+ if (!fresh && projectsUi.at && Date.now() - projectsUi.at < 3000) return projectsUi
544
+ try { const d = await api('GET', '/v1/projects'); projectsUi.list = d.projects || []; projectsUi.repos = d.repos || []; projectsUi.mayEdit = d.mayEdit !== false; projectsUi.at = Date.now() } catch { projectsUi.at = Date.now() }
545
+ return projectsUi
546
+ }
547
+ function projectsChanged(d) { if (d && d.projects) { projectsUi.list = d.projects; projectsUi.repos = d.repos || projectsUi.repos; projectsUi.at = Date.now() } }
548
+ // The small chip next to the repo: tap = show only this project.
549
+ function projChip(s) {
550
+ const p = s && s.repoProject
551
+ if (!p) return ''
552
+ return '<button type="button" class="chip proj" data-action="project-filter" data-project="' + esc(p.id) + '" title="' + esc('Project ' + p.name + ' · show only this project') + '">' + (p.emoji ? '<span class="pe" aria-hidden="true">' + esc(p.emoji) + '</span>' : '') + esc(p.name) + '</button>'
553
+ }
554
+ const canProject = (s) => !!(s && !s.agent && s.repoKey && s.repoKey !== 'no-repo' && projectsUi.mayEdit)
555
+ function projectMenuItem(s) {
556
+ if (!canProject(s)) return null
557
+ const p = s.repoProject
558
+ return { action: 'project-menu', label: p ? 'Project: ' + p.name + '…' : 'Add to project…', icon: 'folder', attrs: { 'data-key': s.repoKey, 'data-repo': s.repoLabel || s.repoName || '' } }
559
+ }
560
+ // "Add to project": the projects (✓ the one it's in), New project…, and Remove.
561
+ function openProjectMenu(anchor, { key, label }) {
562
+ const s = (state.sessions || []).find((x) => x.repoKey === key)
563
+ const cur = (s && s.repoProject) || (projectsUi.repos.find((r) => r.key === key) || {}).project || null
564
+ const items = projectsUi.list.map((p) => ({ label: (cur && cur.id === p.id ? '✓ ' : '') + projectLabel(p), icon: '', cls: cur && cur.id === p.id ? 'on' : '', attrs: { 'data-action': 'project-add', 'data-project': p.id, 'data-key': key, 'aria-checked': String(!!(cur && cur.id === p.id)) } }))
565
+ items.push({ label: 'New project…', icon: icon('plus'), attrs: { 'data-action': 'project-new', 'data-key': key, 'data-repo': label } })
566
+ if (cur) items.push({ label: 'Remove from ' + cur.name, icon: icon('x'), attrs: { 'data-action': 'project-rm', 'data-project': cur.id, 'data-key': key } })
567
+ openActionMenu(anchor, items, { title: 'Add ' + (label || 'this repo') + ' to a project' })
568
+ }
569
+ // The selected project's own menu (from the filter row): rename, remove a repo, delete.
570
+ function openProjectManage(anchor, id) {
571
+ const p = projectsUi.list.find((x) => x.id === id)
572
+ if (!p) return
573
+ const items = [{ label: 'Rename…', icon: icon('pencil'), attrs: { 'data-action': 'project-rename', 'data-project': p.id } }]
574
+ for (const r of p.repos) items.push({ label: 'Remove ' + (r.label || r.name || r.github || r.path), icon: icon('x'), attrs: { 'data-action': 'project-rm', 'data-project': p.id, 'data-key': r.key || r.github || r.path || r.id, 'data-rid': r.id } })
575
+ items.push({ label: 'Delete project', icon: icon('trash'), cls: 'danger', attrs: { 'data-action': 'project-delete', 'data-project': p.id, title: 'Its sessions stay as they are' } })
576
+ openActionMenu(anchor, items, { title: projectLabel(p) + ' · ' + p.repos.length + (p.repos.length === 1 ? ' repo' : ' repos') })
577
+ }
578
+ // The project chips (board and PRs page): All, then each project with its count; the picked one has ⋯.
579
+ function projectRow(list, sel, action, counts) {
580
+ if (!projectsUi.list.length) return ''
581
+ const chips = projectChips(projectsUi.list, list)
582
+ const total = counts != null ? counts : (list || []).length
583
+ const b = (id, label, n, on) => '<button type="button" class="rchip pchip' + (on ? ' on' : '') + (n || on || !id ? '' : ' zero') + '" data-action="' + action + '" data-project="' + esc(id) + '" aria-pressed="' + on + '"><span class="rl">' + label + '</span>' + (n != null ? '<span class="rc">' + n + '</span>' : '') + '</button>'
584
+ const manage = sel && projectsUi.mayEdit && projectsUi.list.some((p) => p.id === sel) ? '<button type="button" class="btn icon-btn more proj-more" data-action="project-manage" data-project="' + esc(sel) + '" aria-label="Project actions" title="Rename, remove a repo, delete" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' : ''
585
+ return '<div class="repos projects" role="group" aria-label="Filter by project"><div class="track">' + b('', 'All projects', total, !sel) +
586
+ chips.map((c) => b(c.id, (c.emoji ? '<span class="pe" aria-hidden="true">' + esc(c.emoji) + '</span>' : '') + esc(c.name), c.count, sel === c.id)).join('') + manage + '</div></div>'
587
+ }
588
+ async function projectAction(action, btn) {
589
+ const anchorFor = () => (lastMenuAnchor && lastMenuAnchor.isConnected ? lastMenuAnchor : btn)
590
+ const done = (d) => { projectsChanged(d); return render() }
591
+ const toastErr = (e) => toast(e.message || String(e))
592
+ if (action === 'project-menu') { await loadProjects(); return openProjectMenu(btn.isConnected && !btn.classList.contains('palette-proxy') ? btn : anchorFor(), { key: btn.dataset.key, label: btn.dataset.repo }) }
593
+ if (action === 'project-manage') { await loadProjects(); return openProjectManage(btn, btn.dataset.project) }
594
+ if (action === 'project-add') {
595
+ try { const d = await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/repos', { repo: btn.dataset.key }); toast('Added ' + ((d.added && d.added.name) || 'the repo') + ' to ' + (d.project ? d.project.name : 'the project') + (d.movedFrom ? ' (was in ' + d.movedFrom + ')' : '')); return done(d) } catch (e) { return toastErr(e) }
596
+ }
597
+ if (action === 'project-rm') {
598
+ const p = projectsUi.list.find((x) => x.id === btn.dataset.project)
599
+ try { const d = await api('POST', '/v1/projects/' + encodeURIComponent(btn.dataset.project) + '/repos', { repo: btn.dataset.rid || btn.dataset.key, remove: true }); toast('Removed from ' + (p ? p.name : 'the project')); return done(d) } catch (e) { return toastErr(e) }
600
+ }
601
+ if (action === 'project-new') {
602
+ const anchor = btn.isConnected && !btn.classList.contains('palette-proxy') ? btn : anchorFor()
603
+ return openNameSheet(anchor, { title: 'New project' + (btn.dataset.repo ? ' with ' + btn.dataset.repo : ''), label: 'Project name', placeholder: 'e.g. Acme', submit: 'Create',
604
+ onSubmit: async (name) => { const d = await api('POST', '/v1/projects', { name, repos: btn.dataset.key ? [btn.dataset.key] : [] }); toast('Made ' + name); projectsChanged(d); render() } })
605
+ }
606
+ if (action === 'project-rename') {
607
+ const p = projectsUi.list.find((x) => x.id === btn.dataset.project)
608
+ if (!p) return
609
+ const anchor = document.querySelector('[data-action="project-manage"]') || document.body
610
+ return openNameSheet(anchor, { title: 'Rename project', label: 'Project name', value: p.name, submit: 'Rename',
611
+ onSubmit: async (name) => { const d = await api('PATCH', '/v1/projects/' + encodeURIComponent(p.id), { name }); projectsChanged(d); render() } })
612
+ }
613
+ if (action === 'project-delete') {
614
+ const p = projectsUi.list.find((x) => x.id === btn.dataset.project)
615
+ if (!p || !confirm('Delete the project “' + p.name + '”? Its repos and sessions stay as they are.')) return
616
+ try { const d = await api('DELETE', '/v1/projects/' + encodeURIComponent(p.id)); toast('Deleted ' + p.name); if ((state.boardFilter || {}).project === p.id) { saveProject(window.localStorage, null); projectsChanged(d); return setBoardFilter({ project: null }) } return done(d) } catch (e) { return toastErr(e) }
617
+ }
618
+ }
619
+ let lastMenuAnchor = null
620
+
535
621
  async function viewBoard() {
536
622
  if (!state.health) await refreshHealth()
537
623
  if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
538
- const { sessions: all, viewer, closedCount, todoCount, parked = [] } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
624
+ const [{ sessions: all, viewer, closedCount, todoCount, parked = [] }] = await Promise.all([api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : '')), loadProjects()])
539
625
  // 0.8.2: parked sessions have their own section (any age), not a status group
540
626
  const sessions = all.filter((s) => !s.parked)
541
627
  state.sessions = all.concat(parked.filter((p) => !all.some((s) => s.id === p.id)))
@@ -567,12 +653,16 @@ async function viewBoard() {
567
653
  state.boardSpace = sp
568
654
  const spCounts = new Map(spaces.map((x) => [x, shown.filter((s) => s.space === x).length]))
569
655
  const allShown = shown
570
- const inSpace = sp ? shown.filter((s) => s.space === sp) : shown
656
+ // 0.9.0: a project (its repos), after the space and before the repo chips
657
+ const pj = f.project && projectsUi.list.some((p) => p.id === f.project) ? f.project : null
658
+ if (f.project && !pj && projectsUi.at) { f.project = null; saveProject(window.localStorage, null) }
659
+ const spaced = sp ? shown.filter((s) => s.space === sp) : shown
660
+ const inSpace = spaced.filter((s) => inProject(s, pj))
571
661
  const inRepos = applyFilter(inSpace, { repos: f.repos })
572
662
  const count = (k) => k === 'closed' ? (closedCount == null ? '—' : closedCount) : inRepos.filter((s) => inStatus(s, k)).length
573
663
  const bq = (state.boardQ || '').trim()
574
664
  // 0.8.6: a search looks through every session the board has (not only today's), folded ones too
575
- const list = filter === 'closed' ? [] : bq ? applyFilter(sp ? sessions.filter((s) => s.space === sp) : sessions, f).filter((s) => searchMatches(s, bq)).sort((a, b) => (rank[a.status] - rank[b.status]) || (Date.parse(b.lastActivityAt || 0) - Date.parse(a.lastActivityAt || 0))) : applyFilter(inSpace, f)
665
+ const list = filter === 'closed' ? [] : bq ? applyFilter((sp ? sessions.filter((s) => s.space === sp) : sessions).filter((s) => inProject(s, pj)), f).filter((s) => searchMatches(s, bq)).sort((a, b) => (rank[a.status] - rank[b.status]) || (Date.parse(b.lastActivityAt || 0) - Date.parse(a.lastActivityAt || 0))) : applyFilter(inSpace, f)
576
666
  let closedHits = 0
577
667
  if (bq && filter !== 'closed') { try { const c = await api('GET', '/v1/sessions/closed?limit=1&q=' + encodeURIComponent(bq) + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : '')); closedHits = c.total || 0 } catch {} }
578
668
  let html = '<div class="pagehead"><div><h1>Sessions</h1><div class="sub">Every Claude Code session on ' + (multi() ? 'your machines' : 'this machine') + (agentNames().length ? ', plus ' + esc(agentNames().join(' and ')) : '') + ', grouped by what needs you.</div>' + syncChip() + '</div>' +
@@ -586,10 +676,11 @@ async function viewBoard() {
586
676
  const needs = needsItems(sessions)
587
677
  html += needsStrip(needs, needOpts())
588
678
  // 0.7.0: open to-dos are their own sub-count, not part of the Needs you number
589
- const todoN = f.repos.length || sp ? inRepos.reduce((n, s) => n + ((s.todos && s.todos.length) || 0), 0) : (todoCount || 0)
679
+ const todoN = f.repos.length || sp || pj ? inRepos.reduce((n, s) => n + ((s.todos && s.todos.length) || 0), 0) : (todoCount || 0)
590
680
  html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
591
681
  '<button type="button" class="stat' + (filter === k ? ' on' : '') + '" data-action="filter" data-filter="' + k + '" aria-pressed="' + (filter === k) + '" title="' + esc(filter === k ? 'Showing ' + fl.label.toLowerCase() + ' · tap to show all' : 'Show ' + fl.label.toLowerCase()) + '"><div class="n">' + count(k) + (k === 'needs' && todoN ? '<span class="todo-subn" title="To-dos handed back to you (not counted as waiting)">' + todoN + (todoN === 1 ? ' to-do' : ' to-dos') + '</span>' : '') + '</div><div class="k"><span class="pill ' + fl.cls + '" aria-hidden="true"></span>' + esc(fl.label) + '</div></button>').join('') + '</div>'
592
682
  html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
683
+ html += projectRow(spaced, pj, 'board-project')
593
684
  if (filter === 'closed') {
594
685
  html += await closedSection(f, sp)
595
686
  $app.innerHTML = banners() + html
@@ -599,24 +690,25 @@ async function viewBoard() {
599
690
  html += boardSearch(bq, closedHits, list.length)
600
691
  if (bq) {
601
692
  if (list.length) html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
602
- $app.innerHTML = banners() + html
693
+ paintBoardKeepingSearch(banners() + html)
603
694
  revealStat()
604
695
  return boardSearchWire()
605
696
  }
606
697
  if (!shown.length && !empties.length && !parked.length) {
607
698
  html += '<div class="empty mt"><p><strong>No sessions yet.</strong></p><p>Start Claude Code with the mod loaded (<code>claude --plugin-dir ./mod</code>) or install the settings hooks (<code>leftoff init --hooks</code>). Sessions show up here as soon as you send a prompt.</p></div>'
608
699
  } else if (!list.length && shown.length) {
609
- const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
700
+ const pjName = pj ? (projectsUi.list.find((p) => p.id === pj) || {}).name : null
701
+ const inWhat = (pjName ? ' in ' + pjName : '') + (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
610
702
  html += '<div class="empty mt">' + (filter ? 'Nothing ' + esc(FILTERS[filter].label.toLowerCase()) + esc(inWhat) + ' right now.' : 'No sessions' + esc(inWhat) + ' today.') +
611
703
  ' <button type="button" class="linkish" data-action="clear-filters">Show everything</button></div>'
612
704
  } else if (list.length) {
613
705
  html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
614
706
  }
615
- html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
616
- if (!filter) html += parkedSection(applyFilter(sp ? parked.filter((s) => s.space === sp) : parked, { repos: f.repos }), viewer || {})
707
+ html += emptyFold(applyFilter((sp ? empties.filter((s) => s.space === sp) : empties).filter((s) => inProject(s, pj)), { repos: f.repos }))
708
+ if (!filter) html += parkedSection(applyFilter((sp ? parked.filter((s) => s.space === sp) : parked).filter((s) => inProject(s, pj)), { repos: f.repos }), viewer || {})
617
709
  // 0.6.9: the Closed view was only a small stat tile; a quiet way in under the grid too.
618
710
  if (closedCount > 0) html += '<div class="closed-link"><button type="button" class="linkish" data-action="filter" data-filter="closed">Closed (' + esc(closedCount) + ') →</button></div>'
619
- $app.innerHTML = banners() + html
711
+ paintBoardKeepingSearch(banners() + html)
620
712
  revealStat()
621
713
  boardSearchWire()
622
714
  }
@@ -628,6 +720,18 @@ function boardSearch(q, closedHits, n) {
628
720
  (q ? '<div class="board-search-note">' + (n ? esc(n + (n === 1 ? ' match' : ' matches')) : 'No open or recent session matches.') +
629
721
  (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') + '</div>' : '') + '</div>'
630
722
  }
723
+ // A repaint from a live update (or the 30 s refresh) while you type in the
724
+ // search box keeps the box focused, with what you typed and the caret.
725
+ function paintBoardKeepingSearch(h) {
726
+ const old = document.getElementById('boardQ')
727
+ const keep = old && document.activeElement === old ? { v: old.value, pos: old.selectionStart } : null
728
+ $app.innerHTML = h
729
+ if (!keep) return
730
+ const q = document.getElementById('boardQ')
731
+ if (!q) return
732
+ if (q.value !== keep.v) q.value = keep.v
733
+ q.focus(); try { q.setSelectionRange(keep.pos, keep.pos) } catch {}
734
+ }
631
735
  let boardQTimer = null
632
736
  function boardSearchWire() {
633
737
  const q = document.getElementById('boardQ')
@@ -658,6 +762,7 @@ function closedQuery(f, sp) {
658
762
  if (closedUi.docs) qs.set('docs', '1')
659
763
  if (state.machine) qs.set('machine', state.machine)
660
764
  if (f.repos.length) qs.set('repo', f.repos.join(','))
765
+ if (f.project) qs.set('project', f.project)
661
766
  if (sp) qs.set('space', sp)
662
767
  return '/v1/sessions/closed?' + qs
663
768
  }
@@ -705,7 +810,7 @@ function closedCard(s, viewer) {
705
810
  return '<article class="card ended is-closed closed-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
706
811
  '<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-closed">closed</span></div>' +
707
812
  '<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
708
- '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
813
+ projChip(s) + '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
709
814
  '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
710
815
  '<span title="' + esc(clock(s.endedAt)) + '">ended ' + esc(ago(s.endedAt)) + '</span><span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + '</span></div>' +
711
816
  cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
@@ -778,7 +883,7 @@ function parkedCard(s, viewer) {
778
883
  return '<article class="card is-parked parked-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
779
884
  '<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-parked">parked</span></div>' +
780
885
  '<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
781
- '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
886
+ projChip(s) + '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
782
887
  (s.isLocal ? '' : '<span class="chip machine remote" title="another machine">⌂ ' + esc(s.machine) + '</span>') + modelChip(s) +
783
888
  '<span title="' + esc(clock(p.at)) + '">parked ' + esc(ago(p.at)) + '</span>' + (open ? '<span>·</span><span>still open</span>' : '') + '</div>' +
784
889
  (note ? '<div class="park-note"><span class="k">' + (p.note ? 'Note' : 'Next') + '</span> ' + esc(note) + '</div>' : '') +
@@ -994,16 +1099,16 @@ async function viewSession(id) {
994
1099
  const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
995
1100
  state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
996
1101
  const branch = s.repo && s.repo.branch
997
- const spv = await api('GET', '/v1/spaces').catch(() => null)
1102
+ const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
998
1103
  let html = '<a class="back" href="#/">← All sessions</a>'
999
1104
  // 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
1000
1105
  const canRename = s.isLocal !== false && !s.agent && !/^agent:/.test(String(s.id || ''))
1001
1106
  html += '<div class="pagehead"><div>' + (canRename
1002
1107
  ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + esc(s.title || 'Untitled session') + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
1003
- : '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + wtChips(s) + ' &nbsp;' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
1108
+ : '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' &nbsp;' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
1004
1109
  '<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
1005
1110
  html += '<div class="facts">' +
1006
- fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
1111
+ fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
1007
1112
  fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
1008
1113
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1009
1114
  fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
@@ -1026,17 +1131,23 @@ async function viewSession(id) {
1026
1131
  state.listsMayEdit = state.mayAnswer !== false
1027
1132
  html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
1028
1133
  html += '<h2>Checkpoints <span class="count">' + s.checkpointCount + '</span></h2>'
1029
- const newestCp = s.checkpoints.reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
1134
+ const newestCp = s.checkpoints.filter((cp) => !cp.doubt).reduce((a, b) => (!a || String(b.createdAt) > String(a.createdAt) ? b : a), null)
1030
1135
  if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
1031
- else html += '<div class="timeline">' + s.checkpoints.map((cp) =>
1136
+ else html += '<div class="timeline">' + cpTimeline(s.checkpoints.filter((cp) => !cp.doubt), newestCp) + '</div>'
1137
+ // 0.9.0: checkpoints written while Leftoff had another session's branch for this one: kept, folded away
1138
+ const doubted = s.checkpoints.filter((cp) => cp.doubt)
1139
+ if (doubted.length) html += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
1140
+ html += answeredList(s)
1141
+ $app.innerHTML = html
1142
+ }
1143
+ function cpTimeline(list, newestCp) {
1144
+ return list.map((cp) =>
1032
1145
  '<div class="tl-item ' + esc(cp.trigger) + '"><div class="tl-head"><span class="t">' + esc(clock(cp.createdAt)) + '</span><span class="trig">' + esc(cp.trigger) + '</span><span>' + esc(ago(cp.createdAt)) + '</span>' +
1033
1146
  (cp.generator && cp.generator.by ? '<span>· ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') + '</span>' : '') + '</div>' +
1034
1147
  cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp }) +
1035
1148
  (cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
1036
1149
  (cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
1037
- '</div>').join('') + '</div>'
1038
- html += answeredList(s)
1039
- $app.innerHTML = html
1150
+ '</div>').join('')
1040
1151
  }
1041
1152
  // Space of one session (or, on an agent's page, of that agent's feed): its
1042
1153
  // own override, or back to the repo / Mac default (v0.5.8).
@@ -1054,10 +1165,10 @@ function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') +
1054
1165
  // PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
1055
1166
  // PR in the repos your sessions are in). The daemon fetches with gh (or asks
1056
1167
  // the Mac that has it) and caches ~5 min; while it loads we look again shortly.
1057
- const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, view: 'mine', filter: {} }
1168
+ const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, doneOpen: {}, view: 'mine', filter: {} }
1058
1169
  // 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
1059
1170
  // tap repaints from what we have and reads again only when that is old.
1060
- function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen } }
1171
+ function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen, doneOpen: prsUi.doneOpen, projects: projectsUi.list } }
1061
1172
  function paintPrs(d) {
1062
1173
  const q = document.getElementById('prs-q')
1063
1174
  const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
@@ -1076,6 +1187,7 @@ async function viewPrs() {
1076
1187
  view = view === 'team' ? 'team' : 'mine'
1077
1188
  prsUi.view = view
1078
1189
  prsUi.filter = readPrFilter(params)
1190
+ await loadProjects()
1079
1191
  const cached = prsUi.data[view]
1080
1192
  paintPrs(cached || null)
1081
1193
  const fresh = prsUi.fresh; prsUi.fresh = false
@@ -2907,7 +3019,7 @@ window.addEventListener('resize', () => { placeStatus() })
2907
3019
  // ⌘K search: a centered panel with the results grouped as you type. Enter on
2908
3020
  // nothing picked opens the full results page (#/search?q=…), as before.
2909
3021
  const searchUi = { seq: 0, items: [], active: -1, timer: null, last: null }
2910
- const PAGES = [['Board', '#/'], ['Needs you', '#/waiting'], ['Lists', '#/lists'], ['PRs', '#/prs'], ['Timeline', '#/timeline'], ['Digest', '#/digest'], ['Catch me up', '#/catchup/new'], ['What’s new', '#/whats-new'], ['Settings', '#/settings']]
3022
+ const PAGES = [['Board', '#/'], ['Projects', '#/?project='], ['Needs you', '#/waiting'], ['Lists', '#/lists'], ['PRs', '#/prs'], ['Timeline', '#/timeline'], ['Digest', '#/digest'], ['Catch me up', '#/catchup/new'], ['What’s new', '#/whats-new'], ['Settings', '#/settings']]
2911
3023
  const searchIsOpen = () => !document.getElementById('cmdk').hidden
2912
3024
  function openSearch(prefill) {
2913
3025
  const box = document.getElementById('cmdk'), q = document.getElementById('q')
@@ -2927,19 +3039,73 @@ function closeSearch() {
2927
3039
  document.documentElement.classList.remove('cmdk-open')
2928
3040
  clearTimeout(searchUi.timer)
2929
3041
  }
3042
+ // 0.9.0: the palette. Results grouped (Sessions, PRs, Projects, Lists, To-dos,
3043
+ // Files, Actions, Go to), "fuzzy-ish" for pages and actions (the letters in
3044
+ // order), and what you opened lately when the box is empty.
3045
+ const RECENT_KEY = 'cp.recent'
3046
+ function recentItems() { try { const v = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); return Array.isArray(v) ? v.slice(0, 6) : [] } catch { return [] } }
3047
+ function rememberItem(it) {
3048
+ if (!it || !it.href || it.action || /^#\/search/.test(it.href)) return
3049
+ try { const keep = [{ href: it.href, title: it.title, sub: it.sub || '', icon: it.icon || 'history', ext: !!it.ext }, ...recentItems().filter((x) => x.href !== it.href)].slice(0, 6); localStorage.setItem(RECENT_KEY, JSON.stringify(keep)) } catch {}
3050
+ }
3051
+ const fuzzy = (hay, q) => { const h = hay.toLowerCase(); return q.split(/\s+/).filter(Boolean).every((w) => { if (h.includes(w)) return true; if (w.length < 3) return false; let i = 0; for (const ch of h) if (ch === w[i]) i++; return i === w.length }) }
3052
+ // What the palette can do here: on a session's page, that session's actions.
3053
+ function paletteActions() {
3054
+ const out = []
3055
+ const sid = state.route && state.route.name === 's' ? state.route.arg : null
3056
+ const s = sid ? (state.sessions || []).find((x) => x.id === sid) : null
3057
+ if (s) {
3058
+ if (s.isLocal && !s.agent) out.push({ action: 'checkpoint', id: s.id, title: 'Checkpoint now', sub: s.title || 'this session', icon: 'bookmark-plus' })
3059
+ if (!s.agent) out.push({ action: 'resume', id: s.id, title: 'Copy resume command', sub: s.title || 'this session', icon: 'copy' })
3060
+ if (canProject(s)) out.push({ action: 'project-menu', key: s.repoKey, repo: s.repoLabel || '', title: s.repoProject ? 'Change project…' : 'Add to project…', sub: s.repoLabel || '', icon: 'folder' })
3061
+ }
3062
+ if (projectsUi.mayEdit) out.push({ action: 'project-new', title: 'New project…', sub: 'a named group of repos', icon: 'plus' })
3063
+ out.push({ action: 'gen-digest-go', title: 'Today’s digest', sub: 'open it', icon: 'newspaper', href: '#/digest' })
3064
+ return out
3065
+ }
2930
3066
  function searchGroups(q, r) {
2931
3067
  const low = q.toLowerCase()
2932
3068
  const groups = []
2933
- const pages = PAGES.filter(([n]) => !low || n.toLowerCase().includes(low)).map(([n, href]) => ({ href, title: n, sub: '', icon: 'layout-dashboard' }))
2934
- const sets = low ? SETTINGS.filter((x) => (x.label + ' ' + x.words).toLowerCase().includes(low)).map((x) => ({ href: '#/settings/' + x.key, title: 'Settings → ' + x.label, sub: x.sub, icon: x.icon })) : []
2935
- if (r) {
2936
- if (r.sessions.length) groups.push(['Sessions', r.sessions.slice(0, 6).map((x) => ({ href: '#/s/' + encodeURIComponent(x.id), title: x.title || 'Untitled', sub: [x.repoLabel || x.repoName, x.machine, x.lastActivityAt ? ago(x.lastActivityAt) : ''].filter(Boolean).join(' · '), icon: 'history' }))])
2937
- if (r.checkpoints.length) groups.push(['Checkpoints', r.checkpoints.slice(0, 6).map((cp) => ({ href: '#/s/' + encodeURIComponent(cp.sessionId), title: cp.doing || cp.next || 'Checkpoint', sub: [cp.sessionTitle || 'Untitled', cp.createdAt ? clock(cp.createdAt) : ''].filter(Boolean).join(' · '), icon: 'check' }))])
3069
+ const pages = PAGES.filter(([n]) => !low || fuzzy(n, low)).map(([n, href]) => ({ href, title: n, sub: '', icon: 'layout-dashboard' }))
3070
+ const acts = paletteActions().filter((a) => !low || fuzzy(a.title + ' ' + (a.sub || ''), low)).map((a) => a.href ? { ...a, action: null } : a)
3071
+ const sets = low ? SETTINGS.filter((x) => fuzzy(x.label + ' ' + x.words, low)).map((x) => ({ href: '#/settings/' + x.key, title: 'Settings → ' + x.label, sub: x.sub, icon: x.icon })) : []
3072
+ if (!low) {
3073
+ const rec = recentItems()
3074
+ if (rec.length) groups.push(['Recent', rec])
2938
3075
  }
3076
+ if (r) {
3077
+ const proj = (x) => (x.repoProject ? projectLabel(x.repoProject) : '')
3078
+ if (r.sessions.length) groups.push(['Sessions', r.sessions.slice(0, 6).map((x) => ({ href: '#/s/' + encodeURIComponent(x.id), title: x.title || 'Untitled', sub: [x.endedAt ? 'closed' : x.parked ? 'parked' : 'open', proj(x), x.repoLabel || x.repoName, x.machine, x.lastActivityAt ? ago(x.lastActivityAt) : ''].filter(Boolean).join(' · '), icon: 'history' }))])
3079
+ if ((r.projects || []).length) groups.push(['Projects', r.projects.map((p) => ({ href: '#/?project=' + encodeURIComponent(p.id), title: projectLabel(p), sub: p.repos + (p.repos === 1 ? ' repo' : ' repos'), icon: 'folder' }))])
3080
+ if ((r.prs || []).length) groups.push(['PRs', r.prs.slice(0, 5).map((p) => ({ href: p.url, ext: true, title: '#' + p.number + ' ' + (p.title || ''), sub: [p.repo, p.state].filter(Boolean).join(' · '), icon: 'chevron-right' }))])
3081
+ if ((r.lists || []).length) groups.push(['Lists', r.lists.slice(0, 4).map((l) => ({ href: listHref(l.ref), title: l.name, sub: [l.repo, l.progress].filter(Boolean).join(' · '), icon: 'list-checks' }))])
3082
+ if ((r.todos || []).length) groups.push(['To-dos', r.todos.slice(0, 4).map((t) => ({ href: t.sessionId ? '#/s/' + encodeURIComponent(t.sessionId) : '#/waiting', title: t.text, sub: t.sessionTitle || '', icon: 'circle-check' }))])
3083
+ if ((r.files || []).length) groups.push(['Files', r.files.slice(0, 5).map((f) => ({ href: '#/s/' + encodeURIComponent(f.sessionId), title: f.name, sub: f.sessionTitle || '', icon: 'file-text' }))])
3084
+ if (r.checkpoints.length) groups.push(['Checkpoints', r.checkpoints.slice(0, 4).map((cp) => ({ href: '#/s/' + encodeURIComponent(cp.sessionId), title: cp.doing || cp.next || 'Checkpoint', sub: [cp.sessionTitle || 'Untitled', cp.createdAt ? clock(cp.createdAt) : ''].filter(Boolean).join(' · '), icon: 'check' }))])
3085
+ }
3086
+ if (acts.length) groups.push(['Actions', acts.slice(0, low ? 4 : 5)])
2939
3087
  if (sets.length) groups.push(['Settings', sets.slice(0, 5)])
2940
3088
  if (pages.length) groups.push(['Go to', pages.slice(0, low ? 4 : 8)])
2941
3089
  return groups
2942
3090
  }
3091
+ // An action item runs through the board's own click handler (the same code as its button).
3092
+ function runPaletteItem(it) {
3093
+ closeSearch()
3094
+ if (!it.action) return false
3095
+ const b = document.createElement('button')
3096
+ b.type = 'button'; b.dataset.action = it.action
3097
+ if (it.id) b.dataset.id = it.id
3098
+ if (it.key) b.dataset.key = it.key
3099
+ if (it.repo) b.dataset.repo = it.repo
3100
+ b.className = 'palette-proxy'
3101
+ const anchor = document.getElementById('searchBtn')
3102
+ if (anchor) { lastMenuAnchor = anchor }
3103
+ b.style.cssText = 'position:fixed;left:-9999px;top:0'
3104
+ document.body.appendChild(b)
3105
+ b.click()
3106
+ setTimeout(() => b.remove(), 60000) // kept while its action runs (a checkpoint takes a while)
3107
+ return true
3108
+ }
2943
3109
  function paintSearch() {
2944
3110
  const q = document.getElementById('q').value.trim()
2945
3111
  const list = document.getElementById('cmdkList')
@@ -2951,10 +3117,11 @@ function paintSearch() {
2951
3117
  for (const [name, items] of groups) { h += '<div class="cmdk-group" role="presentation">' + esc(name) + '</div>'; for (const it of items) { searchUi.items.push(it); h += optHtml(it, searchUi.items.length - 1) } }
2952
3118
  if (q) h = optHtml(searchUi.items[0], 0) + h
2953
3119
  if (q && q.length >= 2 && !r) h += '<div class="cmdk-note">Searching…</div>'
2954
- else if (q && r && !r.sessions.length && !r.checkpoints.length) h += '<div class="cmdk-note">No sessions or checkpoints match “' + esc(q) + '”.</div>'
3120
+ else if (q && r && !r.sessions.length && !r.checkpoints.length && !(r.projects || []).length && !(r.prs || []).length && !(r.files || []).length && !(r.lists || []).length && !(r.todos || []).length) h += '<div class="cmdk-note">Nothing matches “' + esc(q) + '”.</div>'
2955
3121
  list.innerHTML = h
2956
- setSearchActive(searchUi.items.length ? 0 : -1)
2957
- function optHtml(it, i) { return '<a class="cmdk-opt" role="option" id="cmdk-o' + i + '" data-i="' + i + '" href="' + esc(it.href) + '" aria-selected="false">' + icon(it.icon || 'search') + '<span class="co-text"><span class="co-title">' + esc(it.title) + '</span>' + (it.sub ? '<span class="co-sub">' + esc(it.sub) + '</span>' : '') + '</span></a>' }
3122
+ // 0.9.0: Enter opens the best match (the first result); ↑ reaches "All results for …"
3123
+ setSearchActive(q && searchUi.items.length > 1 ? 1 : searchUi.items.length ? 0 : -1)
3124
+ function optHtml(it, i) { return '<a class="cmdk-opt" role="option" id="cmdk-o' + i + '" data-i="' + i + '" href="' + esc(it.href || '#') + '"' + (it.ext ? ' target="_blank" rel="noopener"' : '') + (it.action ? ' data-palette-action="' + esc(it.action) + '"' : '') + ' aria-selected="false">' + icon(it.icon || 'search') + '<span class="co-text"><span class="co-title">' + esc(it.title) + '</span>' + (it.sub ? '<span class="co-sub">' + esc(it.sub) + '</span>' : '') + '</span></a>' }
2958
3125
  }
2959
3126
  function setSearchActive(i) {
2960
3127
  searchUi.active = i
@@ -2968,10 +3135,10 @@ async function runSearch() {
2968
3135
  if (q.length < 2) { paintSearch(); return }
2969
3136
  paintSearch()
2970
3137
  try {
2971
- const r = await api('GET', '/v1/search?q=' + encodeURIComponent(q))
3138
+ const r = await api('GET', '/v1/search?light=1&limit=20&q=' + encodeURIComponent(q))
2972
3139
  if (seq !== searchUi.seq) return
2973
3140
  searchUi.last = { q, r }
2974
- } catch { if (seq === searchUi.seq) searchUi.last = { q, r: { sessions: [], checkpoints: [] } } }
3141
+ } catch { if (seq === searchUi.seq) searchUi.last = { q, r: { sessions: [], checkpoints: [], prs: [], projects: [], lists: [], todos: [], files: [] } } }
2975
3142
  if (searchIsOpen()) paintSearch()
2976
3143
  }
2977
3144
  document.getElementById('searchBtn').addEventListener('click', () => openSearch())
@@ -2983,7 +3150,8 @@ document.getElementById('q').addEventListener('keydown', (e) => {
2983
3150
  })
2984
3151
  document.getElementById('cmdk').addEventListener('click', (e) => {
2985
3152
  if (e.target.closest('[data-cmdk-close]') || e.target === e.currentTarget) { closeSearch(); return }
2986
- if (e.target.closest('.cmdk-opt')) closeSearch()
3153
+ const o = e.target.closest('.cmdk-opt')
3154
+ if (o) { const it = searchUi.items[Number(o.dataset.i)]; rememberItem(it); if (it && it.action) { e.preventDefault(); runPaletteItem(it); return } closeSearch() }
2987
3155
  })
2988
3156
  document.getElementById('cmdk').addEventListener('mousemove', (e) => { const o = e.target.closest('.cmdk-opt'); if (o && Number(o.dataset.i) !== searchUi.active) setSearchActive(Number(o.dataset.i)) })
2989
3157
  document.addEventListener('keydown', (e) => {
@@ -3013,7 +3181,10 @@ document.addEventListener('click', (ev) => {
3013
3181
  if (String(window.getSelection && window.getSelection()).trim()) return
3014
3182
  togglePr(row.dataset.pr)
3015
3183
  })
3016
- document.addEventListener('toggle', (ev) => { if (ev.target && ev.target.matches && ev.target.matches('details.prgroup.quiet')) prsUi.quietOpen = ev.target.open }, true)
3184
+ document.addEventListener('toggle', (ev) => {
3185
+ if (ev.target && ev.target.matches && ev.target.matches('details.prgroup.quiet')) prsUi.quietOpen = ev.target.open
3186
+ if (ev.target && ev.target.matches && ev.target.matches('details.prgroup.done')) prsUi.doneOpen[ev.target.dataset.group] = ev.target.open // 0.9.0
3187
+ }, true)
3017
3188
  let prsQTimer = null
3018
3189
  document.addEventListener('input', (ev) => {
3019
3190
  if (ev.target.id !== 'prs-q') return
@@ -3052,6 +3223,7 @@ document.addEventListener('click', async (ev) => {
3052
3223
  const f = { ...prsUi.filter }, k = btn.dataset.k, v = btn.dataset.v || ''
3053
3224
  if (k === 'repo') f.repos = v ? toggleRepo(f.repos || [], { key: btn.dataset.key || v, label: v }, ev.shiftKey || ev.metaKey || ev.ctrlKey) : []
3054
3225
  else if (k === 'state') f.state = f.state === v ? null : v
3226
+ else if (k === 'project') { f.project = f.project === v || !v ? null : v; if (f.project) f.repos = [] } // 0.9.0
3055
3227
  return setPrsFilter(f)
3056
3228
  }
3057
3229
  if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
@@ -3284,7 +3456,17 @@ document.addEventListener('click', async (ev) => {
3284
3456
  if (action === 'tl-session') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: btn.dataset.session || null }); return }
3285
3457
  if (action === 'tl-trigger') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, trigger: btn.dataset.trigger || null }); return }
3286
3458
  if (action === 'tl-clear') { saveRepos(window.localStorage, []); location.hash = '#/timeline?repo='; return }
3287
- if (action === 'clear-filters') { state.moreOpen = false; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: null }) }
3459
+ if (action === 'clear-filters') { state.moreOpen = false; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: null, project: null }) }
3460
+ // Projects (0.9.0): the filter chips, the chip on a card, and the menus
3461
+ if (action === 'board-project' || action === 'project-filter') {
3462
+ ev.preventDefault(); ev.stopPropagation()
3463
+ const cur = (state.boardFilter || {}).project || null
3464
+ const id = btn.dataset.project || null
3465
+ const next = action === 'board-project' && cur === id ? null : id
3466
+ if (!state.route || state.route.name !== 'board') { saveProject(window.localStorage, next); location.hash = filterHash({ repos: [], status: null, project: next }); return }
3467
+ return setBoardFilter({ project: next, repos: next && next !== cur ? [] : undefined })
3468
+ }
3469
+ if (action && action.startsWith('project-')) { ev.preventDefault(); ev.stopPropagation(); return projectAction(action, btn) }
3288
3470
  // Spaces (v0.5.8)
3289
3471
  if (action === 'board-space') {
3290
3472
  state.boardSpace = btn.dataset.space || null
@@ -3361,7 +3543,7 @@ document.addEventListener('click', async (ev) => {
3361
3543
  if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
3362
3544
  } else if (action === 'checkpoint') {
3363
3545
  btn.disabled = true; btn.textContent = 'Writing…'
3364
- if (!btn.isConnected) toast('Writing a checkpoint…', 20000) // from the ⋯ menu, which has closed
3546
+ if (!btn.isConnected || btn.classList.contains('palette-proxy')) toast('Writing a checkpoint…', 20000) // from the ⋯ menu (closed) or ⌘K
3365
3547
  const cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true })
3366
3548
  toast('Checkpoint saved · Next: ' + cp.next)
3367
3549
  render()
@@ -3375,14 +3557,29 @@ document.addEventListener('click', async (ev) => {
3375
3557
  digestUi.standups = {}; digestUi.date = null
3376
3558
  location.hash = '#/digest/' + d.meta.date
3377
3559
  render()
3560
+ } else if (action === 'title-auto') {
3561
+ toast('Naming it from the latest messages…', 20000)
3562
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/title/auto')
3563
+ toast('Named “' + r.title + '” from the latest messages')
3564
+ render()
3378
3565
  } else if (action === 'rename') {
3379
3566
  const box = btn.closest('.title')
3380
3567
  if (!box || box.querySelector('input')) return
3381
3568
  const current = s ? s.title || '' : btn.dataset.title || ''
3382
- box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title" maxlength="120" enterkeyhint="done">'
3569
+ box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title" maxlength="120" enterkeyhint="done">' +
3570
+ (s && s.isLocal === false ? '' : '<button type="button" class="btn icon-btn title-auto" aria-label="Regenerate from recent messages" title="Regenerate from recent messages">↻</button>')
3383
3571
  const input = box.querySelector('input')
3384
3572
  input.focus(); input.select()
3385
3573
  let finished = false
3574
+ // 0.9.0: ↻ names it from the latest messages (like /cp-name --auto); pressed before the box loses focus
3575
+ const regen = box.querySelector('.title-auto')
3576
+ if (regen) regen.addEventListener('pointerdown', async (e) => {
3577
+ e.preventDefault(); e.stopPropagation()
3578
+ finished = true
3579
+ regen.disabled = true; input.disabled = true
3580
+ try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/title/auto'); toast('Named “' + r.title + '” from the latest messages') } catch (err) { toast(err.message, 5000) }
3581
+ render()
3582
+ })
3386
3583
  const done = async (save) => {
3387
3584
  if (finished) return
3388
3585
  finished = true
@@ -3707,7 +3904,10 @@ document.addEventListener('submit', async (ev) => {
3707
3904
  ev.preventDefault()
3708
3905
  const q = document.getElementById('q').value.trim()
3709
3906
  const it = searchUi.items[searchUi.active]
3907
+ rememberItem(it)
3908
+ if (it && it.action) { runPaletteItem(it); return }
3710
3909
  closeSearch()
3910
+ if (it && it.ext) { window.open(it.href, '_blank', 'noopener'); return }
3711
3911
  location.hash = it ? it.href : q ? '#/search?q=' + encodeURIComponent(q) : location.hash
3712
3912
  } else if (ev.target.id === 'settingsForm') {
3713
3913
  ev.preventDefault()
@@ -3761,7 +3961,8 @@ function connect() {
3761
3961
  if (stream) { try { stream.close() } catch {} }
3762
3962
  const es = stream = new EventSource('/v1/stream')
3763
3963
  es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
3764
- for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, soon)
3964
+ es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
3965
+ for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'projects.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, soon)
3765
3966
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
3766
3967
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
3767
3968
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
package/ui/icons.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // so they follow light and dark; always aria-hidden next to a text label.
4
4
  // index.html carries the nav's copies inline (no script needed to draw them).
5
5
  export const ICONS = {
6
+ "folder": "<path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\"/>",
7
+ "trash": "<path d=\"M3 6h18\"/> <path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"/> <path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"/> <line x1=\"10\" x2=\"10\" y1=\"11\" y2=\"17\"/> <line x1=\"14\" x2=\"14\" y1=\"11\" y2=\"17\"/>",
6
8
  "clipboard": "<rect width=\"8\" height=\"4\" x=\"8\" y=\"2\" rx=\"1\" ry=\"1\"/> <path d=\"M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2\"/>",
7
9
  "layout-dashboard": "<rect width=\"7\" height=\"9\" x=\"3\" y=\"3\" rx=\"1\"/> <rect width=\"7\" height=\"5\" x=\"14\" y=\"3\" rx=\"1\"/> <rect width=\"7\" height=\"9\" x=\"14\" y=\"12\" rx=\"1\"/> <rect width=\"7\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/>",
8
10
  "coffee": "<path d=\"M10 2v2\"/> <path d=\"M14 2v2\"/> <path d=\"M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1\"/> <path d=\"M6 2v2\"/>",