@leftoff/cli 0.6.8 → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.6.8",
3
+ "version": "0.7.0",
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', 'todos', 'files']
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.7.0' // 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.7.0",
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,143 @@ 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); }
1444
+
1445
+ /* ---- To do and Files (0.7.0) ---- */
1446
+ .todo-subcount { font-size: 13px; font-weight: 500; color: var(--yellow); background: var(--tint-yellow); border-radius: 999px; padding: 2px 9px; margin-left: 6px; vertical-align: middle; }
1447
+ .stat .todo-subn { display: inline-block; margin-left: 6px; font-size: 11px; font-weight: 500; color: var(--yellow); vertical-align: middle; white-space: nowrap; }
1448
+ .todos-sec { margin-top: 18px; }
1449
+ .todo-head h2 { color: var(--yellow); }
1450
+ .todo-head .count { background: var(--yellow); color: #1a1405; }
1451
+ .todo-group { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; margin-top: 10px; }
1452
+ .todo-sess { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
1453
+ .todo-sess a { color: var(--text2); text-decoration: none; font-weight: 600; }
1454
+ ul.todos { list-style: none; margin: 0; padding: 0; }
1455
+ li.todo { display: flex; gap: 10px; align-items: flex-start; padding: 8px 0; border-top: 1px solid var(--line); }
1456
+ .todo-group li.todo:first-child, .stodos li.todo:first-child { border-top: 0; }
1457
+ li.todo.ticked { opacity: .45; }
1458
+ li.todo.ticked .todo-text { text-decoration: line-through; }
1459
+ .todo-check { position: relative; flex: none; width: 24px; height: 24px; margin-top: 1px; cursor: pointer; }
1460
+ .todo-check input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
1461
+ .todo-check .box { display: block; width: 22px; height: 22px; border: 2px solid var(--line-strong); border-radius: 6px; background: var(--field); }
1462
+ .todo-check input:checked + .box { background: var(--green); border-color: var(--green); }
1463
+ .todo-check input:focus-visible + .box { box-shadow: 0 0 0 3px var(--focus); }
1464
+ .todo-main { min-width: 0; flex: 1; }
1465
+ .todo-text { color: var(--text); font-size: 14.5px; line-height: 1.4; overflow-wrap: anywhere; }
1466
+ .todo-text code, .todo-code code { font-family: var(--mono); font-size: 12.5px; }
1467
+ .todo-codes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
1468
+ .todo-code { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; padding: 4px 8px; color: var(--text2); cursor: pointer; font: inherit; }
1469
+ .todo-code code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
1470
+ .todo-code .todo-cp { flex: none; font-size: 11.5px; color: var(--accent); }
1471
+ .todo-meta { margin-top: 3px; font-size: 12px; color: var(--faint); }
1472
+ .todo-add { display: flex; gap: 8px; margin-top: 10px; }
1473
+ .todo-add input { flex: 1; min-width: 0; font-size: 16px; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--field); color: var(--text); }
1474
+ .todo-add input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
1475
+ .handoff-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; }
1476
+ .handoff-chips .chip { text-decoration: none; }
1477
+ .chip.todochip { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 35%, transparent); background: var(--tint-yellow); }
1478
+ .chip.fileschip { color: var(--cyan); border-color: color-mix(in srgb, var(--cyan) 35%, transparent); background: var(--tint-cyan); }
1479
+ .closed-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1480
+ .chip.docs-filter { cursor: pointer; font: inherit; font-size: 13px; padding: 5px 12px; }
1481
+ .chip.docs-filter[aria-pressed="true"] { color: var(--cyan); border-color: var(--cyan); background: var(--tint-cyan); }
1482
+ ul.files-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); overflow: hidden; }
1483
+ ul.files-list li + li { border-top: 1px solid var(--line); }
1484
+ .f-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 11px 12px; background: none; border: 0; color: var(--text); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
1485
+ .f-row:hover { background: var(--subtle); }
1486
+ .f-ic { flex: none; min-width: 38px; text-align: center; font-size: 10px; font-weight: 700; letter-spacing: .03em; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: 3px 4px; }
1487
+ .f-ic.art { color: var(--accent); font-size: 13px; padding: 1px 4px; }
1488
+ .f-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
1489
+ .f-name.mono { font-family: var(--mono); font-size: 13px; }
1490
+ .f-kind { flex: none; font-size: 12px; color: var(--muted); }
1491
+ .f-kind.new { color: var(--green); }
1492
+ .f-at { flex: none; font-size: 12px; color: var(--faint); }
1493
+ .f-code { margin: 8px 2px 0; font-size: 13px; }
1494
+ .doc-viewer { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.55); display: flex; align-items: stretch; justify-content: center; padding: max(16px, env(safe-area-inset-top)) 12px 12px; }
1495
+ .doc-viewer[hidden] { display: none; }
1496
+ html.dv-open, html.dv-open body { overflow: hidden; }
1497
+ .dv-sheet { width: 100%; max-width: 820px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); display: flex; flex-direction: column; overflow: hidden; }
1498
+ .dv-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
1499
+ .dv-title { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1500
+ .dv-x { flex: none; width: 36px; height: 36px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); color: var(--text); font-size: 20px; line-height: 1; cursor: pointer; }
1501
+ .dv-sub { padding: 6px 14px 0; font-size: 12px; }
1502
+ .dv-body { flex: 1; overflow: auto; padding: 8px 16px 24px; -webkit-overflow-scrolling: touch; }
1503
+ .doc-md { font-size: 15px; line-height: 1.55; color: var(--text2); overflow-wrap: anywhere; }
1504
+ .doc-md h2, .doc-md h3, .doc-md h4 { color: var(--text); margin: 18px 0 8px; }
1505
+ .doc-md h2, .doc-md h3, .doc-md h4 { text-transform: none; letter-spacing: normal; }
1506
+ .doc-md h2 { font-size: 20px; } .doc-md h3 { font-size: 17px; } .doc-md h4 { font-size: 15px; }
1507
+ .doc-md code { font-family: var(--mono); font-size: 13px; background: var(--bg2); border-radius: 5px; padding: 1px 5px; }
1508
+ .doc-md pre.doc-code, .doc-pre { background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.45; white-space: pre; }
1509
+ .doc-md pre.doc-code code { background: none; padding: 0; }
1510
+ .doc-md blockquote { margin: 10px 0; padding: 2px 12px; border-left: 3px solid var(--line-strong); color: var(--muted); }
1511
+ .doc-md li.sub { margin-left: 18px; }
1512
+ .doc-md li.task { list-style: none; margin-left: -18px; }
1513
+ .doc-md .tbox { display: inline-block; width: 13px; height: 13px; border: 1.5px solid var(--line-strong); border-radius: 3px; margin-right: 7px; vertical-align: -1px; }
1514
+ .doc-md .tbox.on { background: var(--green); border-color: var(--green); }
1515
+ .doc-table { overflow-x: auto; }
1516
+ .doc-md table { border-collapse: collapse; font-size: 13.5px; }
1517
+ .doc-md table { overflow-wrap: normal; word-break: normal; }
1518
+ .doc-md th { white-space: nowrap; }
1519
+ .doc-md th, .doc-md td { border: 1px solid var(--line); padding: 5px 8px; text-align: left; }
1520
+ .doc-img { text-align: center; padding-top: 10px; }
1521
+ .doc-img img { max-width: 100%; height: auto; border-radius: 8px; background: #fff; }
package/ui/app.js CHANGED
@@ -8,6 +8,8 @@ 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'
12
+ import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
11
13
  const $app = document.getElementById('app')
12
14
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
13
15
  const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
@@ -297,7 +299,7 @@ function card(s) {
297
299
  '</div>' + pill(s.status) + '</div>' +
298
300
  '<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
301
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
300
- listChips(s) + nb + cpBlock(s.latestCheckpoint) +
302
+ cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
301
303
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
302
304
  '<div class="actions-row">' + goButton(s) +
303
305
  '<button type="button" class="btn" data-action="resume">Copy resume</button>' +
@@ -370,7 +372,7 @@ function chipBar(chips, selected, allCount, emptyNote) {
370
372
  async function viewBoard() {
371
373
  if (!state.health) await refreshHealth()
372
374
  if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
373
- const { sessions, viewer, closedCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
375
+ const { sessions, viewer, closedCount, todoCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
374
376
  state.sessions = sessions
375
377
  state.viewerLocal = viewer ? viewer.local !== false : true
376
378
  state.ghostty = viewer ? viewer.ghostty === true : false
@@ -413,8 +415,10 @@ async function viewBoard() {
413
415
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
414
416
  const needs = needsItems(sessions)
415
417
  html += needsStrip(needs, needOpts())
418
+ // 0.7.0: open to-dos are their own sub-count, not part of the Needs you number
419
+ const todoN = f.repos.length || sp ? inRepos.reduce((n, s) => n + ((s.todos && s.todos.length) || 0), 0) : (todoCount || 0)
416
420
  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>'
421
+ '<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>'
418
422
  html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
419
423
  if (filter === 'closed') {
420
424
  html += await closedSection(f, sp)
@@ -432,6 +436,8 @@ async function viewBoard() {
432
436
  html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
433
437
  }
434
438
  html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
439
+ // 0.6.9: the Closed view was only a small stat tile; a quiet way in under the grid too.
440
+ 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
441
  $app.innerHTML = banners() + html
436
442
  revealStat()
437
443
  }
@@ -440,10 +446,11 @@ async function viewBoard() {
440
446
  // a search box and Show more. Each card has its last checkpoint and Resume,
441
447
  // which reopens it in a new Ghostty tab (claude --resume) on the Mac that owns it.
442
448
  const CLOSED_PAGE = 30
443
- const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set() }
449
+ const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set(), docs: false }
444
450
  function closedQuery(f, sp) {
445
451
  const qs = new URLSearchParams({ limit: String(closedUi.limit) })
446
452
  if (closedUi.q.trim()) qs.set('q', closedUi.q.trim())
453
+ if (closedUi.docs) qs.set('docs', '1')
447
454
  if (state.machine) qs.set('machine', state.machine)
448
455
  if (f.repos.length) qs.set('repo', f.repos.join(','))
449
456
  if (sp) qs.set('space', sp)
@@ -458,12 +465,14 @@ async function closedFetch(f, sp) {
458
465
  async function closedSection(f, sp) {
459
466
  const r = await closedFetch(f, sp)
460
467
  return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.total, 'nothing closed yet') +
461
- '<div class="closed-head"><div class="set-search closed-search">' + icon('search') + '<input id="closedQ" type="search" placeholder="Search closed sessions" aria-label="Search closed sessions" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(closedUi.q) + '"></div></div>' +
468
+ '<div class="closed-head"><div class="set-search closed-search">' + icon('search') + '<input id="closedQ" type="search" placeholder="Search closed sessions" aria-label="Search closed sessions" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(closedUi.q) + '"></div>' +
469
+ '<button type="button" class="chip docs-filter" data-action="closed-docs" aria-pressed="' + closedUi.docs + '" title="Only sessions that made docs, plans or artifacts">' + (closedUi.docs ? '✓ ' : '') + 'Docs &amp; artifacts</button></div>' +
462
470
  '<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
463
471
  }
464
472
  function closedListHtml(r, f, sp) {
465
473
  if (!r.sessions.length) {
466
474
  const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
475
+ if (closedUi.docs && !closedUi.q.trim()) return '<div class="empty mt">No closed sessions with docs or artifacts' + esc((f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')) + ' yet. <button type="button" class="linkish" data-action="closed-clear">Clear</button></div>'
467
476
  return '<div class="empty mt">' + (closedUi.q.trim() ? 'No closed sessions match “' + esc(closedUi.q.trim()) + '”' + esc(inWhat) + '.' : 'No closed sessions' + esc(inWhat) + ' yet.') +
468
477
  (f.repos.length || sp || closedUi.q ? ' <button type="button" class="linkish" data-action="closed-clear">Clear</button>' : '') + '</div>'
469
478
  }
@@ -494,7 +503,7 @@ function closedCard(s, viewer) {
494
503
  '<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>' +
495
504
  '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
496
505
  '<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>' +
497
- cpBlock(s.latestCheckpoint, { showWhen: false }) +
506
+ cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
498
507
  '<div class="actions-row">' + resumeButton(s, viewer) +
499
508
  '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' +
500
509
  '<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
@@ -611,11 +620,14 @@ async function viewWaiting() {
611
620
  const f = boardFilter()
612
621
  const shownSessions = applyFilter(all.map((n) => n.session), { repos: f.repos })
613
622
  const items = all.filter((n) => shownSessions.includes(n.session))
614
- let html = '<div class="pagehead"><div><h1>Needs you</h1><div class="sub">Permission prompts and questions waiting on you, longest wait first. Answer here, or Go to the terminal.' +
623
+ const todoOv = needs.todos || { open: [], done: [] }
624
+ const nTodos = (todoOv.open || []).length
625
+ let html = '<div class="pagehead"><div><h1>Needs you' + (nTodos ? ' <span class="todo-subcount">' + nTodos + (nTodos === 1 ? ' to-do' : ' to-dos') + '</span>' : '') + '</h1><div class="sub">Permission prompts and questions waiting on you, longest wait first. Answer here, or Go to the terminal.' +
615
626
  (state.mayAnswer ? '' : ' Answering from this device is off.') + '</div></div></div>'
616
627
  html += chipBar(repoChips(all.map((n) => n.session)), f.repos, all.length, 'nothing waiting')
617
628
  if (!items.length) html += '<div class="empty mt"><p><strong>Nothing waiting on you.</strong></p><p>When a session asks for permission or asks you a question, it shows up here and at the top of the board.' + (f.repos.length && all.length ? ' (' + all.length + ' in other repos. <button type="button" class="linkish" data-action="clear-filters">Show all</button>)' : '') + '</p></div>'
618
629
  else html += '<section class="needs-strip quiet mt" aria-label="Waiting"><div class="needs-list">' + items.map((n) => needPanel(n, needOpts())).join('') + '</div></section>'
630
+ html += todosSection(todoOv, { agoFn: ago, mayEdit: state.mayAnswer !== false, doneOpen: !!state.todoDoneOpen, repos: f.repos })
619
631
  const recent = needs.recent || []
620
632
  html += '<details class="recent"' + (state.recentOpen !== false ? ' open' : '') + '><summary>Recently answered <span class="count">' + recent.length + '</span></summary>' +
621
633
  (recent.length ? '<ul>' + recent.map((r) => '<li><span class="t" title="' + esc(r.at) + '">' + esc(ago(r.at)) + '</span><a href="#/s/' + encodeURIComponent(r.session.id) + '">' + esc(r.session.title || 'Untitled session') + '</a>' +
@@ -627,8 +639,10 @@ async function viewWaiting() {
627
639
  html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
628
640
  $app.innerHTML = banners() + html
629
641
  // Open/closed survives live repaints (0.6.5).
630
- const dz = $app.querySelector('details.dismissed')
642
+ const dz = $app.querySelector('details.dismissed:not(.todo-done-list)')
631
643
  if (dz) dz.addEventListener('toggle', () => { state.dismissedOpen = dz.open })
644
+ const td = $app.querySelector('details.todo-done-list')
645
+ if (td) td.addEventListener('toggle', () => { state.todoDoneOpen = td.open })
632
646
  const rc = $app.querySelector('details.recent')
633
647
  if (rc) rc.addEventListener('toggle', () => { state.recentOpen = rc.open })
634
648
  }
@@ -744,6 +758,10 @@ async function viewSession(id) {
744
758
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
745
759
  fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
746
760
  html += spacePicker(s, spv, 'session', s.id)
761
+ html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
762
+ // 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
763
+ html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
764
+ html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
747
765
  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
766
  if (s.statusChanges && s.statusChanges.length) {
749
767
  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 +800,42 @@ function spacePicker(s, spv, kind, key) {
782
800
  }
783
801
  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
802
 
803
+ // PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
804
+ // PR in the repos your sessions are in). The daemon fetches with gh (or asks
805
+ // the Mac that has it) and caches ~5 min; while it loads we look again shortly.
806
+ const prsUi = { fresh: false, polls: 0, timer: null, data: {} }
807
+ async function viewPrs() {
808
+ const params = (state.route && state.route.params) || new URLSearchParams()
809
+ let view = params.get('view')
810
+ if (!view) { try { view = localStorage.getItem('leftoff.prsView') } catch {} }
811
+ view = view === 'team' ? 'team' : 'mine'
812
+ const opts = { view, mayEdit: true, sessionAction: prSessionAction }
813
+ if (!prsUi.data[view]) $app.innerHTML = banners() + prsPageHtml(null, opts)
814
+ const fresh = prsUi.fresh; prsUi.fresh = false
815
+ let d
816
+ try { d = await api('GET', '/v1/prs?view=' + view + (fresh ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
817
+ catch (e) { if (e.code === 'login_required') throw e; d = { error: 'Couldn’t load PRs: ' + e.message } }
818
+ if (!(state.route && state.route.name === 'prs')) return
819
+ if (d && d.groups) {
820
+ // Go / Resume on a row look the session up like a card does.
821
+ 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)
822
+ d.multiMachine = new Set(state.sessions.map((x) => x.machine).filter(Boolean)).size > 1
823
+ }
824
+ prsUi.data[view] = d
825
+ $app.innerHTML = banners() + prsPageHtml(d, opts)
826
+ clearTimeout(prsUi.timer)
827
+ if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') render() }, 2500) }
828
+ else if (!(d && d.loading)) prsUi.polls = 0
829
+ }
830
+ function prSessionAction(s) {
831
+ if (s.live === 'closed' || s.status === 'ended') {
832
+ const b = resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty })
833
+ return b ? b.replace('class="btn primary"', 'class="btn sm"') : ''
834
+ }
835
+ const g = goButton(s)
836
+ return g && !/disabled/.test(g) ? g.replace('class="btn go', 'class="btn sm go') : ''
837
+ }
838
+
785
839
  // Timeline (0.4.3): the board's repo chips (same remembered pick), a session
786
840
  // filter and a trigger filter. #/timeline?repo=a,b&session=<id>&trigger=manual
787
841
  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 +2428,7 @@ async function render() {
2374
2428
  if (r.name === 's' && r.arg) await viewSession(r.arg)
2375
2429
  else if (r.name === 'waiting') await viewWaiting()
2376
2430
  else if (r.name === 'timeline') await viewTimeline()
2431
+ else if (r.name === 'prs') await viewPrs()
2377
2432
  else if (r.name === 'digest') await viewDigest(r.arg, r.params.get('machine'))
2378
2433
  else if (r.name === 'catchup') await viewCatchup(r.arg)
2379
2434
  else if (r.name === 'lists') await viewLists(r.arg)
@@ -2551,7 +2606,7 @@ window.addEventListener('resize', () => { placeStatus() })
2551
2606
  // ⌘K search: a centered panel with the results grouped as you type. Enter on
2552
2607
  // nothing picked opens the full results page (#/search?q=…), as before.
2553
2608
  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']]
2609
+ 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
2610
  const searchIsOpen = () => !document.getElementById('cmdk').hidden
2556
2611
  function openSearch(prefill) {
2557
2612
  const box = document.getElementById('cmdk'), q = document.getElementById('q')
@@ -2662,6 +2717,14 @@ document.addEventListener('click', async (ev) => {
2662
2717
  const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
2663
2718
  try {
2664
2719
  if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
2720
+ // PRs (0.6.9)
2721
+ 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 }
2722
+ if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
2723
+ if (action === 'pr-unlink') {
2724
+ btn.disabled = true
2725
+ 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 }
2726
+ return render()
2727
+ }
2665
2728
  if (action === 'gh-check') {
2666
2729
  btn.disabled = true
2667
2730
  try { state.gh = await api('GET', '/v1/gh?fresh=1') } catch { state.gh = null }
@@ -2908,6 +2971,15 @@ document.addEventListener('click', async (ev) => {
2908
2971
  if (action === 'quick-reply') return quickReplyClick(btn)
2909
2972
  if (action === 'need-more') return needMoreClick(btn)
2910
2973
  if (action === 'need-dismiss') return needDismissClick(btn)
2974
+ // ---- To do and Files (0.7.0)
2975
+ if (action === 'todo-restore' || action === 'todo-restore-all') {
2976
+ btn.disabled = true
2977
+ state.todoDoneOpen = true
2978
+ try { await api('POST', '/v1/todos/restore', action === 'todo-restore-all' ? { all: true } : { key: btn.dataset.todo }) } catch (e) { toast(e.message, 5000) }
2979
+ return render()
2980
+ }
2981
+ if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
2982
+ if (action === 'doc-close') return closeDoc()
2911
2983
  if (action === 'need-restore' || action === 'need-restore-all') {
2912
2984
  btn.disabled = true
2913
2985
  state.dismissedOpen = true
@@ -2917,7 +2989,9 @@ document.addEventListener('click', async (ev) => {
2917
2989
  if (action === 'go') return goClick(btn, id, s)
2918
2990
  if (action === 'resume-open') return resumeOpen(btn, btn.dataset.id || id)
2919
2991
  if (action === 'closed-more') { closedUi.limit += CLOSED_PAGE; btn.disabled = true; try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) } return }
2920
- if (action === 'closed-clear') { closedUi.q = ''; closedUi.limit = CLOSED_PAGE; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: 'closed' }) }
2992
+ // 0.7.0: only sessions with docs, plans or artifacts
2993
+ if (action === 'closed-docs') { closedUi.docs = !closedUi.docs; closedUi.limit = CLOSED_PAGE; btn.setAttribute('aria-pressed', String(closedUi.docs)); btn.textContent = (closedUi.docs ? '✓ ' : '') + 'Docs & artifacts'; try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) } return }
2994
+ if (action === 'closed-clear') { closedUi.docs = false; closedUi.q = ''; closedUi.limit = CLOSED_PAGE; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: 'closed' }) }
2921
2995
  if (action && action.startsWith('st-')) return stAction(action, btn)
2922
2996
  if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
2923
2997
  if (action === 'resume') {
@@ -3060,6 +3134,65 @@ async function needMoreClick(btn) {
3060
3134
  paint()
3061
3135
  }
3062
3136
 
3137
+ // Tick a to-do (0.7.0): done on every board at once, kept in the folder, Undo for 5 s.
3138
+ async function todoDoneChange(cb) {
3139
+ const key = cb.dataset.todoDone
3140
+ if (!key || !cb.checked) return
3141
+ const li = cb.closest('li.todo')
3142
+ if (li) li.classList.add('ticked')
3143
+ try { await api('POST', '/v1/todos/done', { key }) } catch (e) { cb.checked = false; if (li) li.classList.remove('ticked'); toast('Couldn’t tick it: ' + e.message, 5000); return }
3144
+ for (const s of state.sessions || []) if (s.todos) s.todos = s.todos.filter((t) => t.key !== key)
3145
+ toastUndo('Done', async () => {
3146
+ try { await api('POST', '/v1/todos/restore', { key }) } catch (e) { toast(e.message, 5000) }
3147
+ render()
3148
+ })
3149
+ setTimeout(() => { if (!busy()) render() }, 400)
3150
+ }
3151
+
3152
+ // The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
3153
+ // that owns it (through another Mac or the relay from a phone). Markdown is
3154
+ // rendered from escaped text; HTML files show as source; anything else downloads.
3155
+ function docViewerEl() {
3156
+ let el = document.getElementById('docViewer')
3157
+ if (el) return el
3158
+ el = document.createElement('div')
3159
+ el.id = 'docViewer'
3160
+ el.className = 'doc-viewer'
3161
+ el.hidden = true
3162
+ el.setAttribute('role', 'dialog')
3163
+ el.setAttribute('aria-modal', 'true')
3164
+ el.setAttribute('aria-labelledby', 'docViewerTitle')
3165
+ el.innerHTML = '<div class="dv-sheet"><div class="dv-head"><div class="dv-title" id="docViewerTitle"></div><a class="btn sm dv-dl" hidden>Download</a><button type="button" class="dv-x" data-action="doc-close" aria-label="Close">×</button></div><div class="dv-sub faint"></div><div class="dv-body"></div></div>'
3166
+ el.addEventListener('click', (ev) => { if (ev.target === el) closeDoc() })
3167
+ document.body.appendChild(el)
3168
+ return el
3169
+ }
3170
+ function closeDoc() { const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = '' } document.documentElement.classList.remove('dv-open') }
3171
+ document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
3172
+ async function openDoc(sessionId, { path, plan } = {}) {
3173
+ if (!sessionId || (!path && !plan)) return
3174
+ const el = docViewerEl()
3175
+ const name = plan ? 'Plan' : String(path).split('/').pop()
3176
+ el.querySelector('.dv-title').textContent = name
3177
+ el.querySelector('.dv-sub').textContent = ''
3178
+ el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
3179
+ const dl = el.querySelector('.dv-dl'); dl.hidden = true; dl.removeAttribute('href')
3180
+ el.hidden = false
3181
+ document.documentElement.classList.add('dv-open')
3182
+ const qs = new URLSearchParams(plan ? { plan } : { path })
3183
+ try {
3184
+ const r = await api('GET', '/v1/sessions/' + encodeURIComponent(sessionId) + '/file?' + qs)
3185
+ if (el.hidden) return
3186
+ el.querySelector('.dv-title').textContent = r.name || name
3187
+ 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(' · ')
3188
+ el.querySelector('.dv-body').innerHTML = docViewerBody(r)
3189
+ const href = docDownloadHref(r)
3190
+ if (href) { dl.href = href; dl.download = String(r.name || name).split('/').pop() + (r.plan ? '.md' : ''); dl.hidden = false }
3191
+ } catch (e) {
3192
+ el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(e.message) + '</div>'
3193
+ }
3194
+ }
3195
+
3063
3196
  // Dismiss a need (0.6.5): gone from every card and count at once, kept on
3064
3197
  // the daemon (so your other devices and the relay see it too), Undo for 5 s.
3065
3198
  async function needDismissClick(btn) {
@@ -3089,6 +3222,25 @@ function goClick(btn, id, s) {
3089
3222
  }
3090
3223
 
3091
3224
  document.addEventListener('submit', async (ev) => {
3225
+ if (ev.target.dataset && ev.target.dataset.todoAdd) {
3226
+ ev.preventDefault()
3227
+ const input = ev.target.querySelector('input[name=todo]')
3228
+ const text = input.value.trim()
3229
+ if (!text) { input.focus(); return }
3230
+ const sid = ev.target.dataset.todoAdd === '-' ? null : ev.target.dataset.todoAdd
3231
+ const b = ev.target.querySelector('button'); if (b) b.disabled = true
3232
+ try { await api('POST', '/v1/todos', { text, sessionId: sid }); input.value = ''; input.blur(); toast('Added to your to-dos'); await render() } catch (e) { toast(e.message, 5000) } finally { if (b) b.disabled = false }
3233
+ return
3234
+ }
3235
+ if (ev.target.dataset && ev.target.dataset.prLink) {
3236
+ ev.preventDefault()
3237
+ const input = ev.target.querySelector('input[name=ref]')
3238
+ const ref = input.value.trim()
3239
+ if (!ref) { input.focus(); return }
3240
+ const b = ev.target.querySelector('button'); if (b) b.disabled = true
3241
+ 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 }
3242
+ return
3243
+ }
3092
3244
  if (ev.target.dataset && ev.target.dataset.listAdd) {
3093
3245
  ev.preventDefault()
3094
3246
  const input = ev.target.querySelector('input[name=text]')
@@ -3146,7 +3298,7 @@ document.addEventListener('submit', async (ev) => {
3146
3298
 
3147
3299
  // Live updates over SSE; re-render the current view, at most every 800 ms.
3148
3300
  let pending = null
3149
- const busy = () => !!document.querySelector('.lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))
3301
+ const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))
3150
3302
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
3151
3303
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
3152
3304
  function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && state.route.name === 'settings') && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
@@ -3154,7 +3306,7 @@ let stream = null
3154
3306
  function connect() {
3155
3307
  if (stream) { try { stream.close() } catch {} }
3156
3308
  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)
3309
+ 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
3310
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
3159
3311
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
3160
3312
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
@@ -3165,6 +3317,7 @@ function connect() {
3165
3317
  }
3166
3318
 
3167
3319
  document.addEventListener('change', async (ev) => {
3320
+ if (ev.target.dataset && ev.target.dataset.todoDone) return todoDoneChange(ev.target)
3168
3321
  // Spaces (v0.5.8): a session's or agent's own space, a Mac's default.
3169
3322
  if (ev.target.dataset && ev.target.dataset.spaceKind) {
3170
3323
  const sel = ev.target