@leftoff/cli 0.6.9 → 0.7.1

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.1",
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
@@ -284,10 +284,20 @@ const CP_ORDER = ['id', 'session_id', 'session_title', 'created_at', 'trigger',
284
284
 
285
285
  export function resumeCommand(session) {
286
286
  const id = session.id || session.sessionId
287
- const cwd = session.cwd
287
+ const cwd = resumeDir(session)
288
288
  return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + claudeInvocation(session, ['--resume', id])
289
289
  }
290
290
 
291
+ // 0.7.1: where to run `claude --resume`: the session's folder, or, inside a git
292
+ // worktree, that worktree's own top folder (Claude Code declines to re-enter a
293
+ // `git worktree add` worktree from one of its subfolders; code.claude.com/docs/en/worktrees).
294
+ export function resumeDir(session) {
295
+ const cwd = session && session.cwd
296
+ const wt = session && session.repo && session.repo.worktree
297
+ if (cwd && wt && typeof wt === 'string' && (cwd === wt || String(cwd).startsWith(wt.replace(/\/+$/, '') + '/'))) return wt
298
+ return cwd || null
299
+ }
300
+
291
301
  // POSIX single quotes when needed (also right in fish).
292
302
  export function shArg(s) {
293
303
  const v = String(s)
@@ -494,7 +504,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
494
504
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
495
505
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
496
506
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
497
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked']
507
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos']
498
508
 
499
509
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
500
510
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -745,7 +755,8 @@ export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
745
755
  // "N empty sessions" row until their first prompt; the digest and restore skip them.
746
756
  export function isEmptySession(s, checkpointCount = 0) {
747
757
  if (!s) return true
748
- return !(Number(s.turns) > 0) && !s.title && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
758
+ // 0.7.1: a prompt counts. A long first turn (a worktree bug fix) has no completed turn yet.
759
+ return !(Number(s.turns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
749
760
  !(Number(s.historyTurns) > 0) && s.startSource !== 'resume'
750
761
  }
751
762
 
@@ -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.1' // keep in step with .claude-plugin/plugin.json
31
31
 
32
32
  const st = {
33
33
  internal: false,
@@ -828,6 +828,18 @@ function startReplyLoop($) {
828
828
  loop().catch(() => { st.replyLoop = false })
829
829
  }
830
830
 
831
+ // 0.7.1: Claude moved (cd, or into a git worktree). Settings-hook payloads carry
832
+ // the session's cwd and transcript path, which both follow it into a worktree
833
+ // (code.claude.com/docs/en/worktrees), so pass them on when they change.
834
+ function moved(e) {
835
+ const out = {}
836
+ const r = st.record || {}
837
+ if (e && typeof e.cwd === 'string' && e.cwd && e.cwd !== r.cwd) out.cwd = e.cwd
838
+ if (e && typeof e.transcript_path === 'string' && e.transcript_path && e.transcript_path !== r.transcriptPath) out.transcriptPath = e.transcript_path
839
+ if (out.cwd && st.hello) st.hello = { ...st.hello, cwd: out.cwd, repo: null } // a hello said again says where it is now
840
+ return out
841
+ }
842
+
831
843
  export function register(on, options) {
832
844
  on('session.start', async ($, e, next) => {
833
845
  try {
@@ -890,7 +902,7 @@ export function register(on, options) {
890
902
  })
891
903
 
892
904
  on('classic.UserPromptSubmit', async ($, e, next) => {
893
- await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title }) // our own /rename isn't a name you chose
905
+ await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title, ...moved(e) }) // our own /rename isn't a name you chose
894
906
  return next(e)
895
907
  })
896
908
 
@@ -920,6 +932,7 @@ export function register(on, options) {
920
932
  // board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
921
933
  const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
922
934
  await sendOrSpool($, 'turn_completed', {
935
+ ...moved(e),
923
936
  lastAssistantPreview: clip(e.last_assistant_message || '', 280),
924
937
  question: trailingQuestion(e.last_assistant_message || ''),
925
938
  ...(listHint ? { listHint } : {}),
@@ -937,13 +950,18 @@ export function register(on, options) {
937
950
  })
938
951
 
939
952
  on('classic.Notification', async ($, e, next) => {
940
- await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200) })
953
+ await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200), ...moved(e) })
941
954
  return next(e)
942
955
  })
943
956
 
944
957
  on('classic.PermissionRequest', permissionHook)
945
958
 
946
- on('classic.PostToolUse', async ($, e, next) => { await toolRan($, e); return next(e) })
959
+ on('classic.PostToolUse', async ($, e, next) => {
960
+ await toolRan($, e)
961
+ const m = moved(e) // EnterWorktree, or a cd: say so now, not at the end of the turn
962
+ if (m.cwd || m.transcriptPath) await sendOrSpool($, 'activity', { kind: 'cwd', ...m })
963
+ return next(e)
964
+ })
947
965
 
948
966
  on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
949
967
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.6.9",
3
+ "version": "0.7.1",
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
@@ -342,6 +342,9 @@ h2 .count { color: var(--faint); font-weight: 500; }
342
342
  .repomore .opt[hidden] { display: none; }
343
343
  .chip.repo-link { cursor: pointer; font: inherit; font-family: var(--mono); font-size: 11.5px; }
344
344
  .chip.repo-link:hover { color: var(--text); border-color: var(--line-strong); }
345
+ /* 0.7.1: git worktree marker and "started in" */
346
+ .chip.wt { color: var(--text2); max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
347
+ .chip.started-in { color: var(--muted); border-style: dashed; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
345
348
  .linkish { background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer; font: inherit; }
346
349
  .linkish:hover { text-decoration: underline; }
347
350
  .digest-repos { margin: 0 0 14px; }
@@ -1441,3 +1444,81 @@ a.cpr:hover { border-color: var(--line-strong); }
1441
1444
  .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
1445
  .stat.on .n { color: var(--accent); }
1443
1446
  .stat.on .k { color: var(--text); }
1447
+
1448
+ /* ---- To do and Files (0.7.0) ---- */
1449
+ .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; }
1450
+ .stat .todo-subn { display: inline-block; margin-left: 6px; font-size: 11px; font-weight: 500; color: var(--yellow); vertical-align: middle; white-space: nowrap; }
1451
+ .todos-sec { margin-top: 18px; }
1452
+ .todo-head h2 { color: var(--yellow); }
1453
+ .todo-head .count { background: var(--yellow); color: #1a1405; }
1454
+ .todo-group { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; margin-top: 10px; }
1455
+ .todo-sess { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
1456
+ .todo-sess a { color: var(--text2); text-decoration: none; font-weight: 600; }
1457
+ ul.todos { list-style: none; margin: 0; padding: 0; }
1458
+ li.todo { display: flex; gap: 10px; align-items: flex-start; padding: 8px 0; border-top: 1px solid var(--line); }
1459
+ .todo-group li.todo:first-child, .stodos li.todo:first-child { border-top: 0; }
1460
+ li.todo.ticked { opacity: .45; }
1461
+ li.todo.ticked .todo-text { text-decoration: line-through; }
1462
+ .todo-check { position: relative; flex: none; width: 24px; height: 24px; margin-top: 1px; cursor: pointer; }
1463
+ .todo-check input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
1464
+ .todo-check .box { display: block; width: 22px; height: 22px; border: 2px solid var(--line-strong); border-radius: 6px; background: var(--field); }
1465
+ .todo-check input:checked + .box { background: var(--green); border-color: var(--green); }
1466
+ .todo-check input:focus-visible + .box { box-shadow: 0 0 0 3px var(--focus); }
1467
+ .todo-main { min-width: 0; flex: 1; }
1468
+ .todo-text { color: var(--text); font-size: 14.5px; line-height: 1.4; overflow-wrap: anywhere; }
1469
+ .todo-text code, .todo-code code { font-family: var(--mono); font-size: 12.5px; }
1470
+ .todo-codes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
1471
+ .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; }
1472
+ .todo-code code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
1473
+ .todo-code .todo-cp { flex: none; font-size: 11.5px; color: var(--accent); }
1474
+ .todo-meta { margin-top: 3px; font-size: 12px; color: var(--faint); }
1475
+ .todo-add { display: flex; gap: 8px; margin-top: 10px; }
1476
+ .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); }
1477
+ .todo-add input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
1478
+ .handoff-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; }
1479
+ .handoff-chips .chip { text-decoration: none; }
1480
+ .chip.todochip { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 35%, transparent); background: var(--tint-yellow); }
1481
+ .chip.fileschip { color: var(--cyan); border-color: color-mix(in srgb, var(--cyan) 35%, transparent); background: var(--tint-cyan); }
1482
+ .closed-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1483
+ .chip.docs-filter { cursor: pointer; font: inherit; font-size: 13px; padding: 5px 12px; }
1484
+ .chip.docs-filter[aria-pressed="true"] { color: var(--cyan); border-color: var(--cyan); background: var(--tint-cyan); }
1485
+ ul.files-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); overflow: hidden; }
1486
+ ul.files-list li + li { border-top: 1px solid var(--line); }
1487
+ .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; }
1488
+ .f-row:hover { background: var(--subtle); }
1489
+ .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; }
1490
+ .f-ic.art { color: var(--accent); font-size: 13px; padding: 1px 4px; }
1491
+ .f-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
1492
+ .f-name.mono { font-family: var(--mono); font-size: 13px; }
1493
+ .f-kind { flex: none; font-size: 12px; color: var(--muted); }
1494
+ .f-kind.new { color: var(--green); }
1495
+ .f-at { flex: none; font-size: 12px; color: var(--faint); }
1496
+ .f-code { margin: 8px 2px 0; font-size: 13px; }
1497
+ .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; }
1498
+ .doc-viewer[hidden] { display: none; }
1499
+ html.dv-open, html.dv-open body { overflow: hidden; }
1500
+ .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; }
1501
+ .dv-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
1502
+ .dv-title { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1503
+ .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; }
1504
+ .dv-sub { padding: 6px 14px 0; font-size: 12px; }
1505
+ .dv-body { flex: 1; overflow: auto; padding: 8px 16px 24px; -webkit-overflow-scrolling: touch; }
1506
+ .doc-md { font-size: 15px; line-height: 1.55; color: var(--text2); overflow-wrap: anywhere; }
1507
+ .doc-md h2, .doc-md h3, .doc-md h4 { color: var(--text); margin: 18px 0 8px; }
1508
+ .doc-md h2, .doc-md h3, .doc-md h4 { text-transform: none; letter-spacing: normal; }
1509
+ .doc-md h2 { font-size: 20px; } .doc-md h3 { font-size: 17px; } .doc-md h4 { font-size: 15px; }
1510
+ .doc-md code { font-family: var(--mono); font-size: 13px; background: var(--bg2); border-radius: 5px; padding: 1px 5px; }
1511
+ .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; }
1512
+ .doc-md pre.doc-code code { background: none; padding: 0; }
1513
+ .doc-md blockquote { margin: 10px 0; padding: 2px 12px; border-left: 3px solid var(--line-strong); color: var(--muted); }
1514
+ .doc-md li.sub { margin-left: 18px; }
1515
+ .doc-md li.task { list-style: none; margin-left: -18px; }
1516
+ .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; }
1517
+ .doc-md .tbox.on { background: var(--green); border-color: var(--green); }
1518
+ .doc-table { overflow-x: auto; }
1519
+ .doc-md table { border-collapse: collapse; font-size: 13.5px; }
1520
+ .doc-md table { overflow-wrap: normal; word-break: normal; }
1521
+ .doc-md th { white-space: nowrap; }
1522
+ .doc-md th, .doc-md td { border: 1px solid var(--line); padding: 5px 8px; text-align: left; }
1523
+ .doc-img { text-align: center; padding-top: 10px; }
1524
+ .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)
@@ -138,11 +139,20 @@ function agentChip(a, { title } = {}) {
138
139
  return '<span class="chip machine agent' + (a.ok === false ? ' stale' : '') + '" title="' + esc(title || ('external agent · ' + (a.pullsHere ? 'pulled by this Mac' : 'pulled by ' + (a.puller || 'another Mac')) + (a.lastSuccessAt ? ' · last pull ' + ago(a.lastSuccessAt) : ''))) + '">' + esc(a.icon || '◆') + ' ' + esc(a.display || a.name) + '</span>'
139
140
  }
140
141
 
142
+ // 0.7.1: a session in a git worktree says which ("⑂ fix-login"); one filed
143
+ // where its edits went says where it started.
144
+ function wtChips(s) {
145
+ let h = ''
146
+ if (s.worktree && s.worktree.name) h += '<span class="chip mono wt" title="' + esc('git worktree · ' + (s.worktree.path || s.worktree.name)) + '">⑂ ' + esc(s.worktree.name) + '</span>'
147
+ if (s.startedIn) h += '<span class="chip started-in" title="' + esc('Started in ' + s.startedIn + '; its edits went to ' + (s.repoLabel || s.repoName || 'this repo')) + '">started in ' + esc(s.startedIn) + '</span>'
148
+ return h
149
+ }
150
+
141
151
  function where(s) {
142
152
  if (s.agent) return (s.project ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(s.repoLabel) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc(s.repoLabel + ' · show only this project') + '">' + esc(s.repoLabel) + '</button>' : '') + agentChip(s.agent)
143
153
  const branch = s.repo && s.repo.branch
144
154
  const label = s.repoLabel || s.repoName || 'no repo'
145
- return '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' +
155
+ return '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
146
156
  (multi() || !s.isLocal ? '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + '</span>' : '') + modelChip(s)
147
157
  }
148
158
 
@@ -298,7 +308,7 @@ function card(s) {
298
308
  '</div>' + pill(s.status) + '</div>' +
299
309
  '<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
310
  (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) +
311
+ cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
302
312
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
303
313
  '<div class="actions-row">' + goButton(s) +
304
314
  '<button type="button" class="btn" data-action="resume">Copy resume</button>' +
@@ -371,7 +381,7 @@ function chipBar(chips, selected, allCount, emptyNote) {
371
381
  async function viewBoard() {
372
382
  if (!state.health) await refreshHealth()
373
383
  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) : ''))
384
+ const { sessions, viewer, closedCount, todoCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
375
385
  state.sessions = sessions
376
386
  state.viewerLocal = viewer ? viewer.local !== false : true
377
387
  state.ghostty = viewer ? viewer.ghostty === true : false
@@ -414,8 +424,10 @@ async function viewBoard() {
414
424
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
415
425
  const needs = needsItems(sessions)
416
426
  html += needsStrip(needs, needOpts())
427
+ // 0.7.0: open to-dos are their own sub-count, not part of the Needs you number
428
+ const todoN = f.repos.length || sp ? inRepos.reduce((n, s) => n + ((s.todos && s.todos.length) || 0), 0) : (todoCount || 0)
417
429
  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>'
430
+ '<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
431
  html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
420
432
  if (filter === 'closed') {
421
433
  html += await closedSection(f, sp)
@@ -443,10 +455,11 @@ async function viewBoard() {
443
455
  // a search box and Show more. Each card has its last checkpoint and Resume,
444
456
  // which reopens it in a new Ghostty tab (claude --resume) on the Mac that owns it.
445
457
  const CLOSED_PAGE = 30
446
- const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set() }
458
+ const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set(), docs: false }
447
459
  function closedQuery(f, sp) {
448
460
  const qs = new URLSearchParams({ limit: String(closedUi.limit) })
449
461
  if (closedUi.q.trim()) qs.set('q', closedUi.q.trim())
462
+ if (closedUi.docs) qs.set('docs', '1')
450
463
  if (state.machine) qs.set('machine', state.machine)
451
464
  if (f.repos.length) qs.set('repo', f.repos.join(','))
452
465
  if (sp) qs.set('space', sp)
@@ -461,12 +474,14 @@ async function closedFetch(f, sp) {
461
474
  async function closedSection(f, sp) {
462
475
  const r = await closedFetch(f, sp)
463
476
  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>' +
477
+ '<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>' +
478
+ '<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
479
  '<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
466
480
  }
467
481
  function closedListHtml(r, f, sp) {
468
482
  if (!r.sessions.length) {
469
483
  const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
484
+ 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
485
  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
486
  (f.repos.length || sp || closedUi.q ? ' <button type="button" class="linkish" data-action="closed-clear">Clear</button>' : '') + '</div>'
472
487
  }
@@ -494,10 +509,10 @@ function closedCard(s, viewer) {
494
509
  return '<article class="card ended is-closed closed-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
495
510
  '<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-closed">closed</span></div>' +
496
511
  '<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
497
- '<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>' +
512
+ '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
498
513
  '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
499
514
  '<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 }) +
515
+ cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
501
516
  '<div class="actions-row">' + resumeButton(s, viewer) +
502
517
  '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' +
503
518
  '<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
@@ -536,7 +551,7 @@ async function resumeOpen(btn, id) {
536
551
  btn.disabled = true; btn.textContent = 'Opening…'
537
552
  try {
538
553
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/resume', {}, { timeoutMs: 60000 })
539
- toast(r.peer ? 'Resumed on ' + r.peer + ' in a new Ghostty tab' : 'Resumed in a new Ghostty tab')
554
+ toast(r.note ? r.note : r.peer ? 'Resumed on ' + r.peer + ' in a new Ghostty tab' : 'Resumed in a new Ghostty tab', r.note ? 6000 : undefined)
540
555
  return true
541
556
  } catch (e) {
542
557
  toast(e.message, 6000)
@@ -614,11 +629,14 @@ async function viewWaiting() {
614
629
  const f = boardFilter()
615
630
  const shownSessions = applyFilter(all.map((n) => n.session), { repos: f.repos })
616
631
  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.' +
632
+ const todoOv = needs.todos || { open: [], done: [] }
633
+ const nTodos = (todoOv.open || []).length
634
+ 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
635
  (state.mayAnswer ? '' : ' Answering from this device is off.') + '</div></div></div>'
619
636
  html += chipBar(repoChips(all.map((n) => n.session)), f.repos, all.length, 'nothing waiting')
620
637
  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
638
  else html += '<section class="needs-strip quiet mt" aria-label="Waiting"><div class="needs-list">' + items.map((n) => needPanel(n, needOpts())).join('') + '</div></section>'
639
+ html += todosSection(todoOv, { agoFn: ago, mayEdit: state.mayAnswer !== false, doneOpen: !!state.todoDoneOpen, repos: f.repos })
622
640
  const recent = needs.recent || []
623
641
  html += '<details class="recent"' + (state.recentOpen !== false ? ' open' : '') + '><summary>Recently answered <span class="count">' + recent.length + '</span></summary>' +
624
642
  (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 +648,10 @@ async function viewWaiting() {
630
648
  html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
631
649
  $app.innerHTML = banners() + html
632
650
  // Open/closed survives live repaints (0.6.5).
633
- const dz = $app.querySelector('details.dismissed')
651
+ const dz = $app.querySelector('details.dismissed:not(.todo-done-list)')
634
652
  if (dz) dz.addEventListener('toggle', () => { state.dismissedOpen = dz.open })
653
+ const td = $app.querySelector('details.todo-done-list')
654
+ if (td) td.addEventListener('toggle', () => { state.todoDoneOpen = td.open })
635
655
  const rc = $app.querySelector('details.recent')
636
656
  if (rc) rc.addEventListener('toggle', () => { state.recentOpen = rc.open })
637
657
  }
@@ -739,15 +759,18 @@ async function viewSession(id) {
739
759
  const branch = s.repo && s.repo.branch
740
760
  const spv = await api('GET', '/v1/spaces').catch(() => null)
741
761
  let html = '<a class="back" href="#/">← All sessions</a>'
742
- html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled session') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + ' &nbsp;' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
762
+ html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled session') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + wtChips(s) + ' &nbsp;' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
743
763
  '<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
744
764
  html += '<div class="facts">' +
745
- fact('Machine', s.machine + (s.isLocal ? ' (this one)' : ' (synced)')) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
765
+ fact('Machine', s.machine + (s.isLocal ? ' (this one)' : ' (synced)')) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
746
766
  fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
747
767
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
748
768
  fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
749
769
  html += spacePicker(s, spv, 'session', s.id)
750
770
  html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
771
+ // 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
772
+ html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
773
+ html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
751
774
  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
775
  if (s.statusChanges && s.statusChanges.length) {
753
776
  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 +2980,15 @@ document.addEventListener('click', async (ev) => {
2957
2980
  if (action === 'quick-reply') return quickReplyClick(btn)
2958
2981
  if (action === 'need-more') return needMoreClick(btn)
2959
2982
  if (action === 'need-dismiss') return needDismissClick(btn)
2983
+ // ---- To do and Files (0.7.0)
2984
+ if (action === 'todo-restore' || action === 'todo-restore-all') {
2985
+ btn.disabled = true
2986
+ state.todoDoneOpen = true
2987
+ try { await api('POST', '/v1/todos/restore', action === 'todo-restore-all' ? { all: true } : { key: btn.dataset.todo }) } catch (e) { toast(e.message, 5000) }
2988
+ return render()
2989
+ }
2990
+ if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
2991
+ if (action === 'doc-close') return closeDoc()
2960
2992
  if (action === 'need-restore' || action === 'need-restore-all') {
2961
2993
  btn.disabled = true
2962
2994
  state.dismissedOpen = true
@@ -2966,7 +2998,9 @@ document.addEventListener('click', async (ev) => {
2966
2998
  if (action === 'go') return goClick(btn, id, s)
2967
2999
  if (action === 'resume-open') return resumeOpen(btn, btn.dataset.id || id)
2968
3000
  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' }) }
3001
+ // 0.7.0: only sessions with docs, plans or artifacts
3002
+ 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 }
3003
+ if (action === 'closed-clear') { closedUi.docs = false; closedUi.q = ''; closedUi.limit = CLOSED_PAGE; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: 'closed' }) }
2970
3004
  if (action && action.startsWith('st-')) return stAction(action, btn)
2971
3005
  if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
2972
3006
  if (action === 'resume') {
@@ -3109,6 +3143,65 @@ async function needMoreClick(btn) {
3109
3143
  paint()
3110
3144
  }
3111
3145
 
3146
+ // Tick a to-do (0.7.0): done on every board at once, kept in the folder, Undo for 5 s.
3147
+ async function todoDoneChange(cb) {
3148
+ const key = cb.dataset.todoDone
3149
+ if (!key || !cb.checked) return
3150
+ const li = cb.closest('li.todo')
3151
+ if (li) li.classList.add('ticked')
3152
+ 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 }
3153
+ for (const s of state.sessions || []) if (s.todos) s.todos = s.todos.filter((t) => t.key !== key)
3154
+ toastUndo('Done', async () => {
3155
+ try { await api('POST', '/v1/todos/restore', { key }) } catch (e) { toast(e.message, 5000) }
3156
+ render()
3157
+ })
3158
+ setTimeout(() => { if (!busy()) render() }, 400)
3159
+ }
3160
+
3161
+ // The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
3162
+ // that owns it (through another Mac or the relay from a phone). Markdown is
3163
+ // rendered from escaped text; HTML files show as source; anything else downloads.
3164
+ function docViewerEl() {
3165
+ let el = document.getElementById('docViewer')
3166
+ if (el) return el
3167
+ el = document.createElement('div')
3168
+ el.id = 'docViewer'
3169
+ el.className = 'doc-viewer'
3170
+ el.hidden = true
3171
+ el.setAttribute('role', 'dialog')
3172
+ el.setAttribute('aria-modal', 'true')
3173
+ el.setAttribute('aria-labelledby', 'docViewerTitle')
3174
+ 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>'
3175
+ el.addEventListener('click', (ev) => { if (ev.target === el) closeDoc() })
3176
+ document.body.appendChild(el)
3177
+ return el
3178
+ }
3179
+ function closeDoc() { const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = '' } document.documentElement.classList.remove('dv-open') }
3180
+ document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
3181
+ async function openDoc(sessionId, { path, plan } = {}) {
3182
+ if (!sessionId || (!path && !plan)) return
3183
+ const el = docViewerEl()
3184
+ const name = plan ? 'Plan' : String(path).split('/').pop()
3185
+ el.querySelector('.dv-title').textContent = name
3186
+ el.querySelector('.dv-sub').textContent = ''
3187
+ el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
3188
+ const dl = el.querySelector('.dv-dl'); dl.hidden = true; dl.removeAttribute('href')
3189
+ el.hidden = false
3190
+ document.documentElement.classList.add('dv-open')
3191
+ const qs = new URLSearchParams(plan ? { plan } : { path })
3192
+ try {
3193
+ const r = await api('GET', '/v1/sessions/' + encodeURIComponent(sessionId) + '/file?' + qs)
3194
+ if (el.hidden) return
3195
+ el.querySelector('.dv-title').textContent = r.name || name
3196
+ 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(' · ')
3197
+ el.querySelector('.dv-body').innerHTML = docViewerBody(r)
3198
+ const href = docDownloadHref(r)
3199
+ if (href) { dl.href = href; dl.download = String(r.name || name).split('/').pop() + (r.plan ? '.md' : ''); dl.hidden = false }
3200
+ } catch (e) {
3201
+ el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(e.message) + '</div>'
3202
+ }
3203
+ }
3204
+
3112
3205
  // Dismiss a need (0.6.5): gone from every card and count at once, kept on
3113
3206
  // the daemon (so your other devices and the relay see it too), Undo for 5 s.
3114
3207
  async function needDismissClick(btn) {
@@ -3138,6 +3231,16 @@ function goClick(btn, id, s) {
3138
3231
  }
3139
3232
 
3140
3233
  document.addEventListener('submit', async (ev) => {
3234
+ if (ev.target.dataset && ev.target.dataset.todoAdd) {
3235
+ ev.preventDefault()
3236
+ const input = ev.target.querySelector('input[name=todo]')
3237
+ const text = input.value.trim()
3238
+ if (!text) { input.focus(); return }
3239
+ const sid = ev.target.dataset.todoAdd === '-' ? null : ev.target.dataset.todoAdd
3240
+ const b = ev.target.querySelector('button'); if (b) b.disabled = true
3241
+ 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 }
3242
+ return
3243
+ }
3141
3244
  if (ev.target.dataset && ev.target.dataset.prLink) {
3142
3245
  ev.preventDefault()
3143
3246
  const input = ev.target.querySelector('input[name=ref]')
@@ -3204,7 +3307,7 @@ document.addEventListener('submit', async (ev) => {
3204
3307
 
3205
3308
  // Live updates over SSE; re-render the current view, at most every 800 ms.
3206
3309
  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))
3310
+ 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
3311
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
3209
3312
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
3210
3313
  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 +3326,7 @@ function connect() {
3223
3326
  }
3224
3327
 
3225
3328
  document.addEventListener('change', async (ev) => {
3329
+ if (ev.target.dataset && ev.target.dataset.todoDone) return todoDoneChange(ev.target)
3226
3330
  // Spaces (v0.5.8): a session's or agent's own space, a Mac's default.
3227
3331
  if (ev.target.dataset && ev.target.dataset.spaceKind) {
3228
3332
  const sel = ev.target