@leftoff/cli 0.6.9 → 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.9",
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', 'prLinks', 'prUnlinked']
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.9' // 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.9",
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
@@ -1441,3 +1441,81 @@ a.cpr:hover { border-color: var(--line-strong); }
1441
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
1442
  .stat.on .n { color: var(--accent); }
1443
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
@@ -9,6 +9,7 @@ import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentRepl
9
9
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
10
10
  import { mdInline, renderSection } from './changelog-md.js'
11
11
  import { cardPrsHtml, sessionPrsSection, prsPageHtml } from './prs.js'
12
+ import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
12
13
  const $app = document.getElementById('app')
13
14
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
14
15
  const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
@@ -298,7 +299,7 @@ function card(s) {
298
299
  '</div>' + pill(s.status) + '</div>' +
299
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>' +
300
301
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
301
- cardPrsHtml(s.prs) + listChips(s) + nb + cpBlock(s.latestCheckpoint) +
302
+ cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
302
303
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
303
304
  '<div class="actions-row">' + goButton(s) +
304
305
  '<button type="button" class="btn" data-action="resume">Copy resume</button>' +
@@ -371,7 +372,7 @@ function chipBar(chips, selected, allCount, emptyNote) {
371
372
  async function viewBoard() {
372
373
  if (!state.health) await refreshHealth()
373
374
  if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
374
- 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) : ''))
375
376
  state.sessions = sessions
376
377
  state.viewerLocal = viewer ? viewer.local !== false : true
377
378
  state.ghostty = viewer ? viewer.ghostty === true : false
@@ -414,8 +415,10 @@ async function viewBoard() {
414
415
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
415
416
  const needs = needsItems(sessions)
416
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)
417
420
  html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
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>'
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>'
419
422
  html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
420
423
  if (filter === 'closed') {
421
424
  html += await closedSection(f, sp)
@@ -443,10 +446,11 @@ async function viewBoard() {
443
446
  // a search box and Show more. Each card has its last checkpoint and Resume,
444
447
  // which reopens it in a new Ghostty tab (claude --resume) on the Mac that owns it.
445
448
  const CLOSED_PAGE = 30
446
- 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 }
447
450
  function closedQuery(f, sp) {
448
451
  const qs = new URLSearchParams({ limit: String(closedUi.limit) })
449
452
  if (closedUi.q.trim()) qs.set('q', closedUi.q.trim())
453
+ if (closedUi.docs) qs.set('docs', '1')
450
454
  if (state.machine) qs.set('machine', state.machine)
451
455
  if (f.repos.length) qs.set('repo', f.repos.join(','))
452
456
  if (sp) qs.set('space', sp)
@@ -461,12 +465,14 @@ async function closedFetch(f, sp) {
461
465
  async function closedSection(f, sp) {
462
466
  const r = await closedFetch(f, sp)
463
467
  return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.total, 'nothing closed yet') +
464
- '<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>' +
465
470
  '<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
466
471
  }
467
472
  function closedListHtml(r, f, sp) {
468
473
  if (!r.sessions.length) {
469
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>'
470
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.') +
471
477
  (f.repos.length || sp || closedUi.q ? ' <button type="button" class="linkish" data-action="closed-clear">Clear</button>' : '') + '</div>'
472
478
  }
@@ -497,7 +503,7 @@ function closedCard(s, viewer) {
497
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>' +
498
504
  '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
499
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>' +
500
- cpBlock(s.latestCheckpoint, { showWhen: false }) +
506
+ cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
501
507
  '<div class="actions-row">' + resumeButton(s, viewer) +
502
508
  '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' +
503
509
  '<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
@@ -614,11 +620,14 @@ async function viewWaiting() {
614
620
  const f = boardFilter()
615
621
  const shownSessions = applyFilter(all.map((n) => n.session), { repos: f.repos })
616
622
  const items = all.filter((n) => shownSessions.includes(n.session))
617
- 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.' +
618
626
  (state.mayAnswer ? '' : ' Answering from this device is off.') + '</div></div></div>'
619
627
  html += chipBar(repoChips(all.map((n) => n.session)), f.repos, all.length, 'nothing waiting')
620
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>'
621
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 })
622
631
  const recent = needs.recent || []
623
632
  html += '<details class="recent"' + (state.recentOpen !== false ? ' open' : '') + '><summary>Recently answered <span class="count">' + recent.length + '</span></summary>' +
624
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>' +
@@ -630,8 +639,10 @@ async function viewWaiting() {
630
639
  html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
631
640
  $app.innerHTML = banners() + html
632
641
  // Open/closed survives live repaints (0.6.5).
633
- const dz = $app.querySelector('details.dismissed')
642
+ const dz = $app.querySelector('details.dismissed:not(.todo-done-list)')
634
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 })
635
646
  const rc = $app.querySelector('details.recent')
636
647
  if (rc) rc.addEventListener('toggle', () => { state.recentOpen = rc.open })
637
648
  }
@@ -748,6 +759,9 @@ async function viewSession(id) {
748
759
  fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
749
760
  html += spacePicker(s, spv, 'session', s.id)
750
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>'
751
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>'
752
766
  if (s.statusChanges && s.statusChanges.length) {
753
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>'
@@ -2957,6 +2971,15 @@ document.addEventListener('click', async (ev) => {
2957
2971
  if (action === 'quick-reply') return quickReplyClick(btn)
2958
2972
  if (action === 'need-more') return needMoreClick(btn)
2959
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()
2960
2983
  if (action === 'need-restore' || action === 'need-restore-all') {
2961
2984
  btn.disabled = true
2962
2985
  state.dismissedOpen = true
@@ -2966,7 +2989,9 @@ document.addEventListener('click', async (ev) => {
2966
2989
  if (action === 'go') return goClick(btn, id, s)
2967
2990
  if (action === 'resume-open') return resumeOpen(btn, btn.dataset.id || id)
2968
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 }
2969
- 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' }) }
2970
2995
  if (action && action.startsWith('st-')) return stAction(action, btn)
2971
2996
  if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
2972
2997
  if (action === 'resume') {
@@ -3109,6 +3134,65 @@ async function needMoreClick(btn) {
3109
3134
  paint()
3110
3135
  }
3111
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
+
3112
3196
  // Dismiss a need (0.6.5): gone from every card and count at once, kept on
3113
3197
  // the daemon (so your other devices and the relay see it too), Undo for 5 s.
3114
3198
  async function needDismissClick(btn) {
@@ -3138,6 +3222,16 @@ function goClick(btn, id, s) {
3138
3222
  }
3139
3223
 
3140
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
+ }
3141
3235
  if (ev.target.dataset && ev.target.dataset.prLink) {
3142
3236
  ev.preventDefault()
3143
3237
  const input = ev.target.querySelector('input[name=ref]')
@@ -3204,7 +3298,7 @@ document.addEventListener('submit', async (ev) => {
3204
3298
 
3205
3299
  // Live updates over SSE; re-render the current view, at most every 800 ms.
3206
3300
  let pending = null
3207
- 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))
3208
3302
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
3209
3303
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
3210
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) }
@@ -3223,6 +3317,7 @@ function connect() {
3223
3317
  }
3224
3318
 
3225
3319
  document.addEventListener('change', async (ev) => {
3320
+ if (ev.target.dataset && ev.target.dataset.todoDone) return todoDoneChange(ev.target)
3226
3321
  // Spaces (v0.5.8): a session's or agent's own space, a Mac's default.
3227
3322
  if (ev.target.dataset && ev.target.dataset.spaceKind) {
3228
3323
  const sel = ev.target
package/ui/handoffs.js ADDED
@@ -0,0 +1,184 @@
1
+ // To do and Files (0.7.0). Pure HTML builders over what the daemon sends:
2
+ // /v1/needs .todos ({ open, done }) and a session's todos / files / filesSummary.
3
+ // app.js does the fetching and wires the clicks. renderDoc is the viewer's
4
+ // Markdown: everything is escaped first, so raw HTML in a doc shows as text and
5
+ // never runs; links open only for https. Unit tests: test/unit/handoffs-ui.test.mjs.
6
+ import { mdInline } from './changelog-md.js'
7
+
8
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
9
+ const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many || one + 's')
10
+ const safeArtifact = (u) => (/^https:\/\/claude\.ai\/code\/artifact\/[A-Za-z0-9_-]{4,80}$/.test(String(u || '')) ? String(u) : '')
11
+
12
+ // ------------------------------------------------------------------ to-dos
13
+
14
+ export function todoRow(t, { agoFn = null, mayEdit = true } = {}) {
15
+ const codes = (t.codes || []).map((c) => '<button type="button" class="todo-code" data-action="copy-cmd" data-cmd="' + esc(c) + '" title="Copy"><code>' + esc(c.length > 60 ? c.slice(0, 57) + '…' : c) + '</code><span class="todo-cp" aria-hidden="true">Copy</span></button>').join('')
16
+ const meta = [t.manual ? 'you added' : null, agoFn && t.at ? agoFn(t.at) : null].filter(Boolean).join(' · ')
17
+ return '<li class="todo" data-todo="' + esc(t.key) + '"><label class="todo-check"><input type="checkbox" data-todo-done="' + esc(t.key) + '"' + (mayEdit ? '' : ' disabled') + ' aria-label="Done: ' + esc(t.text) + '"><span class="box" aria-hidden="true"></span></label>' +
18
+ '<div class="todo-main"><div class="todo-text">' + mdInline(t.text) + '</div>' + (codes ? '<div class="todo-codes">' + codes + '</div>' : '') + (meta ? '<div class="todo-meta">' + esc(meta) + '</div>' : '') + '</div></li>'
19
+ }
20
+
21
+ export function todoAddForm(sessionId = '') {
22
+ return '<form class="todo-add addform" data-todo-add="' + esc(sessionId || '-') + '" autocomplete="off"><input type="text" name="todo" maxlength="200" placeholder="Add a to-do" aria-label="Add a to-do" enterkeyhint="done"><button type="submit" class="btn sm">Add</button></form>'
23
+ }
24
+
25
+ // Open to-dos grouped by session (your own, with no session, last).
26
+ export function groupTodos(open = []) {
27
+ const groups = new Map()
28
+ for (const t of open) {
29
+ const id = (t.session && t.session.id) || ''
30
+ if (!groups.has(id)) groups.set(id, { session: t.session || null, items: [] })
31
+ groups.get(id).items.push(t)
32
+ }
33
+ return [...groups.values()].sort((a, b) => (!a.session) - (!b.session) || String(b.items[b.items.length - 1].at || '').localeCompare(String(a.items[a.items.length - 1].at || '')))
34
+ }
35
+
36
+ export function doneTodosHtml(done = [], { open = false, agoFn = null } = {}) {
37
+ if (!done.length) return ''
38
+ return '<details class="dismissed todo-done-list"' + (open ? ' open' : '') + '><summary>Done (' + done.length + ')</summary><ul>' +
39
+ done.slice(0, 100).map((d) => '<li><div class="dl-main"><span class="dl-title">' + mdInline(d.text) + '</span>' + (d.session ? '<span class="dl-line">' + esc(d.session.title) + '</span>' : '') +
40
+ (agoFn && d.doneAt ? '<span class="dl-at">done ' + esc(agoFn(d.doneAt)) + '</span>' : '') + '</div>' +
41
+ '<button type="button" class="btn sm" data-action="todo-restore" data-todo="' + esc(d.key) + '">Restore</button></li>').join('') +
42
+ '</ul>' + (done.length > 1 ? '<button type="button" class="linkish" data-action="todo-restore-all">Restore all</button>' : '') + '</details>'
43
+ }
44
+
45
+ // The Needs view's "To do" section.
46
+ export function todosSection(ov, { agoFn = null, mayEdit = true, doneOpen = false, repos = [] } = {}) {
47
+ const open = ((ov && ov.open) || []).filter((t) => !repos.length || (t.session && repos.includes(t.session.repoLabel)) || !t.session)
48
+ const head = '<div class="needs-head todo-head"><h2>To do <span class="count">' + open.length + '</span></h2><span class="sub">Steps sessions handed back to you, and your own</span></div>'
49
+ const body = open.length
50
+ ? groupTodos(open).map((g) => '<div class="todo-group">' + (g.session ? '<div class="todo-sess"><a href="#/s/' + encodeURIComponent(g.session.id) + '">' + esc(g.session.title) + '</a>' + (g.session.repoLabel ? '<span class="chip mono">' + esc(g.session.repoLabel) + '</span>' : '') + '</div>' : '<div class="todo-sess"><span>Your to-dos</span></div>') +
51
+ '<ul class="todos">' + g.items.map((t) => todoRow(t, { agoFn, mayEdit })).join('') + '</ul></div>').join('')
52
+ : '<div class="nocp">No to-dos. When a session says you need to do something yourself (run a command, set a secret, add DNS records), it shows up here.</div>'
53
+ return '<section class="todos-sec mt" aria-label="To do">' + head + (mayEdit ? todoAddForm('') : '') + body + doneTodosHtml((ov && ov.done) || [], { open: doneOpen, agoFn }) + '</section>'
54
+ }
55
+
56
+ // A session page's to-dos.
57
+ export function sessionTodosSection(s, { agoFn = null, mayEdit = true } = {}) {
58
+ const list = s.todos || []
59
+ if (!list.length && !mayEdit) return ''
60
+ return '<h2>To do <span class="count">' + list.length + '</span></h2>' +
61
+ (list.length ? '<ul class="todos">' + list.map((t) => todoRow(t, { agoFn, mayEdit })).join('') + '</ul>' : '') + (mayEdit ? todoAddForm(s.id) : '')
62
+ }
63
+
64
+ // ------------------------------------------------------------------ chips
65
+
66
+ export function filesChipLabel(fs, { compact = false } = {}) {
67
+ if (!fs) return ''
68
+ const parts = []
69
+ const docs = (fs.docs || 0) + (fs.plans || 0)
70
+ if (docs) parts.push(compact ? docs + (docs === 1 ? ' doc' : ' docs') : plural(docs, 'doc'))
71
+ if (fs.artifacts) parts.push(plural(fs.artifacts, 'artifact'))
72
+ return parts.join(' · ')
73
+ }
74
+ // The card's small chips: "2 to-dos" and "2 docs · 1 artifact".
75
+ export function cardHandoffChips(s) {
76
+ const n = (s.todos || []).length
77
+ const fl = filesChipLabel(s.filesSummary)
78
+ if (!n && !fl) return ''
79
+ const href = '#/s/' + encodeURIComponent(s.id)
80
+ return '<div class="handoff-chips">' + (n ? '<a class="chip todochip" href="' + href + '" title="Steps handed back to you">☐ ' + esc(plural(n, 'to-do')) + '</a>' : '') +
81
+ (fl ? '<a class="chip fileschip" href="' + href + '" title="Docs and artifacts from this session">' + esc(fl) + '</a>' : '') + '</div>'
82
+ }
83
+
84
+ // ------------------------------------------------------------------ files
85
+
86
+ const TYPE_ICON = { markdown: 'MD', image: 'IMG', pdf: 'PDF', html: 'HTML', text: 'TXT', other: 'FILE' }
87
+ export function sessionFilesSection(s, { agoFn = null } = {}) {
88
+ const f = s.files
89
+ if (!f) return ''
90
+ const arts = (f.artifacts || []).filter((a) => safeArtifact(a.url))
91
+ const plans = f.plans || []
92
+ const docs = f.docs || []
93
+ if (!arts.length && !plans.length && !docs.length && !f.code) return ''
94
+ const when = (at) => (agoFn && at ? '<span class="f-at">' + esc(agoFn(at)) + '</span>' : '')
95
+ let h = '<h2>Files</h2><ul class="files-list">'
96
+ for (const a of arts) h += '<li class="f-art"><a class="f-row" href="' + esc(safeArtifact(a.url)) + '" target="_blank" rel="noopener noreferrer"><span class="f-ic art" aria-hidden="true">◆</span><span class="f-name">' + esc(a.title) + '</span><span class="f-kind">Artifact ↗</span>' + when(a.at) + '</a></li>'
97
+ for (const p of plans) h += '<li><button type="button" class="f-row" data-action="file-open" data-plan="' + esc(p.id) + '" data-session="' + esc(s.id) + '"><span class="f-ic" aria-hidden="true">PLAN</span><span class="f-name">' + esc(p.title) + '</span><span class="f-kind">Plan</span>' + when(p.at) + '</button></li>'
98
+ for (const d of docs) h += '<li><button type="button" class="f-row" data-action="file-open" data-path="' + esc(d.path) + '" data-session="' + esc(s.id) + '"><span class="f-ic" aria-hidden="true">' + (d.plan ? 'PLAN' : TYPE_ICON[d.type] || 'FILE') + '</span><span class="f-name mono">' + esc(d.name) + '</span><span class="f-kind ' + (d.kind === 'new' ? 'new' : '') + '">' + (d.plan ? 'Plan' : d.kind === 'new' ? 'new' : 'edited') + '</span>' + when(d.at) + '</button></li>'
99
+ h += '</ul>'
100
+ if (f.code) h += '<div class="f-code faint">' + esc(plural(f.code, 'code file')) + ' changed</div>'
101
+ return h
102
+ }
103
+
104
+ // ------------------------------------------------------------------ viewer
105
+
106
+ // Markdown -> safe HTML: headings, paragraphs, lists, task boxes, quotes,
107
+ // fenced code, tables, rules. Inline markup via mdInline (escaped; https links only).
108
+ export function renderDoc(md) {
109
+ const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').replace(/^---\n[\s\S]*?\n---\n/, '').split('\n')
110
+ const out = []
111
+ let i = 0
112
+ const para = []
113
+ const flush = () => { if (para.length) { out.push('<p>' + para.map((x) => mdInline(x)).join('<br>') + '</p>'); para.length = 0 } }
114
+ while (i < lines.length) {
115
+ const ln = lines[i]
116
+ let m
117
+ if ((m = ln.match(/^\s*(```|~~~)\s*([\w+-]*)/))) {
118
+ flush()
119
+ const fence = m[1]; const body = []
120
+ i++
121
+ while (i < lines.length && !lines[i].trim().startsWith(fence)) body.push(lines[i++])
122
+ i++
123
+ out.push('<pre class="doc-code"><code>' + esc(body.join('\n')) + '</code></pre>')
124
+ continue
125
+ }
126
+ if (!ln.trim()) { flush(); i++; continue }
127
+ if ((m = ln.match(/^(#{1,6})\s+(.*)$/))) { flush(); const n = Math.min(6, m[1].length + 1); out.push('<h' + n + '>' + mdInline(m[2].replace(/\s+#+\s*$/, '')) + '</h' + n + '>'); i++; continue }
128
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(ln)) { flush(); out.push('<hr>'); i++; continue }
129
+ if (/^\s*>/.test(ln)) {
130
+ flush(); const q = []
131
+ while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, ''))
132
+ out.push('<blockquote>' + renderDoc(q.join('\n')) + '</blockquote>'); continue
133
+ }
134
+ if (/^\s*\|.*\|\s*$/.test(ln) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
135
+ flush()
136
+ const cells = (r) => r.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
137
+ const head = cells(ln); i += 2
138
+ const rows = []
139
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]))
140
+ out.push('<div class="doc-table"><table><thead><tr>' + head.map((c) => '<th>' + mdInline(c) + '</th>').join('') + '</tr></thead><tbody>' + rows.map((r) => '<tr>' + r.map((c) => '<td>' + mdInline(c) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>')
141
+ continue
142
+ }
143
+ if (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(ln)) {
144
+ flush()
145
+ const ordered = /^\s*\d/.test(ln)
146
+ const items = []
147
+ while (i < lines.length && (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length))) {
148
+ const l = lines[i++]
149
+ const mm = l.match(/^(\s*)(?:[-*+]|\d{1,3}[.)])\s+(.*)$/)
150
+ if (mm) items.push({ depth: Math.floor(mm[1].replace(/\t/g, ' ').length / 2), text: mm[2] })
151
+ else items[items.length - 1].text += ' ' + l.trim()
152
+ }
153
+ const li = (t) => { const tm = t.match(/^\[([ xX])\]\s+(.*)$/); return tm ? '<li class="task"><span class="tbox' + (tm[1] !== ' ' ? ' on' : '') + '" aria-hidden="true"></span>' + mdInline(tm[2]) + '</li>' : '<li>' + mdInline(t) + '</li>' }
154
+ out.push('<' + (ordered ? 'ol' : 'ul') + '>' + items.map((it) => (it.depth ? li(it.text).replace('<li', '<li class="sub"') : li(it.text))).join('') + '</' + (ordered ? 'ol' : 'ul') + '>')
155
+ continue
156
+ }
157
+ para.push(ln.trim()); i++
158
+ }
159
+ flush()
160
+ return out.join('\n')
161
+ }
162
+
163
+ // The viewer's body for a file from GET /v1/sessions/:id/file.
164
+ export function docViewerBody(r) {
165
+ if (!r) return ''
166
+ if (r.missing) return '<div class="empty">File no longer exists</div>'
167
+ if (r.tooBig) return '<div class="empty">' + esc(r.message || 'Too big to open here.') + '</div>'
168
+ if (r.type === 'markdown') return '<article class="doc-md">' + renderDoc(r.text || '') + '</article>'
169
+ if (r.type === 'image') {
170
+ const src = r.base64 ? 'data:' + (/^image\/(png|jpeg|gif|webp)$/.test(r.mime) ? r.mime : 'application/octet-stream') + ';base64,' + r.base64
171
+ : r.mime === 'image/svg+xml' && typeof r.text === 'string' ? 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(r.text) : ''
172
+ return src ? '<div class="doc-img"><img alt="' + esc(r.name) + '" src="' + esc(src) + '"></div>' : '<div class="empty">Can’t show this image.</div>'
173
+ }
174
+ if (r.type === 'text' || r.type === 'html') return '<pre class="doc-pre">' + esc(r.text || '') + '</pre>'
175
+ return '<div class="empty">No preview for this kind of file.</div>'
176
+ }
177
+ // A download for anything (an HTML file is offered as a file, never rendered live).
178
+ export function docDownloadHref(r) {
179
+ if (!r || r.missing || r.tooBig) return ''
180
+ const mime = r.type === 'html' ? 'text/plain' : r.mime || 'application/octet-stream'
181
+ if (typeof r.base64 === 'string') return 'data:' + mime + ';base64,' + r.base64
182
+ if (typeof r.text === 'string') return 'data:' + mime + ';charset=utf-8,' + encodeURIComponent(r.text)
183
+ return ''
184
+ }