@leftoff/cli 0.8.8 → 0.9.0
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 +31 -1
- package/assets/sample-transcript.jsonl +12 -12
- package/bin/leftoff.js +377 -371
- 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 +138 -16
- package/ui/app.js +348 -56
- package/ui/changelog-md.js +6 -2
- package/ui/handoffs.js +62 -5
- package/ui/icons.js +4 -0
- package/ui/picker.js +38 -1
- package/ui/prs.js +134 -43
- package/ui/repos.js +23 -3
package/ui/app.js
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
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'
|
|
11
11
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
12
12
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
|
|
13
13
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
14
|
-
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
|
|
14
|
+
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName } from './handoffs.js'
|
|
15
15
|
const $app = document.getElementById('app')
|
|
16
16
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
17
17
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
@@ -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,12 +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)
|
|
1105
|
+
const canRename = s.isLocal !== false && !s.agent && !/^agent:/.test(String(s.id || ''))
|
|
1106
|
+
html += '<div class="pagehead"><div>' + (canRename
|
|
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>'
|
|
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>' +
|
|
1000
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>'
|
|
1001
1110
|
html += '<div class="facts">' +
|
|
1002
|
-
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)) +
|
|
1003
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) +
|
|
1004
1113
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
1005
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>'
|
|
@@ -1022,17 +1131,23 @@ async function viewSession(id) {
|
|
|
1022
1131
|
state.listsMayEdit = state.mayAnswer !== false
|
|
1023
1132
|
html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
|
|
1024
1133
|
html += '<h2>Checkpoints <span class="count">' + s.checkpointCount + '</span></h2>'
|
|
1025
|
-
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)
|
|
1026
1135
|
if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
|
|
1027
|
-
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) =>
|
|
1028
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>' +
|
|
1029
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>' +
|
|
1030
1147
|
cpBlock(cp, { showWhen: false, more: true, open: cp === newestCp }) +
|
|
1031
1148
|
(cp.userNote ? '<div class="note">✎ ' + esc(cp.userNote) + '</div>' : '') +
|
|
1032
1149
|
(cp.filesTouched && cp.filesTouched.length ? '<div class="files">' + cp.filesTouched.map((f) => '<span class="chip mono">' + esc(f) + '</span>').join('') + '</div>' : '') +
|
|
1033
|
-
'</div>').join('')
|
|
1034
|
-
html += answeredList(s)
|
|
1035
|
-
$app.innerHTML = html
|
|
1150
|
+
'</div>').join('')
|
|
1036
1151
|
}
|
|
1037
1152
|
// Space of one session (or, on an agent's page, of that agent's feed): its
|
|
1038
1153
|
// own override, or back to the repo / Mac default (v0.5.8).
|
|
@@ -1050,10 +1165,10 @@ function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') +
|
|
|
1050
1165
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
1051
1166
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
1052
1167
|
// the Mac that has it) and caches ~5 min; while it loads we look again shortly.
|
|
1053
|
-
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: {} }
|
|
1054
1169
|
// 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
|
|
1055
1170
|
// tap repaints from what we have and reads again only when that is old.
|
|
1056
|
-
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 } }
|
|
1057
1172
|
function paintPrs(d) {
|
|
1058
1173
|
const q = document.getElementById('prs-q')
|
|
1059
1174
|
const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
|
|
@@ -1072,6 +1187,7 @@ async function viewPrs() {
|
|
|
1072
1187
|
view = view === 'team' ? 'team' : 'mine'
|
|
1073
1188
|
prsUi.view = view
|
|
1074
1189
|
prsUi.filter = readPrFilter(params)
|
|
1190
|
+
await loadProjects()
|
|
1075
1191
|
const cached = prsUi.data[view]
|
|
1076
1192
|
paintPrs(cached || null)
|
|
1077
1193
|
const fresh = prsUi.fresh; prsUi.fresh = false
|
|
@@ -1102,7 +1218,7 @@ async function togglePr(url, { retry = false } = {}) {
|
|
|
1102
1218
|
const d = prsUi.data[prsUi.view]
|
|
1103
1219
|
const r = prRows(d).find((x) => x.pr.url === url)
|
|
1104
1220
|
const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
|
|
1105
|
-
if (el && r) el.outerHTML = prRowHtml(r, { ...prsOpts(prsUi.view, prsUi.filter), team: prsUi.view === 'team', now: Date.now(), showMachine: !!d.multiMachine, open: prsUi.open.has(url), detail: prsUi.details[url] })
|
|
1221
|
+
if (el && r) el.outerHTML = prRowHtml(r, { ...prsOpts(prsUi.view, prsUi.filter), team: prsUi.view === 'team', now: Date.now(), showMachine: !!d.multiMachine, me: d.me || null, open: prsUi.open.has(url), detail: prsUi.details[url] })
|
|
1106
1222
|
}
|
|
1107
1223
|
const have = prsUi.details[url]
|
|
1108
1224
|
if (!prsUi.open.has(url) || (have && !retry && (have.loading || (have.ok && Date.now() - have.at < 60000)))) return repaint()
|
|
@@ -1199,7 +1315,7 @@ function renderMd(md, { spaces = [] } = {}) {
|
|
|
1199
1315
|
}
|
|
1200
1316
|
if (/^\s{2,}\S/.test(line) && inList) {
|
|
1201
1317
|
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
1202
|
-
const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
|
|
1318
|
+
const t = line.trim(), sm = t.match(/^(Stopped at|Next|Last reply|Doing|Latest|Needs you|Merged|Commits): ([\s\S]*?)(?: (Next): ([\s\S]*))?$/)
|
|
1203
1319
|
if (sm) out.push('<span class="sub"><span class="k">' + sm[1] + '</span> ' + inline(sm[2]) + '</span>' + (sm[3] ? '<span class="sub"><span class="k">' + sm[3] + '</span> ' + inline(sm[4]) + '</span>' : ''))
|
|
1204
1320
|
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
1205
1321
|
continue
|
|
@@ -2903,7 +3019,7 @@ window.addEventListener('resize', () => { placeStatus() })
|
|
|
2903
3019
|
// ⌘K search: a centered panel with the results grouped as you type. Enter on
|
|
2904
3020
|
// nothing picked opens the full results page (#/search?q=…), as before.
|
|
2905
3021
|
const searchUi = { seq: 0, items: [], active: -1, timer: null, last: null }
|
|
2906
|
-
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']]
|
|
2907
3023
|
const searchIsOpen = () => !document.getElementById('cmdk').hidden
|
|
2908
3024
|
function openSearch(prefill) {
|
|
2909
3025
|
const box = document.getElementById('cmdk'), q = document.getElementById('q')
|
|
@@ -2923,19 +3039,73 @@ function closeSearch() {
|
|
|
2923
3039
|
document.documentElement.classList.remove('cmdk-open')
|
|
2924
3040
|
clearTimeout(searchUi.timer)
|
|
2925
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
|
+
}
|
|
2926
3066
|
function searchGroups(q, r) {
|
|
2927
3067
|
const low = q.toLowerCase()
|
|
2928
3068
|
const groups = []
|
|
2929
|
-
const pages = PAGES.filter(([n]) => !low || n
|
|
2930
|
-
const
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
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])
|
|
2934
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)])
|
|
2935
3087
|
if (sets.length) groups.push(['Settings', sets.slice(0, 5)])
|
|
2936
3088
|
if (pages.length) groups.push(['Go to', pages.slice(0, low ? 4 : 8)])
|
|
2937
3089
|
return groups
|
|
2938
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
|
+
}
|
|
2939
3109
|
function paintSearch() {
|
|
2940
3110
|
const q = document.getElementById('q').value.trim()
|
|
2941
3111
|
const list = document.getElementById('cmdkList')
|
|
@@ -2947,10 +3117,11 @@ function paintSearch() {
|
|
|
2947
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) } }
|
|
2948
3118
|
if (q) h = optHtml(searchUi.items[0], 0) + h
|
|
2949
3119
|
if (q && q.length >= 2 && !r) h += '<div class="cmdk-note">Searching…</div>'
|
|
2950
|
-
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>'
|
|
2951
3121
|
list.innerHTML = h
|
|
2952
|
-
|
|
2953
|
-
|
|
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>' }
|
|
2954
3125
|
}
|
|
2955
3126
|
function setSearchActive(i) {
|
|
2956
3127
|
searchUi.active = i
|
|
@@ -2964,10 +3135,10 @@ async function runSearch() {
|
|
|
2964
3135
|
if (q.length < 2) { paintSearch(); return }
|
|
2965
3136
|
paintSearch()
|
|
2966
3137
|
try {
|
|
2967
|
-
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))
|
|
2968
3139
|
if (seq !== searchUi.seq) return
|
|
2969
3140
|
searchUi.last = { q, r }
|
|
2970
|
-
} 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: [] } } }
|
|
2971
3142
|
if (searchIsOpen()) paintSearch()
|
|
2972
3143
|
}
|
|
2973
3144
|
document.getElementById('searchBtn').addEventListener('click', () => openSearch())
|
|
@@ -2979,7 +3150,8 @@ document.getElementById('q').addEventListener('keydown', (e) => {
|
|
|
2979
3150
|
})
|
|
2980
3151
|
document.getElementById('cmdk').addEventListener('click', (e) => {
|
|
2981
3152
|
if (e.target.closest('[data-cmdk-close]') || e.target === e.currentTarget) { closeSearch(); return }
|
|
2982
|
-
|
|
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() }
|
|
2983
3155
|
})
|
|
2984
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)) })
|
|
2985
3157
|
document.addEventListener('keydown', (e) => {
|
|
@@ -3009,7 +3181,10 @@ document.addEventListener('click', (ev) => {
|
|
|
3009
3181
|
if (String(window.getSelection && window.getSelection()).trim()) return
|
|
3010
3182
|
togglePr(row.dataset.pr)
|
|
3011
3183
|
})
|
|
3012
|
-
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)
|
|
3013
3188
|
let prsQTimer = null
|
|
3014
3189
|
document.addEventListener('input', (ev) => {
|
|
3015
3190
|
if (ev.target.id !== 'prs-q') return
|
|
@@ -3022,6 +3197,8 @@ document.addEventListener('input', (ev) => {
|
|
|
3022
3197
|
document.addEventListener('keydown', (ev) => { if (ev.target.id === 'prs-q' && ev.key === 'Escape' && ev.target.value) { ev.preventDefault(); ev.target.value = ''; ev.target.dispatchEvent(new Event('input', { bubbles: true })) } })
|
|
3023
3198
|
|
|
3024
3199
|
window.addEventListener('hashchange', () => closeActionMenu())
|
|
3200
|
+
// 0.8.9: a session page's title is a button too (Enter or Space renames it)
|
|
3201
|
+
document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"]')) { e.preventDefault(); t.click() } })
|
|
3025
3202
|
// 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
|
|
3026
3203
|
document.addEventListener('keydown', (ev) => {
|
|
3027
3204
|
const b = ev.target && ev.target.closest && ev.target.closest('[data-action="card-more"]')
|
|
@@ -3045,7 +3222,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
3045
3222
|
if (action === 'prs-filter') {
|
|
3046
3223
|
const f = { ...prsUi.filter }, k = btn.dataset.k, v = btn.dataset.v || ''
|
|
3047
3224
|
if (k === 'repo') f.repos = v ? toggleRepo(f.repos || [], { key: btn.dataset.key || v, label: v }, ev.shiftKey || ev.metaKey || ev.ctrlKey) : []
|
|
3048
|
-
else if (k === 'state'
|
|
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
|
|
3049
3227
|
return setPrsFilter(f)
|
|
3050
3228
|
}
|
|
3051
3229
|
if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
|
|
@@ -3278,7 +3456,17 @@ document.addEventListener('click', async (ev) => {
|
|
|
3278
3456
|
if (action === 'tl-session') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: btn.dataset.session || null }); return }
|
|
3279
3457
|
if (action === 'tl-trigger') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, trigger: btn.dataset.trigger || null }); return }
|
|
3280
3458
|
if (action === 'tl-clear') { saveRepos(window.localStorage, []); location.hash = '#/timeline?repo='; return }
|
|
3281
|
-
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) }
|
|
3282
3470
|
// Spaces (v0.5.8)
|
|
3283
3471
|
if (action === 'board-space') {
|
|
3284
3472
|
state.boardSpace = btn.dataset.space || null
|
|
@@ -3327,6 +3515,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
3327
3515
|
}
|
|
3328
3516
|
if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
|
|
3329
3517
|
if (action === 'doc-close') return closeDoc()
|
|
3518
|
+
if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
|
|
3519
|
+
if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
|
|
3330
3520
|
if (action === 'need-restore' || action === 'need-restore-all') {
|
|
3331
3521
|
btn.disabled = true
|
|
3332
3522
|
state.dismissedOpen = true
|
|
@@ -3353,7 +3543,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
3353
3543
|
if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
|
|
3354
3544
|
} else if (action === 'checkpoint') {
|
|
3355
3545
|
btn.disabled = true; btn.textContent = 'Writing…'
|
|
3356
|
-
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
|
|
3357
3547
|
const cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true })
|
|
3358
3548
|
toast('Checkpoint saved · Next: ' + cp.next)
|
|
3359
3549
|
render()
|
|
@@ -3367,24 +3557,41 @@ document.addEventListener('click', async (ev) => {
|
|
|
3367
3557
|
digestUi.standups = {}; digestUi.date = null
|
|
3368
3558
|
location.hash = '#/digest/' + d.meta.date
|
|
3369
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()
|
|
3370
3565
|
} else if (action === 'rename') {
|
|
3371
3566
|
const box = btn.closest('.title')
|
|
3372
3567
|
if (!box || box.querySelector('input')) return
|
|
3373
|
-
const current = s ? s.title || '' : ''
|
|
3374
|
-
box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title">'
|
|
3568
|
+
const current = s ? s.title || '' : btn.dataset.title || ''
|
|
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>')
|
|
3375
3571
|
const input = box.querySelector('input')
|
|
3376
3572
|
input.focus(); input.select()
|
|
3377
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
|
+
})
|
|
3378
3583
|
const done = async (save) => {
|
|
3379
3584
|
if (finished) return
|
|
3380
3585
|
finished = true
|
|
3381
3586
|
if (save && input.value.trim() && input.value.trim() !== current) {
|
|
3382
|
-
|
|
3383
|
-
|
|
3587
|
+
try {
|
|
3588
|
+
await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { title: input.value.trim() })
|
|
3589
|
+
toast('Renamed. With the mod, the session and its tab title follow within a minute')
|
|
3590
|
+
} catch (e) { toast(e.message, 5000) }
|
|
3384
3591
|
}
|
|
3385
3592
|
render()
|
|
3386
3593
|
}
|
|
3387
|
-
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') done(true)
|
|
3594
|
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); done(true) } if (e.key === 'Escape') { e.stopPropagation(); done(false) } })
|
|
3388
3595
|
input.addEventListener('blur', () => done(true))
|
|
3389
3596
|
}
|
|
3390
3597
|
} catch (e) { toast(e.message); btn.disabled = false }
|
|
@@ -3507,6 +3714,7 @@ async function todoDoneChange(cb) {
|
|
|
3507
3714
|
// The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
|
|
3508
3715
|
// that owns it (through another Mac or the relay from a phone). Markdown is
|
|
3509
3716
|
// rendered from escaped text; HTML files show as source; anything else downloads.
|
|
3717
|
+
const docUi = { r: null, name: '', printing: null }
|
|
3510
3718
|
function docViewerEl() {
|
|
3511
3719
|
let el = document.getElementById('docViewer')
|
|
3512
3720
|
if (el) return el
|
|
@@ -3517,12 +3725,15 @@ function docViewerEl() {
|
|
|
3517
3725
|
el.setAttribute('role', 'dialog')
|
|
3518
3726
|
el.setAttribute('aria-modal', 'true')
|
|
3519
3727
|
el.setAttribute('aria-labelledby', 'docViewerTitle')
|
|
3520
|
-
|
|
3728
|
+
// 0.8.9: Download is a small menu (Copy / Download as Markdown / Download as PDF…), our own, not a native one
|
|
3729
|
+
el.innerHTML = '<div class="dv-sheet"><div class="dv-head"><div class="dv-title" id="docViewerTitle"></div>' +
|
|
3730
|
+
'<button type="button" class="btn sm dv-dl" data-action="doc-dl" aria-haspopup="menu" aria-expanded="false" hidden>' + icon('download') + '<span class="dl-word">Download</span><span class="chev" aria-hidden="true">▾</span></button>' +
|
|
3731
|
+
'<button type="button" class="dv-x" data-action="doc-close" aria-label="Close">×</button></div><div class="dv-sub faint"></div><div class="dv-ptitle" aria-hidden="true"></div><div class="dv-body"></div></div>'
|
|
3521
3732
|
el.addEventListener('click', (ev) => { if (ev.target === el) closeDoc() })
|
|
3522
3733
|
document.body.appendChild(el)
|
|
3523
3734
|
return el
|
|
3524
3735
|
}
|
|
3525
|
-
function closeDoc() { const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = '' } document.documentElement.classList.remove('dv-open') }
|
|
3736
|
+
function closeDoc() { closeActionMenu(); endDocPrint(); const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = '' } docUi.r = null; document.documentElement.classList.remove('dv-open') }
|
|
3526
3737
|
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
3527
3738
|
async function openDoc(sessionId, { path, plan } = {}) {
|
|
3528
3739
|
if (!sessionId || (!path && !plan)) return
|
|
@@ -3531,7 +3742,8 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
3531
3742
|
el.querySelector('.dv-title').textContent = name
|
|
3532
3743
|
el.querySelector('.dv-sub').textContent = ''
|
|
3533
3744
|
el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
|
|
3534
|
-
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
3745
|
+
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
3746
|
+
docUi.r = null; docUi.name = name
|
|
3535
3747
|
el.hidden = false
|
|
3536
3748
|
document.documentElement.classList.add('dv-open')
|
|
3537
3749
|
const qs = new URLSearchParams(plan ? { plan } : { path })
|
|
@@ -3541,12 +3753,88 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
3541
3753
|
el.querySelector('.dv-title').textContent = r.name || name
|
|
3542
3754
|
el.querySelector('.dv-sub').textContent = [r.plan ? 'Plan' : r.kind === 'new' ? 'New' : r.kind === 'edited' ? 'Edited' : '', r.size ? (r.size < 1024 ? r.size + ' B' : Math.round(r.size / 1024) + ' KB') : '', r.peer ? 'from ' + r.peer : ''].filter(Boolean).join(' · ')
|
|
3543
3755
|
el.querySelector('.dv-body').innerHTML = docViewerBody(r)
|
|
3544
|
-
|
|
3545
|
-
|
|
3756
|
+
el.querySelector('.dv-ptitle').textContent = docFileName(r, name)
|
|
3757
|
+
docUi.r = r; docUi.name = name
|
|
3758
|
+
const items = docMenuItems(r)
|
|
3759
|
+
if (items.length) {
|
|
3760
|
+
dl.hidden = false
|
|
3761
|
+
const menu = items.length > 1
|
|
3762
|
+
dl.setAttribute('aria-haspopup', menu ? 'menu' : 'false')
|
|
3763
|
+
dl.querySelector('.chev').hidden = !menu
|
|
3764
|
+
dl.title = menu ? 'Copy or download' : 'Download ' + docFileName(r, name)
|
|
3765
|
+
dl.setAttribute('aria-label', menu ? 'Copy or download' : 'Download')
|
|
3766
|
+
}
|
|
3546
3767
|
} catch (e) {
|
|
3547
3768
|
el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(e.message) + '</div>'
|
|
3548
3769
|
}
|
|
3549
3770
|
}
|
|
3771
|
+
function openDocMenu(btn) {
|
|
3772
|
+
const r = docUi.r
|
|
3773
|
+
const items = docMenuItems(r)
|
|
3774
|
+
if (!items.length) return
|
|
3775
|
+
if (items.length === 1) return docAction(items[0].key)
|
|
3776
|
+
const ic = { copy: 'copy', download: 'download', pdf: 'file-text' }
|
|
3777
|
+
openActionMenu(btn, items.map((it) => ({ label: it.label, icon: icon(ic[it.key] || 'download'), attrs: { 'data-action': 'doc-' + it.key, title: it.title } })), { title: docFileName(r, docUi.name) })
|
|
3778
|
+
}
|
|
3779
|
+
async function copyRaw(text) {
|
|
3780
|
+
if (await clipWrite(text)) return true
|
|
3781
|
+
const ta = document.createElement('textarea')
|
|
3782
|
+
ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;font-size:16px'
|
|
3783
|
+
document.body.appendChild(ta); ta.select()
|
|
3784
|
+
let ok = false
|
|
3785
|
+
try { ok = document.execCommand('copy') } catch {}
|
|
3786
|
+
ta.remove()
|
|
3787
|
+
return ok
|
|
3788
|
+
}
|
|
3789
|
+
async function docAction(key) {
|
|
3790
|
+
const r = docUi.r
|
|
3791
|
+
if (!r) return
|
|
3792
|
+
const btn = document.querySelector('#docViewer .dv-dl')
|
|
3793
|
+
if (key === 'copy') {
|
|
3794
|
+
const ok = await copyRaw(String(r.text || ''))
|
|
3795
|
+
if (!ok) { toast('Couldn’t copy here. Download it instead.', 4000); return }
|
|
3796
|
+
toast('Copied ' + docFileName(r, docUi.name))
|
|
3797
|
+
if (btn) {
|
|
3798
|
+
const word = btn.querySelector('.dl-word')
|
|
3799
|
+
btn.classList.add('copied'); if (word) word.textContent = 'Copied'
|
|
3800
|
+
clearTimeout(docUi.copiedTimer)
|
|
3801
|
+
docUi.copiedTimer = setTimeout(() => { btn.classList.remove('copied'); if (word) word.textContent = 'Download' }, 1500)
|
|
3802
|
+
}
|
|
3803
|
+
return
|
|
3804
|
+
}
|
|
3805
|
+
if (key === 'download') {
|
|
3806
|
+
const a = document.createElement('a')
|
|
3807
|
+
a.href = docDownloadHref(r); a.download = docFileName(r, docUi.name); a.rel = 'noopener'
|
|
3808
|
+
document.body.appendChild(a); a.click(); a.remove()
|
|
3809
|
+
return
|
|
3810
|
+
}
|
|
3811
|
+
if (key === 'pdf') return printDoc()
|
|
3812
|
+
}
|
|
3813
|
+
// Download as PDF: the browser's print dialog over just this file (print
|
|
3814
|
+
// styles in app.css), in the light theme, titled with the file's name, so
|
|
3815
|
+
// "Save as PDF" makes a clean PDF. No PDF library.
|
|
3816
|
+
function printDoc() {
|
|
3817
|
+
const r = docUi.r
|
|
3818
|
+
if (!r || r.type !== 'markdown') return
|
|
3819
|
+
endDocPrint()
|
|
3820
|
+
const root = document.documentElement
|
|
3821
|
+
docUi.printing = { title: document.title, theme: root.getAttribute('data-theme') }
|
|
3822
|
+
root.setAttribute('data-theme', 'light')
|
|
3823
|
+
root.classList.add('dv-printing')
|
|
3824
|
+
document.title = docFileName(r, docUi.name).replace(/\.(md|markdown)$/i, '')
|
|
3825
|
+
window.addEventListener('afterprint', endDocPrint, { once: true })
|
|
3826
|
+
toast('Choose “Save as PDF” in the print dialog', 5000)
|
|
3827
|
+
setTimeout(() => { try { window.print() } catch { endDocPrint() } }, 60)
|
|
3828
|
+
}
|
|
3829
|
+
function endDocPrint() {
|
|
3830
|
+
const p = docUi.printing
|
|
3831
|
+
if (!p) return
|
|
3832
|
+
docUi.printing = null
|
|
3833
|
+
const root = document.documentElement
|
|
3834
|
+
root.classList.remove('dv-printing')
|
|
3835
|
+
if (p.theme) root.setAttribute('data-theme', p.theme); else root.removeAttribute('data-theme')
|
|
3836
|
+
document.title = p.title
|
|
3837
|
+
}
|
|
3550
3838
|
|
|
3551
3839
|
// Dismiss a need (0.6.5): gone from every card and count at once, kept on
|
|
3552
3840
|
// the daemon (so your other devices and the relay see it too), Undo for 5 s.
|
|
@@ -3616,7 +3904,10 @@ document.addEventListener('submit', async (ev) => {
|
|
|
3616
3904
|
ev.preventDefault()
|
|
3617
3905
|
const q = document.getElementById('q').value.trim()
|
|
3618
3906
|
const it = searchUi.items[searchUi.active]
|
|
3907
|
+
rememberItem(it)
|
|
3908
|
+
if (it && it.action) { runPaletteItem(it); return }
|
|
3619
3909
|
closeSearch()
|
|
3910
|
+
if (it && it.ext) { window.open(it.href, '_blank', 'noopener'); return }
|
|
3620
3911
|
location.hash = it ? it.href : q ? '#/search?q=' + encodeURIComponent(q) : location.hash
|
|
3621
3912
|
} else if (ev.target.id === 'settingsForm') {
|
|
3622
3913
|
ev.preventDefault()
|
|
@@ -3670,7 +3961,8 @@ function connect() {
|
|
|
3670
3961
|
if (stream) { try { stream.close() } catch {} }
|
|
3671
3962
|
const es = stream = new EventSource('/v1/stream')
|
|
3672
3963
|
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
3673
|
-
|
|
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)
|
|
3674
3966
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
3675
3967
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
3676
3968
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|