@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.6.8",
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',
@@ -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.8' // keep in step with .claude-plugin/plugin.json
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.8",
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
+ }