@leftoff/cli 0.6.8 → 0.6.9
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 +10 -0
- package/bin/leftoff.js +258 -255
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +62 -0
- package/ui/app.js +62 -4
- package/ui/index.html +1 -0
- package/ui/prs.js +128 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/core.js
CHANGED
|
@@ -494,7 +494,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
494
494
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
495
495
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
496
496
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
497
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend']
|
|
497
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked']
|
|
498
498
|
|
|
499
499
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
500
500
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
package/mod/hooks/register.js
CHANGED
|
@@ -27,7 +27,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
27
27
|
const MODEL_TIMEOUT_MS = 20000
|
|
28
28
|
|
|
29
29
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
30
|
-
const MOD_VERSION = '0.6.
|
|
30
|
+
const MOD_VERSION = '0.6.9' // keep in step with .claude-plugin/plugin.json
|
|
31
31
|
|
|
32
32
|
const st = {
|
|
33
33
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -1379,3 +1379,65 @@ a.need-pr:hover { border-color: var(--line-strong); }
|
|
|
1379
1379
|
@media (max-width: 640px) { .form .set-advbody { gap: 12px 18px; } }
|
|
1380
1380
|
.form .set-advbody > .section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
|
|
1381
1381
|
.form .hint.nomt { margin-top: 0; }
|
|
1382
|
+
|
|
1383
|
+
/* PRs (0.6.9): a card's PRs, the session page's list and Link a PR, the PRs tab */
|
|
1384
|
+
.pr-num { font-weight: 700; font-family: var(--mono); }
|
|
1385
|
+
.pr-state { font-weight: 600; color: var(--green); }
|
|
1386
|
+
.pr-state.draft, .pr-state.closed { color: var(--muted); }
|
|
1387
|
+
.pr-state.merged { color: var(--accent); }
|
|
1388
|
+
.pr-checks { color: var(--muted); white-space: nowrap; }
|
|
1389
|
+
.pr-checks.pass { color: var(--green); }
|
|
1390
|
+
.pr-checks.fail { color: var(--red); }
|
|
1391
|
+
.pr-checks.pending { color: var(--yellow); }
|
|
1392
|
+
.card-prs { display: flex; flex-direction: column; gap: 4px; margin: 8px 0 2px; min-width: 0; }
|
|
1393
|
+
.cpr { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 12.5px; color: var(--text); text-decoration: none; background: var(--card2); border: 1px solid var(--line); border-radius: 8px; padding: 4px 9px; }
|
|
1394
|
+
a.cpr:hover { border-color: var(--line-strong); }
|
|
1395
|
+
.cpr .pr-title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; }
|
|
1396
|
+
.cpr .pr-num, .cpr .pr-state, .cpr .pr-checks { flex: none; }
|
|
1397
|
+
.cpr.more { color: var(--muted); justify-content: center; }
|
|
1398
|
+
.prs-filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 14px; }
|
|
1399
|
+
.prgroup { margin-bottom: 18px; }
|
|
1400
|
+
.prgroup h2 { font-size: 14px; margin: 14px 0 8px; }
|
|
1401
|
+
.prgroup.attention h2 { color: var(--red); }
|
|
1402
|
+
.prrow { display: flex; gap: 10px; align-items: flex-start; background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin-bottom: 8px; min-width: 0; }
|
|
1403
|
+
.prrow.slim { background: var(--card2); }
|
|
1404
|
+
.pr-main { flex: 1 1 auto; min-width: 0; }
|
|
1405
|
+
.pr-line1 { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 9px; font-size: 12.5px; }
|
|
1406
|
+
.pr-att { color: var(--red); font-weight: 600; }
|
|
1407
|
+
.pr-review { color: var(--muted); }
|
|
1408
|
+
.pr-review.approved { color: var(--green); }
|
|
1409
|
+
.pr-review.changes_requested { color: var(--red); }
|
|
1410
|
+
.prrow .pr-title { font-weight: 500; margin-top: 3px; overflow-wrap: anywhere; }
|
|
1411
|
+
.prrow .pr-title a { color: var(--text); text-decoration: none; }
|
|
1412
|
+
.prrow .pr-title a:hover { text-decoration: underline; }
|
|
1413
|
+
.pr-meta { font-size: 12px; color: var(--faint); margin-top: 2px; overflow-wrap: anywhere; }
|
|
1414
|
+
.pr-sessions { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
|
|
1415
|
+
.pr-sess { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; min-width: 0; }
|
|
1416
|
+
.pr-sess .sesslink { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
|
|
1417
|
+
.pr-sess .sesslink::before { content: '↳ '; color: var(--faint); }
|
|
1418
|
+
.pr-actions { display: flex; flex-direction: column; gap: 6px; flex: none; align-items: stretch; }
|
|
1419
|
+
.prlist { margin-bottom: 8px; }
|
|
1420
|
+
.prlink { display: flex; gap: 8px; margin: 6px 0 16px; }
|
|
1421
|
+
.prlink input { flex: 1 1 auto; min-width: 0; font-size: 16px; }
|
|
1422
|
+
.prs-gh { margin-top: 6px; }
|
|
1423
|
+
.prs-foot { font-size: 12px; margin-top: 6px; }
|
|
1424
|
+
@media (max-width: 639.98px) {
|
|
1425
|
+
.tabs { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
1426
|
+
.prrow { flex-direction: column; gap: 8px; }
|
|
1427
|
+
.pr-actions { flex-direction: row; flex-wrap: wrap; }
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/* 0.6.9: "Closed (N) →" under the Board's grid */
|
|
1431
|
+
.closed-link { margin: 18px 0 6px; text-align: center; }
|
|
1432
|
+
.closed-link .linkish { color: var(--muted); font-size: 13.5px; padding: 8px 12px; }
|
|
1433
|
+
.closed-link .linkish:hover { color: var(--text); }
|
|
1434
|
+
|
|
1435
|
+
/* 0.6.9: the Board's stat tiles read as filters you can tap: a lift on hover,
|
|
1436
|
+
a press on touch, and the picked one clearly on (accent ring, tint, number). */
|
|
1437
|
+
.stat { transition: border-color .15s, background .15s, transform .12s, box-shadow .15s; -webkit-tap-highlight-color: transparent; }
|
|
1438
|
+
@media (hover: hover) { .stat:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: var(--card-shadow), 0 4px 14px rgba(0, 0, 0, .12); } }
|
|
1439
|
+
.stat:active { transform: scale(.97); }
|
|
1440
|
+
.stat:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
1441
|
+
.stat.on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent), var(--card-shadow); background: color-mix(in srgb, var(--accent) 12%, var(--card)); }
|
|
1442
|
+
.stat.on .n { color: var(--accent); }
|
|
1443
|
+
.stat.on .k { color: var(--text); }
|
package/ui/app.js
CHANGED
|
@@ -8,6 +8,7 @@ import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, to
|
|
|
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
|
+
import { cardPrsHtml, sessionPrsSection, prsPageHtml } from './prs.js'
|
|
11
12
|
const $app = document.getElementById('app')
|
|
12
13
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
13
14
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
@@ -297,7 +298,7 @@ function card(s) {
|
|
|
297
298
|
'</div>' + pill(s.status) + '</div>' +
|
|
298
299
|
'<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' ? ' · no prompt yet' : '') + '</span>' +
|
|
299
300
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
300
|
-
listChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
301
|
+
cardPrsHtml(s.prs) + listChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
301
302
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
302
303
|
'<div class="actions-row">' + goButton(s) +
|
|
303
304
|
'<button type="button" class="btn" data-action="resume">Copy resume</button>' +
|
|
@@ -414,7 +415,7 @@ async function viewBoard() {
|
|
|
414
415
|
const needs = needsItems(sessions)
|
|
415
416
|
html += needsStrip(needs, needOpts())
|
|
416
417
|
html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
|
|
417
|
-
'<button type="button" class="stat' + (filter === k ? ' on' : '') + '" data-action="filter" data-filter="' + k + '"><div class="n">' + count(k) + '</div><div class="k"><span class="pill ' + fl.cls + '" aria-hidden="true"></span>' + esc(fl.label) + '</div></button>').join('') + '</div>'
|
|
418
|
+
'<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) + '</div><div class="k"><span class="pill ' + fl.cls + '" aria-hidden="true"></span>' + esc(fl.label) + '</div></button>').join('') + '</div>'
|
|
418
419
|
html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
|
|
419
420
|
if (filter === 'closed') {
|
|
420
421
|
html += await closedSection(f, sp)
|
|
@@ -432,6 +433,8 @@ async function viewBoard() {
|
|
|
432
433
|
html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
|
|
433
434
|
}
|
|
434
435
|
html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
|
|
436
|
+
// 0.6.9: the Closed view was only a small stat tile; a quiet way in under the grid too.
|
|
437
|
+
if (closedCount > 0) html += '<div class="closed-link"><button type="button" class="linkish" data-action="filter" data-filter="closed">Closed (' + esc(closedCount) + ') →</button></div>'
|
|
435
438
|
$app.innerHTML = banners() + html
|
|
436
439
|
revealStat()
|
|
437
440
|
}
|
|
@@ -744,6 +747,7 @@ async function viewSession(id) {
|
|
|
744
747
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
745
748
|
fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
746
749
|
html += spacePicker(s, spv, 'session', s.id)
|
|
750
|
+
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
747
751
|
if (s.needsYou) html += '<section class="needs-strip one" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>'
|
|
748
752
|
if (s.statusChanges && s.statusChanges.length) {
|
|
749
753
|
html += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" title="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
|
|
@@ -782,6 +786,42 @@ function spacePicker(s, spv, kind, key) {
|
|
|
782
786
|
}
|
|
783
787
|
function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
|
|
784
788
|
|
|
789
|
+
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
790
|
+
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
791
|
+
// the Mac that has it) and caches ~5 min; while it loads we look again shortly.
|
|
792
|
+
const prsUi = { fresh: false, polls: 0, timer: null, data: {} }
|
|
793
|
+
async function viewPrs() {
|
|
794
|
+
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
795
|
+
let view = params.get('view')
|
|
796
|
+
if (!view) { try { view = localStorage.getItem('leftoff.prsView') } catch {} }
|
|
797
|
+
view = view === 'team' ? 'team' : 'mine'
|
|
798
|
+
const opts = { view, mayEdit: true, sessionAction: prSessionAction }
|
|
799
|
+
if (!prsUi.data[view]) $app.innerHTML = banners() + prsPageHtml(null, opts)
|
|
800
|
+
const fresh = prsUi.fresh; prsUi.fresh = false
|
|
801
|
+
let d
|
|
802
|
+
try { d = await api('GET', '/v1/prs?view=' + view + (fresh ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
803
|
+
catch (e) { if (e.code === 'login_required') throw e; d = { error: 'Couldn’t load PRs: ' + e.message } }
|
|
804
|
+
if (!(state.route && state.route.name === 'prs')) return
|
|
805
|
+
if (d && d.groups) {
|
|
806
|
+
// Go / Resume on a row look the session up like a card does.
|
|
807
|
+
for (const g of d.groups) for (const r of g.rows || []) for (const x of r.sessions || []) if (!state.sessions.some((y) => y.id === x.id)) state.sessions.push(x)
|
|
808
|
+
d.multiMachine = new Set(state.sessions.map((x) => x.machine).filter(Boolean)).size > 1
|
|
809
|
+
}
|
|
810
|
+
prsUi.data[view] = d
|
|
811
|
+
$app.innerHTML = banners() + prsPageHtml(d, opts)
|
|
812
|
+
clearTimeout(prsUi.timer)
|
|
813
|
+
if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') render() }, 2500) }
|
|
814
|
+
else if (!(d && d.loading)) prsUi.polls = 0
|
|
815
|
+
}
|
|
816
|
+
function prSessionAction(s) {
|
|
817
|
+
if (s.live === 'closed' || s.status === 'ended') {
|
|
818
|
+
const b = resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty })
|
|
819
|
+
return b ? b.replace('class="btn primary"', 'class="btn sm"') : ''
|
|
820
|
+
}
|
|
821
|
+
const g = goButton(s)
|
|
822
|
+
return g && !/disabled/.test(g) ? g.replace('class="btn go', 'class="btn sm go') : ''
|
|
823
|
+
}
|
|
824
|
+
|
|
785
825
|
// Timeline (0.4.3): the board's repo chips (same remembered pick), a session
|
|
786
826
|
// filter and a trigger filter. #/timeline?repo=a,b&session=<id>&trigger=manual
|
|
787
827
|
const TRIGGER_FILTERS = { manual: { label: 'Manual', test: (t) => t === 'manual' }, auto: { label: 'Automatic', test: (t) => t !== 'manual' && t !== 'session_end' }, closed: { label: 'Closed', test: (t) => t === 'session_end' } }
|
|
@@ -2374,6 +2414,7 @@ async function render() {
|
|
|
2374
2414
|
if (r.name === 's' && r.arg) await viewSession(r.arg)
|
|
2375
2415
|
else if (r.name === 'waiting') await viewWaiting()
|
|
2376
2416
|
else if (r.name === 'timeline') await viewTimeline()
|
|
2417
|
+
else if (r.name === 'prs') await viewPrs()
|
|
2377
2418
|
else if (r.name === 'digest') await viewDigest(r.arg, r.params.get('machine'))
|
|
2378
2419
|
else if (r.name === 'catchup') await viewCatchup(r.arg)
|
|
2379
2420
|
else if (r.name === 'lists') await viewLists(r.arg)
|
|
@@ -2551,7 +2592,7 @@ window.addEventListener('resize', () => { placeStatus() })
|
|
|
2551
2592
|
// ⌘K search: a centered panel with the results grouped as you type. Enter on
|
|
2552
2593
|
// nothing picked opens the full results page (#/search?q=…), as before.
|
|
2553
2594
|
const searchUi = { seq: 0, items: [], active: -1, timer: null, last: null }
|
|
2554
|
-
const PAGES = [['Board', '#/'], ['Needs you', '#/waiting'], ['Lists', '#/lists'], ['Timeline', '#/timeline'], ['Digest', '#/digest'], ['Catch me up', '#/catchup/new'], ['What’s new', '#/whats-new'], ['Settings', '#/settings']]
|
|
2595
|
+
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']]
|
|
2555
2596
|
const searchIsOpen = () => !document.getElementById('cmdk').hidden
|
|
2556
2597
|
function openSearch(prefill) {
|
|
2557
2598
|
const box = document.getElementById('cmdk'), q = document.getElementById('q')
|
|
@@ -2662,6 +2703,14 @@ document.addEventListener('click', async (ev) => {
|
|
|
2662
2703
|
const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
|
|
2663
2704
|
try {
|
|
2664
2705
|
if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
|
|
2706
|
+
// PRs (0.6.9)
|
|
2707
|
+
if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = v === 'team' ? '#/prs?view=team' : '#/prs'; return }
|
|
2708
|
+
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
2709
|
+
if (action === 'pr-unlink') {
|
|
2710
|
+
btn.disabled = true
|
|
2711
|
+
try { await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/prs', { ref: btn.dataset.url, unlink: true }); toast('Unlinked') } catch (e) { btn.disabled = false; toast(e.message, 5000); return }
|
|
2712
|
+
return render()
|
|
2713
|
+
}
|
|
2665
2714
|
if (action === 'gh-check') {
|
|
2666
2715
|
btn.disabled = true
|
|
2667
2716
|
try { state.gh = await api('GET', '/v1/gh?fresh=1') } catch { state.gh = null }
|
|
@@ -3089,6 +3138,15 @@ function goClick(btn, id, s) {
|
|
|
3089
3138
|
}
|
|
3090
3139
|
|
|
3091
3140
|
document.addEventListener('submit', async (ev) => {
|
|
3141
|
+
if (ev.target.dataset && ev.target.dataset.prLink) {
|
|
3142
|
+
ev.preventDefault()
|
|
3143
|
+
const input = ev.target.querySelector('input[name=ref]')
|
|
3144
|
+
const ref = input.value.trim()
|
|
3145
|
+
if (!ref) { input.focus(); return }
|
|
3146
|
+
const b = ev.target.querySelector('button'); if (b) b.disabled = true
|
|
3147
|
+
try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(ev.target.dataset.prLink) + '/prs', { ref }); input.value = ''; toast('Linked #' + String(r.url || '').split('/').pop()) ; await render() } catch (e) { toast(e.message, 5000) } finally { if (b) b.disabled = false }
|
|
3148
|
+
return
|
|
3149
|
+
}
|
|
3092
3150
|
if (ev.target.dataset && ev.target.dataset.listAdd) {
|
|
3093
3151
|
ev.preventDefault()
|
|
3094
3152
|
const input = ev.target.querySelector('input[name=text]')
|
|
@@ -3154,7 +3212,7 @@ let stream = null
|
|
|
3154
3212
|
function connect() {
|
|
3155
3213
|
if (stream) { try { stream.close() } catch {} }
|
|
3156
3214
|
const es = stream = new EventSource('/v1/stream')
|
|
3157
|
-
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated']) es.addEventListener(t, soon)
|
|
3215
|
+
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, soon)
|
|
3158
3216
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
3159
3217
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
3160
3218
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
package/ui/index.html
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
<a href="#/" data-route="board"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><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"/></svg><span class="lbl">Board</span></a>
|
|
30
30
|
<a href="#/waiting" data-route="waiting" id="needsTab"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
|
|
31
31
|
<a href="#/lists" data-route="lists" id="listsTab"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M13 5h8"/> <path d="M13 12h8"/> <path d="M13 19h8"/> <path d="m3 17 2 2 4-4"/> <path d="m3 7 2 2 4-4"/></svg><span class="lbl">Lists</span><span class="navcount" id="listsCount" hidden></span></a>
|
|
32
|
+
<a href="#/prs" data-route="prs" id="prsTab"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="18" cy="18" r="3"/> <circle cx="6" cy="6" r="3"/> <path d="M13 6h3a2 2 0 0 1 2 2v7"/> <line x1="6" x2="6" y1="9" y2="21"/></svg><span class="lbl">PRs</span></a>
|
|
32
33
|
<a href="#/timeline" data-route="timeline"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
|
|
33
34
|
<a href="#/digest" data-route="digest"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span class="lbl">Digest</span></a>
|
|
34
35
|
</nav>
|
package/ui/prs.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The PRs tab and PR rows (0.6.9). Pure HTML builders, no fetching: app.js
|
|
2
|
+
// loads /v1/prs and /v1/sessions/:id and paints with these. Sessions and PRs
|
|
3
|
+
// are many-to-many; a row shows every session it is linked to.
|
|
4
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
5
|
+
const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
|
|
6
|
+
|
|
7
|
+
export function shortAgo(iso, now = Date.now()) {
|
|
8
|
+
const t = Date.parse(iso || '')
|
|
9
|
+
if (!t) return ''
|
|
10
|
+
const s = Math.max(0, Math.round((now - t) / 1000))
|
|
11
|
+
if (s < 60) return 'now'
|
|
12
|
+
if (s < 3600) return Math.round(s / 60) + 'm'
|
|
13
|
+
if (s < 86400) return Math.round(s / 3600) + 'h'
|
|
14
|
+
return Math.round(s / 86400) + 'd'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ✓ 3/3, ✕ 1 failing, ◌ 2 running — the class says which.
|
|
18
|
+
export function checksHtml(ck, { compact = false } = {}) {
|
|
19
|
+
if (!ck || !ck.total) return ''
|
|
20
|
+
const cls = ck.failed ? 'fail' : ck.pending ? 'pending' : 'pass'
|
|
21
|
+
const ic = { fail: '✕', pending: '◌', pass: '✓' }[cls]
|
|
22
|
+
const full = ck.summary || (ck.total - ck.failed - ck.pending) + '/' + ck.total + ' checks passed'
|
|
23
|
+
// On a card: ✕ 1/3 (failing of all), ◌ 1/2 (passed so far), ✓ 3/3.
|
|
24
|
+
const short = (ck.failed ? ck.failed : ck.total - ck.failed - ck.pending) + '/' + ck.total
|
|
25
|
+
return '<span class="pr-checks ' + cls + '"' + (compact ? ' title="' + esc(full) + '" aria-label="' + esc(full) + '"' : '') + '><span aria-hidden="true">' + ic + '</span> ' + esc(compact ? short : full) + '</span>'
|
|
26
|
+
}
|
|
27
|
+
const STATE = { open: 'Open', draft: 'Draft', merged: 'Merged', closed: 'Closed' }
|
|
28
|
+
export function stateHtml(state) {
|
|
29
|
+
if (!state) return ''
|
|
30
|
+
return '<span class="pr-state ' + esc(state) + '">' + esc(STATE[state] || state) + '</span>'
|
|
31
|
+
}
|
|
32
|
+
const REVIEW = { approved: 'Approved', changes_requested: 'Changes requested', review_required: 'Review needed' }
|
|
33
|
+
|
|
34
|
+
// A session card's PRs: one compact line each (#, title, state, checks), three at most.
|
|
35
|
+
export function cardPrsHtml(prs) {
|
|
36
|
+
const list = (prs || []).filter((p) => p && p.number)
|
|
37
|
+
if (!list.length) return ''
|
|
38
|
+
const shown = list.slice(0, 3)
|
|
39
|
+
const more = list.length - shown.length
|
|
40
|
+
return '<div class="card-prs">' + shown.map((p) => {
|
|
41
|
+
const url = safeUrl(p.url)
|
|
42
|
+
const inner = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + '<span class="pr-title">' + esc(p.title || p.repo || '') + '</span>' + checksHtml(p.checks, { compact: true })
|
|
43
|
+
return url ? '<a class="cpr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="' + esc((p.repo ? p.repo + ' ' : '') + '#' + p.number + ' on GitHub') + '">' + inner + '</a>' : '<span class="cpr">' + inner + '</span>'
|
|
44
|
+
}).join('') + (more > 0 ? '<a class="cpr more" href="#/prs">+' + more + ' more</a>' : '') + '</div>'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
|
|
48
|
+
export function sessionPrsSection(s, { mayEdit = true } = {}) {
|
|
49
|
+
const list = (s.prs || []).filter((p) => p && p.number)
|
|
50
|
+
let h = '<h2>Pull requests' + (list.length ? ' <span class="count">' + list.length + '</span>' : '') + '</h2>'
|
|
51
|
+
if (list.length) {
|
|
52
|
+
h += '<div class="prlist">' + list.map((p) => {
|
|
53
|
+
const url = safeUrl(p.url)
|
|
54
|
+
return '<div class="prrow slim"><div class="pr-main">' +
|
|
55
|
+
'<div class="pr-line1"><span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) + '</div>' +
|
|
56
|
+
'<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || 'Pull request #' + p.number) + ' ↗</a>' : esc(p.title || '')) + '</div>' +
|
|
57
|
+
'<div class="pr-meta">' + esc(p.repo || '') + (p.via ? ' · ' + esc(VIA[p.via] || p.via) : '') + '</div></div>' +
|
|
58
|
+
(mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div>'
|
|
59
|
+
}).join('') + '</div>'
|
|
60
|
+
} else h += '<div class="nocp">No pull requests linked yet. They link on their own from gh pr create, PR links in the chat, or the branch.</div>'
|
|
61
|
+
if (mayEdit) h += '<form class="prlink" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false"><button type="submit" class="btn">Link</button></form>'
|
|
62
|
+
return h
|
|
63
|
+
}
|
|
64
|
+
const VIA = { created: 'opened here', mentioned: 'mentioned', branch: 'this branch', manual: 'linked by hand' }
|
|
65
|
+
|
|
66
|
+
// gh missing or signed out: the 0.6.5 status line with the fix to copy.
|
|
67
|
+
export function ghLineHtml(g) {
|
|
68
|
+
if (!g || g.status === 'ready') return ''
|
|
69
|
+
const what = g.status === 'signed-out' ? 'gh is installed but not signed in to GitHub, so PRs can’t load.' : 'GitHub’s gh isn’t on this Mac, so PRs can’t load.'
|
|
70
|
+
let h = '<div class="ghcell prs-gh"><div class="ghrow"><span class="chip warn">' + (g.status === 'signed-out' ? 'Signed out' : 'Missing') + '</span><span class="ghwhat">' + what + '</span>' +
|
|
71
|
+
'<button type="button" class="linkish" data-action="prs-refresh">Check again</button></div>'
|
|
72
|
+
if (g.fix && g.fix.cmd) h += '<div class="joinline ghfix"><code class="cmd">' + esc(g.fix.cmd) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(g.fix.cmd) + '">Copy</button></div>'
|
|
73
|
+
if (g.fix && g.fix.url) h += '<div class="hint">Install gh from <a href="' + esc(g.fix.url) + '" target="_blank" rel="noopener">cli.github.com</a>, then run this.</div>'
|
|
74
|
+
return h + '</div>'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sessionLinks(r, opts) {
|
|
78
|
+
const ss = r.sessions || []
|
|
79
|
+
if (!ss.length) return ''
|
|
80
|
+
return '<div class="pr-sessions">' + ss.slice(0, 4).map((s) => {
|
|
81
|
+
const act = opts.sessionAction ? opts.sessionAction(s) : ''
|
|
82
|
+
return '<span class="pr-sess"><a href="#/s/' + encodeURIComponent(s.id) + '" class="sesslink">' + esc(s.title || 'Untitled session') + '</a>' + (s.machine && opts.showMachine ? '<span class="faint"> · ' + esc(s.machine) + '</span>' : '') + act + '</span>'
|
|
83
|
+
}).join('') + (ss.length > 4 ? '<span class="faint">+' + (ss.length - 4) + '</span>' : '') + '</div>'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// One PR row on the PRs tab.
|
|
87
|
+
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false } = {}) {
|
|
88
|
+
const p = r.pr || {}
|
|
89
|
+
const url = safeUrl(p.url)
|
|
90
|
+
const line1 = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) +
|
|
91
|
+
(r.attention && r.attention !== 'Checks failing' ? '<span class="pr-att">' + esc(r.attention) + '</span>' : p.review && REVIEW[p.review] ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
|
|
92
|
+
const meta = team
|
|
93
|
+
? [p.author ? '@' + p.author : '', p.createdAt ? 'opened ' + shortAgo(p.createdAt, now) + ' ago' : '', p.branch || ''].filter(Boolean)
|
|
94
|
+
: [p.repo, p.branch, p.state === 'merged' && p.mergedAt ? 'merged ' + shortAgo(p.mergedAt, now) + ' ago' : p.updatedAt ? 'updated ' + shortAgo(p.updatedAt, now) + ' ago' : ''].filter(Boolean)
|
|
95
|
+
const unlinkTo = !team && mayEdit && (r.sessions || []).length === 1 ? r.sessions[0] : null
|
|
96
|
+
return '<article class="prrow" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
|
|
97
|
+
'<div class="pr-line1">' + line1 + '</div>' +
|
|
98
|
+
'<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || '') + '</a>' : esc(p.title || '')) + '</div>' +
|
|
99
|
+
'<div class="pr-meta">' + meta.map(esc).join(' · ') + '</div>' +
|
|
100
|
+
sessionLinks(r, { sessionAction, showMachine }) + '</div>' +
|
|
101
|
+
'<div class="pr-actions">' + (url ? '<a class="btn sm" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">GitHub ↗</a>' : '') +
|
|
102
|
+
(unlinkTo ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(unlinkTo.id) + '" data-url="' + esc(p.url) + '" title="Unlink from this session">Unlink</button>' : '') + '</div></article>'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The whole tab: Mine / Team switch, the gh line, groups.
|
|
106
|
+
export function prsPageHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null } = {}) {
|
|
107
|
+
let h = '<div class="pagehead"><div><h1>Pull requests</h1><div class="sub">' + (view === 'team' ? 'Every open PR in the repos you work in.' : 'PRs linked to your sessions.') + '</div></div></div>'
|
|
108
|
+
h += '<div class="filters prs-filters"><div class="seg" role="group" aria-label="Whose PRs">' +
|
|
109
|
+
[['mine', 'Mine'], ['team', 'Team']].map(([k, l]) => '<button type="button" data-action="prs-view" data-view="' + k + '" aria-pressed="' + (view === k) + '">' + l + '</button>').join('') +
|
|
110
|
+
'</div><button type="button" class="linkish" data-action="prs-refresh">Refresh</button></div>'
|
|
111
|
+
if (!d) return h + '<div class="empty">Loading pull requests…</div>'
|
|
112
|
+
if (d.error) return h + '<div class="banner warn"><span>' + esc(d.error) + '</span></div>'
|
|
113
|
+
if (d.gh && d.gh.status !== 'ready') return h + ghLineHtml(d.gh)
|
|
114
|
+
const groups = (d.groups || []).filter((g) => g.rows.length || (view === 'team' && g.failed))
|
|
115
|
+
const showMachine = !!d.multiMachine
|
|
116
|
+
if (!groups.length) {
|
|
117
|
+
h += '<div class="empty">' + (d.loading ? 'Loading pull requests…' : view === 'team' ? (d.repos && d.repos.length ? 'No open PRs in ' + esc(d.repos.join(', ')) + '.' : 'None of your sessions are in a GitHub repo yet.') : 'No PRs linked to your sessions yet. They link on their own when a session runs gh pr create, mentions a PR, or works on a branch with an open PR.') + '</div>'
|
|
118
|
+
} else {
|
|
119
|
+
for (const g of groups) {
|
|
120
|
+
h += '<section class="prgroup' + (g.key === 'attention' ? ' attention' : '') + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>'
|
|
121
|
+
if (g.failed && !g.rows.length) h += '<div class="hint">Couldn’t load this repo’s PRs right now.</div>'
|
|
122
|
+
h += g.rows.map((r) => prRowHtml(r, { team: view === 'team', now, mayEdit, sessionAction, showMachine })).join('') + '</section>'
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const foot = [d.servedBy && d.machine && d.servedBy !== d.machine ? 'From ' + d.servedBy + '’s gh' : '', d.loading ? 'Updating…' : d.updatedAt ? 'Updated ' + shortAgo(d.updatedAt, now) + (shortAgo(d.updatedAt, now) === 'now' ? '' : ' ago') : ''].filter(Boolean)
|
|
126
|
+
if (foot.length) h += '<div class="prs-foot faint">' + esc(foot.join(' · ')) + '</div>'
|
|
127
|
+
return h
|
|
128
|
+
}
|