@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/CHANGELOG.md +26 -1
- package/assets/sample-transcript.jsonl +12 -12
- package/bin/leftoff.js +377 -372
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +2 -1
- package/mod/hooks/register.js +14 -4
- package/package.json +1 -1
- package/ui/app.css +36 -0
- package/ui/app.js +244 -43
- package/ui/icons.js +2 -0
- package/ui/picker.js +38 -1
- package/ui/prs.js +61 -14
- package/ui/repos.js +23 -3
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) + ' ' + 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>' : '') + ' ' + 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.
|
|
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('')
|
|
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
|
|
2934
|
-
const
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
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">
|
|
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
|
-
|
|
2957
|
-
|
|
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
|
-
|
|
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) => {
|
|
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
|
|
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
|
-
|
|
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\"/>",
|