@leftoff/cli 0.9.8-beta.9 → 0.9.9-beta.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.
package/ui/app.js CHANGED
@@ -5,29 +5,43 @@ import { icon } from './icons.js'
5
5
  import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet } from './picker.js'
6
6
  import { SETTINGS, settingsKeyFor } from './settings-nav.js'
7
7
  import { mountWizard, shouldAutoOpen } from './wizard.js'
8
+ import { shouldAutoTour, startTour } from './tour.js' // 0.9.8 the product tour
8
9
  import { changedKeys } from './settings-dirty.js'
9
10
  import { looksMd, stripMd, plainCp } from './cp-text.js'
10
11
  import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
11
12
  import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel } from './repos.js'
12
13
  import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
13
14
  import { canStartPull, pullStep, pullRelease } from './pull.js'
15
+ import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
16
+ import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
17
+ import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
18
+ import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml } from './composer.js' // 0.9.8 the Live composer
19
+ import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
14
20
  import { wireTips } from './tips.js'
21
+ import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
15
22
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
16
23
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
17
24
  import { mdInline, renderSection } from './changelog-md.js'
18
25
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
19
26
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
20
- import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
21
- import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
27
+ import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
28
+ import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
22
29
  import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking } from './live-view.js' // 0.9.8 Live link polish
23
30
  import { statusView, elapsedText } from './live-status.js'
31
+ import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
24
32
  import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
25
33
  import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, moveTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir } from './tabs.js' // 0.9.8 tabs for sessions
26
34
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
35
+ import { widState, whatItDidSection, copyText as widCopyText, loadError as widError, hiddenText as widHidden, slotSource as widSlot } from './what-it-did.js' // 0.9.8 What it did
36
+ import { hydrateDiffs, renderDiff } from './diff-view.js' // 0.9.8: a file's changes drawn like GitHub's
27
37
  const $app = document.getElementById('app')
28
38
  // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
29
39
  if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
30
40
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
41
+ // 0.9.8: drafts the update bar's Refresh carried over (ui/update-bar.js)
42
+ const carried = takeCarry()
43
+ if (carried) Object.assign(state.drafts, carried.drafts)
44
+ let updateBar = null
31
45
  const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
32
46
  const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
33
47
 
@@ -318,6 +332,13 @@ function staleLine(sessions) {
318
332
  const ms = [...new Set(sessions.filter((x) => !x.isLocal && x.machine in st).map((x) => x.machine))]
319
333
  return ms.length ? '<div class="stale-line faint">' + ms.map((m) => esc(m) + ': showing data as of ' + esc(st[m] ? ago(st[m]) : 'its last sync')).join(' · ') + '</div>' : ''
320
334
  }
335
+ // 0.9.8: a board filtered to one Mac says so, with the way back to every Mac.
336
+ function machineOnlyLine() {
337
+ if (!state.machine) return ''
338
+ const known = { machines: (state.health && state.health.machines) || [], agents: agentNames() }
339
+ const agent = known.agents.includes(state.machine) && !known.machines.includes(state.machine)
340
+ return '<div class="machine-only" role="status">' + (agent ? '' : mEmoji(state.machine) + ' ') + esc(machineOnlyLabel(state.machine, known)) + ' · <button type="button" class="linkish" data-action="machine-all">Show all Macs</button></div>'
341
+ }
321
342
  function staleNote(machine) {
322
343
  const st = state.health && state.health.sync && state.health.sync.stale
323
344
  if (!st || !machine || !(machine in st)) return ''
@@ -405,15 +426,15 @@ function goButton(s, { page = false } = {}) {
405
426
  if (!j.peer) return ''
406
427
  // 0.9.2: "Go ↗" like a local card (a phone cut "Go on <machine>" short); the machine is in its title and label
407
428
  const label = 'Go ↗', on = 'Go on ' + s.machine
408
- if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
409
- if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
429
+ if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" data-tip="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
430
+ if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" data-tip="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
410
431
  const st = goState.get(s.id)
411
- return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
432
+ return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" data-tip="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
412
433
  }
413
- if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
414
- if (!j.supported) return '<button type="button" class="btn go" disabled title="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
434
+ if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled data-tip="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
435
+ if (!j.supported) return '<button type="button" class="btn go" disabled data-tip="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
415
436
  const st = goState.get(s.id)
416
- return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗" title="' + esc('Bring this session’s ' + (s.terminal ? s.terminal.label + ' ' : '') + 'tab to the front') + '">' + esc(st ? st.label : 'Go ↗') + '</button>'
437
+ return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗" data-tip="' + esc('Bring this session’s ' + (s.terminal ? s.terminal.label + ' ' : '') + 'tab to the front') + '">' + esc(st ? st.label : 'Go ↗') + '</button>'
417
438
  }
418
439
 
419
440
  // The short reason a cross-machine button is grey (the full one is its tooltip).
@@ -756,9 +777,16 @@ async function projectAction(action, btn) {
756
777
  }
757
778
  }
758
779
  let lastMenuAnchor = null
780
+ // The board's Machine filter, kept in this browser ('' = every Mac).
781
+ function setMachineFilter(m) {
782
+ state.machine = m || ''
783
+ try { if (state.machine) localStorage.setItem('cp.machine', state.machine); else localStorage.removeItem('cp.machine') } catch {}
784
+ }
759
785
 
760
786
  async function viewBoard() {
761
787
  if (!state.health) await refreshHealth()
788
+ // 0.9.8: a saved Machine filter for a Mac (or agent) the board no longer knows is dropped
789
+ if (state.machine && state.health && boardMachineFilter(state.machine, { machines: state.health.machines || [], agents: agentNames() }) !== state.machine) setMachineFilter('')
762
790
  if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
763
791
  const [{ sessions: all, viewer, closedCount, todoCount, parked = [] }] = await Promise.all([api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : '')), loadProjects()])
764
792
  // 0.8.2: parked sessions have their own section (any age), not a status group
@@ -818,15 +846,18 @@ async function viewBoard() {
818
846
  const todoN = f.repos.length || sp || pj ? inRepos.reduce((n, s) => n + ((s.todos && s.todos.length) || 0), 0) : (todoCount || 0)
819
847
  html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
820
848
  '<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>'
849
+ html += machineOnlyLine() // 0.9.8: in the filter rows, with the spaces and repos it narrows
821
850
  html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
822
851
  html += projectRow(spaced, pj, 'board-project')
823
852
  if (filter === 'closed') {
824
853
  html += await closedSection(f, sp)
854
+ if (parseRoute().name === 's') return // 0.9.8: you opened a session while the board loaded
825
855
  $app.innerHTML = banners() + html
826
856
  return closedWire()
827
857
  }
828
858
  html += repoRow(inSpace, f)
829
859
  html += boardSearch(bq, closedHits, list.length)
860
+ if ((state.boardQ || '').trim() !== bq) return // you typed on while this one loaded: the newer search paints
830
861
  if (bq) {
831
862
  if (list.length) html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
832
863
  paintBoardKeepingSearch(banners() + html)
@@ -837,7 +868,7 @@ async function viewBoard() {
837
868
  html += '<div class="empty mt"><p><strong>No sessions yet.</strong></p><p>Start Claude Code with the mod loaded (<code>claude --plugin-dir ./mod</code>) or install the settings hooks (<code>leftoff init --hooks</code>). Sessions show up here as soon as you send a prompt.</p></div>'
838
869
  } else if (!list.length && shown.length) {
839
870
  const pjName = pj ? (projectsUi.list.find((p) => p.id === pj) || {}).name : null
840
- const inWhat = (pjName ? ' in ' + pjName : '') + (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
871
+ const inWhat = (pjName ? ' in ' + pjName : '') + (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '') + (state.machine ? ' on ' + state.machine : '')
841
872
  html += '<div class="empty mt">' + (filter ? 'Nothing ' + esc(FILTERS[filter].label.toLowerCase()) + esc(inWhat) + ' right now.' : 'No sessions' + esc(inWhat) + ' today.') +
842
873
  ' <button type="button" class="linkish" data-action="clear-filters">Show everything</button></div>'
843
874
  } else if (list.length) {
@@ -860,38 +891,63 @@ function boardSearch(q, closedHits, n) {
860
891
  (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') +
861
892
  ' <a class="linkish tx-board-link" href="#/search?q=' + encodeURIComponent(q) + '">' + icon('message-square') + 'Search what was said →</a></div>' : '') + '</div>'
862
893
  }
863
- // A repaint from a live update (or the 30 s refresh) while you type in the
864
- // search box keeps the box focused, with what you typed and the caret.
894
+ // A repaint from a live update (or the 30 s refresh, or your own typing's
895
+ // search) never rebuilds the search box: everything around it is replaced and
896
+ // the box itself stays the same element, so the caret, a selection, a word
897
+ // being composed and the focus are exactly as you left them. (Rebuilding it
898
+ // and putting the caret back moved the caret while you typed: the typing's
899
+ // own search put back where the caret was when it started, after you'd typed on.)
865
900
  function paintBoardKeepingSearch(h) {
901
+ if (parseRoute().name === 's') return // 0.9.8: a slow board answer never paints over the session you opened
866
902
  const old = document.getElementById('boardQ')
867
- const keep = old && document.activeElement === old ? { v: old.value, pos: old.selectionStart } : null
903
+ const row = old && old.closest('.board-search-row')
904
+ if (row && row.parentNode === $app) {
905
+ const tpl = document.createElement('template')
906
+ tpl.innerHTML = h
907
+ const nrow = tpl.content.querySelector('.board-search-row')
908
+ const nbox = nrow && nrow.querySelector('#boardQ')
909
+ if (nbox && nrow.parentNode === tpl.content) {
910
+ const kids = [...tpl.content.childNodes], at = kids.indexOf(nrow)
911
+ while ($app.firstChild && $app.firstChild !== row) $app.removeChild($app.firstChild)
912
+ while (row.nextSibling) $app.removeChild(row.nextSibling)
913
+ row.before(...kids.slice(0, at))
914
+ row.after(...kids.slice(at + 1))
915
+ // the note under the box (n matches, closed matches) follows the new answer
916
+ for (const n of [...row.childNodes]) if (!n.contains(old)) row.removeChild(n)
917
+ for (const n of [...nrow.childNodes]) if (!n.contains(nbox)) row.appendChild(n)
918
+ // the words change only when you aren't typing (the address changed, Esc cleared it)
919
+ if (document.activeElement !== old && !boardQTimer && old.value !== nbox.value) old.value = nbox.value
920
+ return
921
+ }
922
+ }
923
+ const keep = old && document.activeElement === old ? { v: old.value, a: old.selectionStart, b: old.selectionEnd, d: old.selectionDirection } : null
868
924
  $app.innerHTML = h
869
925
  if (!keep) return
870
926
  const q = document.getElementById('boardQ')
871
927
  if (!q) return
872
928
  if (q.value !== keep.v) q.value = keep.v
873
- q.focus(); try { q.setSelectionRange(keep.pos, keep.pos) } catch {}
929
+ q.focus({ preventScroll: true }); try { q.setSelectionRange(keep.a, keep.b, keep.d || 'none') } catch {}
874
930
  }
875
931
  let boardQTimer = null
876
932
  function boardSearchWire() {
877
933
  const q = document.getElementById('boardQ')
878
- if (!q) return
934
+ if (!q || q.dataset.wired) return // the same box across repaints: wired once
935
+ q.dataset.wired = '1'
879
936
  q.addEventListener('input', () => {
880
937
  clearTimeout(boardQTimer)
881
938
  boardQTimer = setTimeout(async () => {
882
- const pos = q.selectionStart
939
+ boardQTimer = null
940
+ if (!q.isConnected) return
883
941
  state.boardQ = q.value
884
942
  try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
885
943
  state.route = parseRoute()
886
- await viewBoard()
887
- const again = document.getElementById('boardQ')
888
- if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
944
+ await viewBoard() // the box isn't rebuilt, so the caret stays wherever you've typed to since
889
945
  }, 160)
890
946
  })
891
947
  q.addEventListener('keydown', (ev) => {
892
948
  if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) }
893
949
  // 0.9.6: Enter (Search on the phone's keyboard) opens the full results, transcripts included
894
- else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
950
+ else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); boardQTimer = null; location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
895
951
  })
896
952
  }
897
953
 
@@ -1114,9 +1170,9 @@ function restoreBanner() {
1114
1170
  const when = r.restartAt ? ' (' + clock(r.restartAt) + ')' : ''
1115
1171
  const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open ' + esc(restoreWhen(r)) + '</b>' + esc(when)
1116
1172
  if (!restoreUi.open) {
1117
- return '<div class="banner info restore" id="restoreBanner" role="status"><span class="grow">' + head + '. Put them back, each in its own Ghostty tab.</span>' +
1173
+ return '<div class="banner info restore" id="restoreBanner" role="status"><span class="grow">' + restoreHeadline(r, { esc, clock }) + '</span>' +
1118
1174
  '<button type="button" class="btn primary" data-action="restore-open">' + icon('rotate-ccw') + 'Restore…</button>' +
1119
- '<button type="button" class="btn x" data-action="restore-dismiss" aria-label="Not now" title="Not now">✕</button></div>'
1175
+ '<button type="button" class="btn x" data-action="restore-dismiss" aria-label="Not now" data-tip="Not now">✕</button></div>'
1120
1176
  }
1121
1177
  const gate = restoreGate()
1122
1178
  const picked = restorePicked().length
@@ -1124,11 +1180,11 @@ function restoreBanner() {
1124
1180
  const rows = r.sessions.map((s) => '<label class="rs-row"><input type="checkbox" name="rs" value="' + esc(s.id) + '"' + (restoreUi.off.has(s.id) ? '' : ' checked') + '>' +
1125
1181
  '<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.modelLabel, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span>' + (s.blocked ? '<span class="rs-m err">' + esc(s.error) + '</span>' : '') + '</span></label>').join('')
1126
1182
  return '<div class="banner info restore open" id="restoreBanner" role="region" aria-label="Restore sessions"><div class="rs-head"><span class="grow">' + head + '. Each opens in a new Ghostty tab, in its folder, with <code>claude --resume</code> and its name.</span>' +
1127
- '<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
1183
+ '<button type="button" class="btn x" data-action="restore-close" aria-label="Close" data-tip="Close">✕</button></div>' +
1128
1184
  '<div class="rs-list">' + rows + '</div>' +
1129
1185
  '<div class="rs-actions">' +
1130
1186
  '<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked && !asking ? '' : ' aria-disabled="true"') +
1131
- (gate.ok && gate.how !== 'ask' ? '' : ' aria-describedby="restoreHint"') + (gate.ok ? '' : ' title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
1187
+ (gate.ok && gate.how !== 'ask' ? '' : ' aria-describedby="restoreHint"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
1132
1188
  '<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
1133
1189
  '<button type="button" class="btn" data-action="restore-copy">Copy commands</button>' +
1134
1190
  (restoreUi.ask ? restoreAskNote() : gate.ok && gate.how !== 'ask' ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
@@ -1208,7 +1264,7 @@ function paintNeedsCount(n) {
1208
1264
 
1209
1265
  const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
1210
1266
  function needOpts(extra = {}) {
1211
- return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', ...extra }
1267
+ return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', composerKeys: isTouchUi(window.matchMedia) ? 'Meta+Enter Control+Enter' : 'Enter Meta+Enter Control+Enter', ...extra }
1212
1268
  }
1213
1269
 
1214
1270
  // ⌘↵ (Ctrl+↵ off a Mac) in a Needs-you text box (the Deny note, a question's
@@ -1297,47 +1353,166 @@ function ownerOfflineNote(s) {
1297
1353
  const m = esc(s.machine), sn = staleNote(s.machine)
1298
1354
  return '<p class="owner-off" role="status">' + icon('wifi-off') + '<span>This session lives on <b>' + m + '</b>, which is offline right now. You’re seeing what it last synced' + (sn ? ' (' + esc(sn) + ')' : '') + '. Live, its transcript and Find lists come back when ' + m + ' is.</span></p>'
1299
1355
  }
1356
+ // 0.9.8: opening a session is instant. The tap paints the page's shell at
1357
+ // once (title and pills from the card we already have, or the whole page from
1358
+ // the last visit), then every fetch runs in parallel (the session, its Live
1359
+ // thread, spaces, projects, lists) instead of one after another; on a phone
1360
+ // through the relay each was a full round trip. A newer navigation wins: a
1361
+ // slow answer for a page you already left never paints over the one you're on.
1362
+ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few sessions you opened
1363
+ const SESSION_CACHE_MAX = 8
1364
+ function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
1365
+ const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
1366
+ function sessionShellHtml(s) {
1367
+ const head = s
1368
+ ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + statusPill(s) + liveBadge(s) + '</div>'
1369
+ : '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
1370
+ return '<a class="back" href="#/">← All sessions</a><div class="pagehead s-head s-shell" data-sid="' + esc((s && s.id) || '') + '"><div class="s-head-main">' + head + '</div></div>' +
1371
+ '<div class="s-skel" role="status" aria-live="polite" aria-busy="true"><div class="s-skel-note"><span class="lv-spin" aria-hidden="true"></span>Opening…</div>' +
1372
+ '<div class="skel-line"></div><div class="skel-line short"></div><div class="skel-block"></div><div class="skel-line"></div><div class="skel-line short"></div></div>'
1373
+ }
1374
+ // A selector that finds the same control again after a redraw (null: it can't be told apart).
1375
+ function focusKey(el) {
1376
+ if (!el || !el.tagName) return null
1377
+ if (el.id) return '#' + CSS.escape(el.id)
1378
+ const d = el.dataset || {}
1379
+ const keys = ['action', 'lid', 'sid', 'att', 'id', 'tab', 'submitFor'].filter((k) => d[k] != null)
1380
+ if (!keys.length) return null
1381
+ const attr = (k) => '[data-' + k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + '="' + CSS.escape(d[k]) + '"]'
1382
+ return el.tagName.toLowerCase() + keys.map(attr).join('')
1383
+ }
1384
+ function paintSession(s, data) {
1385
+ if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
1386
+ // a redraw (the live poll, a settled reply) keeps the composer focused with its caret where it was
1387
+ const fa = document.activeElement
1388
+ const kf = fa && fa.classList && fa.classList.contains('composer-input') && $app.contains(fa) ? { d: fa.dataset.draft, a: fa.selectionStart, b: fa.selectionEnd } : null
1389
+ // …and any other control that had focus (a chip, a thumbnail, a button: a
1390
+ // preview closed with Esc puts focus back on one, then a redraw replaced it)
1391
+ const kb = !kf && fa && fa !== document.body && $app.contains(fa) ? focusKey(fa) : null
1392
+ liveKeeper.before($app)
1393
+ $app.innerHTML = sessionPageHtml(s, data)
1394
+ liveAfterRender(s.id)
1395
+ if (kf) {
1396
+ const nt = $app.querySelector('.composer-input[data-draft="' + CSS.escape(kf.d || '') + '"]')
1397
+ if (nt && nt.dataset.draft === kf.d && !nt.disabled) { nt.focus({ preventScroll: true }); try { const n = nt.value.length; nt.setSelectionRange(Math.min(kf.a, n), Math.min(kf.b, n)) } catch {} }
1398
+ } else if (kb && !$app.contains(document.activeElement)) {
1399
+ const hits = $app.querySelectorAll(kb)
1400
+ if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true })
1401
+ }
1402
+ aroundWire(s.id)
1403
+ }
1300
1404
  async function viewSession(id) {
1301
1405
  if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
1302
- const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
1406
+ const showing = $app.querySelector('.s-head[data-sid="' + CSS.escape(id) + '"]') // a re-render of the page you're on: no shell
1407
+ const cached = sessionCache.get(id)
1408
+ const known = (cached && cached.s) || state.sessions.find((x) => x.id === id) || null
1409
+ if (!showing) {
1410
+ if (cached) paintSession(cached.s, cached) // the last visit, at once; fresh data replaces it below
1411
+ else { liveKeeper.before($app); $app.innerHTML = sessionShellHtml(known) }
1412
+ }
1413
+ // on leftoff.dev, another Mac's session loads its data from that Mac: say so before the first call
1414
+ const owner = (x) => { if (x && window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(x.id, x.isLocal === false && !x.agent ? x.machine : null) }
1415
+ owner(known)
1416
+ const pS = api('GET', '/v1/sessions/' + encodeURIComponent(id))
1417
+ const pLive = !known || liveWanted(known) ? fetchLive(id) : null // the thread doesn't wait for the session
1418
+ const pLists = known && known.lists && known.lists.length ? fetchLists(id) : null
1419
+ const pRest = Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1420
+ let s
1421
+ try { s = await pS } catch (e) { if (!onSession(id)) return; throw e }
1303
1422
  state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
1304
- // 0.9.8 auto-switch host: on leftoff.dev, another Mac's session loads its own data from that Mac
1305
- if (window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(s.id, s.isLocal === false && !s.agent ? s.machine : null)
1423
+ owner(s)
1424
+ const status = s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null
1425
+ const lists = s.lists && s.lists.length ? (pLists || fetchLists(id)) : null
1426
+ const live = liveWanted(s) ? (pLive || fetchLive(id)) : !isLive(s) && !s.endedAt && s.live !== 'closed' ? liveHow().then(() => null) : null
1427
+ let [[spv], v, sl] = await Promise.all([pRest, live, lists, status])
1428
+ if (!onSession(id)) return // you moved on while this loaded
1429
+ // a Live fetch that failed keeps the thread you had (its bubbles say "Reconnecting…"), not an empty one
1430
+ const prev = sessionCache.get(id)
1431
+ if (v && v.error && prev && prev.live && !prev.live.error) v = { ...prev.live, error: null }
1432
+ const data = { spv, live: v, lists: sl }
1433
+ if (v && !v.error) cacheSession(id, { s, ...data, live: v })
1434
+ paintSession(s, data)
1435
+ }
1436
+ const fetchLists = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists').catch(() => null)
1437
+ // 0.9.8 What it did: per-session state (kept while the board is open), painted
1438
+ // in place; loaded when you open the section or tap ↻, never polled.
1439
+ const widUi = new Map()
1440
+ function widOf(id) { if (!widUi.has(id)) widUi.set(id, widState()); return widUi.get(id) }
1441
+ function paintWid(id) {
1442
+ const el = document.getElementById('what-it-did')
1443
+ const s = state.sessions.find((x) => x.id === id)
1444
+ if (!el || !s || !onSession(id)) return
1445
+ const fa = document.activeElement
1446
+ const kb = fa && fa !== document.body && el.contains(fa) ? focusKey(fa) : null
1447
+ el.innerHTML = whatItDidSection(s, widOf(id))
1448
+ if (hydrateDiffs(el, (p) => widSlot(widOf(id), p))) widStickyTop()
1449
+ if (kb) { const hits = el.querySelectorAll(kb); if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true }) }
1450
+ }
1451
+ // An open file's header sticks just under the board's own sticky bar, whose height changes with the width.
1452
+ function widStickyTop() {
1453
+ let h = 0
1454
+ for (const b of document.querySelectorAll('.top, .tabstrip')) if (b.offsetParent !== null && getComputedStyle(b).position === 'sticky') h = Math.max(h, b.getBoundingClientRect().height)
1455
+ document.documentElement.style.setProperty('--wid-sticky', Math.round(h) + 'px')
1456
+ }
1457
+ const widPath = (id, what) => '/v1/sessions/' + encodeURIComponent(id) + '/' + what
1458
+ async function loadWid(id) {
1459
+ const ui = widOf(id)
1460
+ if (ui.loading) return
1461
+ Object.assign(ui, { loading: true, tools: null, diff: null, toolsError: null, diffError: null, toolFull: {}, toolLoading: {}, files: {}, fileLoading: {} })
1462
+ paintWid(id)
1463
+ await Promise.all([
1464
+ api('GET', widPath(id, 'tools')).then((r) => { ui.tools = r.tools || [] }, (e) => { ui.toolsError = widError(e) }),
1465
+ api('GET', widPath(id, 'diff')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
1466
+ ])
1467
+ ui.loading = false
1468
+ // a file you had open shows its lines again
1469
+ for (const p of Object.keys(ui.fileOpen)) if (ui.fileOpen[p]) loadWidFile(id, p)
1470
+ paintWid(id)
1471
+ }
1472
+ async function loadWidFile(id, p) {
1473
+ const ui = widOf(id)
1474
+ const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
1475
+ if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
1476
+ ui.fileLoading[p] = true; paintWid(id)
1477
+ try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p)); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
1478
+ delete ui.fileLoading[p]
1479
+ paintWid(id)
1480
+ }
1481
+ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {}) {
1306
1482
  const branch = s.repo && s.repo.branch
1307
- const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects(),
1308
- s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null])
1309
1483
  let html = '<a class="back" href="#/">← All sessions</a>'
1310
1484
  // 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
1311
1485
  const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
1312
- html += '<div class="pagehead s-head"><div class="s-head-main">' + (canRename
1486
+ html += '<div class="pagehead s-head" data-sid="' + esc(s.id) + '"><div class="s-head-main">' + (canRename
1313
1487
  ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + titleHtml(sessionTitle(s), esc) + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
1314
1488
  : '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>') + '<div class="sub">' + statusPill(s) + liveBadge(s) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' &nbsp;' + esc(s.titleSource ? (s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource + ' title') : '') + '</div></div>' +
1315
1489
  // 0.9.8: one line each, 44px, an icon and a short label; the group drops under the title as a whole when it doesn't fit beside it
1316
- '<div class="actions s-actions">' + goButton(s, { page: true }) + (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) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + 'Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
1490
+ '<div class="actions s-actions">' + goButton(s, { page: true }) + (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) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + 'Copy resume</button>' + (s.liveResumeCommand ? '<button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(s.liveResumeCommand) + '" title="Exit the session in its terminal, then run this to bring it back Live">' + icon('copy') + 'Copy Live resume</button>' : '') + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
1317
1491
  html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
1318
1492
  html += '<div class="facts">' +
1319
1493
  fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (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)) +
1320
1494
  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) +
1321
1495
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1322
1496
  fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
1323
- if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + await liveSection(s)
1497
+ if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
1324
1498
  html += parkRow(s)
1325
1499
  html += spacePicker(s, spv, 'session', s.id)
1326
- html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
1500
+ html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
1327
1501
  // 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
1328
1502
  html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
1329
1503
  html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
1504
+ // 0.9.8: the last tools it ran and its folder's changes, folded; loads when you open it
1505
+ html += '<section class="wid" id="what-it-did">' + whatItDidSection(s, widOf(s.id)) + '</section>'
1330
1506
  // 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
1331
1507
  if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
1332
- if (!isLive(s)) html += await liveSection(s)
1508
+ if (!isLive(s)) html += liveBoxHtml(s, liveV)
1333
1509
  if (s.statusChanges && s.statusChanges.length) {
1334
1510
  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>'
1335
1511
  }
1336
1512
  // Lists (v0.5.1): its saved lists in full, the hint, and Find lists in this session
1337
1513
  if (s.lists && s.lists.length) {
1338
- const sl = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists').catch(() => null)
1339
1514
  state.listFull = {}
1340
- for (const l of (sl && sl.lists) || []) state.listFull[l.ref] = l
1515
+ for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l
1341
1516
  }
1342
1517
  state.listsMayEdit = state.mayAnswer !== false
1343
1518
  html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
@@ -1355,10 +1530,7 @@ async function viewSession(id) {
1355
1530
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
1356
1531
  if (empties.length) html += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
1357
1532
  html += answeredList(s)
1358
- liveKeeper.before($app)
1359
- $app.innerHTML = html
1360
- liveAfterRender(s.id)
1361
- aroundWire(s.id)
1533
+ return html
1362
1534
  }
1363
1535
  // ------------------------------------------------------------ Live link (0.9.8 beta)
1364
1536
 
@@ -1381,18 +1553,28 @@ async function liveHow() {
1381
1553
  if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
1382
1554
  return state.liveHow
1383
1555
  }
1384
- async function liveSection(s) {
1556
+ // Does this session's page show a Live thread (and so fetch one)?
1557
+ const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
1558
+ // GET the Live thread; what it says is the newest the page knows (0.9.8: the
1559
+ // time of the last good answer decides "Reconnecting…" on a pending bubble).
1560
+ async function fetchLive(id, { timeoutMs } = {}) {
1561
+ const turns = (state.liveTurns && state.liveTurns[id]) || 20 // "Show earlier" adds 20 at a time
1562
+ try {
1563
+ const v = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/live' + (turns > 20 ? '?turns=' + turns : ''), null, timeoutMs ? { timeoutMs } : {})
1564
+ noteLive(id, v)
1565
+ return v
1566
+ } catch (e) { return { messages: [], error: e.message } }
1567
+ }
1568
+ function liveBoxHtml(s, v) {
1385
1569
  if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
1386
1570
  if (!isLive(s)) {
1387
1571
  if (s.endedAt || s.live === 'closed') return ''
1388
- const how = await liveHow()
1572
+ const how = state.liveHow
1389
1573
  return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
1390
1574
  '<p class="faint">To send this session messages from here or your phone, and answer its permission prompts, start it with <code>leftoff live</code>' +
1391
- (how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p></section>'
1575
+ (how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p>' +
1576
+ (s.liveResumeCommand ? '<p class="faint">Already running? Exit it in its terminal, then paste this to bring it back Live:</p><div class="live-resume-row"><code class="mono">' + esc(s.liveResumeCommand) + '</code><button type="button" class="copy-float" data-action="copy-cmd" data-cmd="' + esc(s.liveResumeCommand) + '" aria-label="Copy Live resume" data-tip="Copy">' + icon('copy') + '</button></div>' : '') + '</section>'
1392
1577
  }
1393
- let v = null
1394
- const turns = (state.liveTurns && state.liveTurns[s.id]) || 20 // "Show earlier" adds 20 at a time
1395
- try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live' + (turns > 20 ? '?turns=' + turns : '')) } catch (e) { v = { messages: [], error: e.message } }
1396
1578
  const msgs = (v && v.messages) || []
1397
1579
  const may = state.mayAnswer !== false
1398
1580
  const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
@@ -1401,17 +1583,337 @@ async function liveSection(s) {
1401
1583
  if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1402
1584
  return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
1403
1585
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1404
- earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now }) + '</ol>' +
1586
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
1405
1587
  '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
1406
1588
  '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1407
1589
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1408
- '<div class="reply-row live-row"><textarea class="reply-text live-text" rows="2" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter') + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
1409
- '<button type="button" class="btn primary" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '"' + (may ? '' : ' aria-disabled="true"') + '>Send</button></div>' +
1590
+ composerHtml(s, draft, may) +
1410
1591
  (may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy &amp; security → Trust my paired devices.</p>') + '</section>'
1411
1592
  }
1593
+ // The Live composer: one rounded box, a textarea that starts at one line and
1594
+ // grows, and a round ↑ in its corner (dim until there's something to send).
1595
+ // No interrupt reaches Claude Code from the board, so while it works the
1596
+ // button still sends: the message waits for its next turn, as before.
1597
+ const FILE_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>'
1598
+ function composerHtml(s, draft, may) {
1599
+ const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
1600
+ return composerBoxHtml({ sid: s.id, submit: 'live', sendAction: 'live-send', draftKey: 'live:' + s.id, draft, may,
1601
+ placeholder: 'Message this session…', label: 'Message this session', boxClass: 'live-row', inputClass: 'live-text',
1602
+ attach: true, atts: attachList(s.id), chipHtml, keys: isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd })
1603
+ }
1604
+
1605
+ // ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
1606
+ // Each file uploads to the Mac that runs the session as soon as you attach it;
1607
+ // the chips live here (not in the DOM), so a redraw of the page keeps them.
1608
+ const attachUi = { by: new Map(), urls: new Map(), blobs: new Map(), loading: new Map(), seq: 0, running: 0, queue: [] }
1609
+ const attachList = (sid) => attachUi.by.get(sid) || []
1610
+ function chipHtml(a) {
1611
+ const pct = a.total ? Math.min(100, Math.round((a.sent / a.total) * 100)) : 0
1612
+ const status = a.status === 'failed' ? 'Didn’t upload' : a.status === 'done' ? fmtSize(a.size) : a.status === 'prep' ? 'Preparing…' : fmtSize(a.size) + ' · ' + pct + '%'
1613
+ return '<div class="att-chip ' + esc(a.status) + '" data-lid="' + esc(a.lid) + '"' + (a.error ? ' title="' + esc(a.error) + '"' : '') + '>' +
1614
+ '<button type="button" class="att-open" data-action="att-chip-open" data-lid="' + esc(a.lid) + '" aria-label="Preview ' + esc(a.name) + '">' +
1615
+ '<span class="att-thumb">' + (a.preview ? '<img src="' + esc(a.preview) + '" alt="">' : FILE_IC) + '</span>' +
1616
+ '<span class="att-meta"><span class="att-name" title="' + esc(a.name) + '">' + esc(shortName(a.name)) + '</span><span class="att-size">' + esc(status) + '</span></span></button>' +
1617
+ (a.status === 'failed' ? '<button type="button" class="att-btn att-retry" data-action="att-retry" data-lid="' + esc(a.lid) + '" aria-label="Retry ' + esc(a.name) + '" title="Retry">↻</button>' : '') +
1618
+ '<button type="button" class="att-btn att-x" data-action="att-remove" data-lid="' + esc(a.lid) + '" aria-label="Remove ' + esc(a.name) + '" title="Remove">×</button>' +
1619
+ (a.status === 'up' || a.status === 'prep' ? '<span class="att-bar" aria-hidden="true"><span data-pct="' + pct + '"></span></span>' : '') + '</div>'
1620
+ }
1621
+ // The board's CSP takes no inline styles: a progress bar's width is set here.
1622
+ function barWidths(root) { for (const b of root.querySelectorAll('.att-bar > span[data-pct]')) b.style.width = b.dataset.pct + '%' }
1623
+ // Redraw one session's chips in place (and the ↑'s state).
1624
+ function paintChips(sid) {
1625
+ const box = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"]')
1626
+ if (!box) return
1627
+ const atts = attachList(sid)
1628
+ const wrap = box.querySelector('.att-chips')
1629
+ const fa = wrap.contains(document.activeElement) ? focusKey(document.activeElement) : null // a progress tick keeps the chip you're on focused
1630
+ wrap.innerHTML = atts.map(chipHtml).join('')
1631
+ if (fa) { const t = wrap.querySelector(fa); if (t) t.focus({ preventScroll: true }) }
1632
+ barWidths(wrap)
1633
+ wrap.hidden = !atts.length
1634
+ box.classList.toggle('has-atts', !!atts.length)
1635
+ const ta = box.querySelector('.composer-input')
1636
+ if (ta) syncComposer(ta)
1637
+ }
1638
+ function addFiles(sid, files, { pasted = false } = {}) {
1639
+ const list = Array.from(files || []).filter(Boolean)
1640
+ if (!sid || !list.length) return
1641
+ const have = attachList(sid)
1642
+ const plan = attachPlan(list, have.length)
1643
+ if (plan.skipped.length) toast('Not attached: ' + skippedText(plan.skipped), 4200)
1644
+ const add = plan.take.map((f) => {
1645
+ const file = pasted && /^image\.\w+$|^$/.test(f.name || '') ? new File([f], pastedName(f.type), { type: f.type, lastModified: Date.now() }) : f
1646
+ return { lid: 'a' + (++attachUi.seq), sid, file, name: file.name || 'file', size: file.size, type: file.type || '', kind: attachKind(file), status: 'prep', sent: 0, total: file.size, id: null, preview: null, thumb: null, error: null }
1647
+ })
1648
+ if (!add.length) return
1649
+ attachUi.by.set(sid, [...have, ...add])
1650
+ paintChips(sid)
1651
+ try { promoteActive() } catch {}
1652
+ for (const a of add) startUpload(a)
1653
+ }
1654
+ function startUpload(a) {
1655
+ a.status = 'prep'; a.error = null; a.sent = 0
1656
+ attachUi.queue.push(a); pumpUploads()
1657
+ }
1658
+ // Three at a time: a phone on a slow line shouldn't open ten uploads at once.
1659
+ function pumpUploads() {
1660
+ while (attachUi.running < 3 && attachUi.queue.length) {
1661
+ const a = attachUi.queue.shift()
1662
+ if (!attachList(a.sid).includes(a)) continue
1663
+ attachUi.running++
1664
+ runUpload(a).finally(() => { attachUi.running--; pumpUploads() })
1665
+ }
1666
+ }
1667
+ async function runUpload(a) {
1668
+ const gone = () => !attachList(a.sid).includes(a)
1669
+ try {
1670
+ if (!a.prepared) {
1671
+ const p = await prepareFile(a.file)
1672
+ a.prepared = p.file; a.thumb = p.thumb
1673
+ a.name = p.file.name || a.name; a.size = p.file.size; a.total = p.file.size; a.type = p.file.type || a.type
1674
+ if (attachKind(p.file) === 'image' && !a.preview && p.thumb) a.preview = 'data:image/jpeg;base64,' + p.thumb
1675
+ // a PDF's first page, for its chip and its bubble (a few seconds at most; without it, the file icon)
1676
+ if (attachKind(p.file) === 'pdf' && !a.thumb) {
1677
+ const t = await Promise.race([pdfThumb(p.file), new Promise((r) => setTimeout(() => r(null), 5000))]).catch(() => null)
1678
+ if (t) { a.thumb = t; a.preview = 'data:image/jpeg;base64,' + t }
1679
+ }
1680
+ if (p.note === 'heic') a.error = 'This browser can’t convert HEIC; it goes as is'
1681
+ }
1682
+ if (gone()) return
1683
+ a.status = 'up'; paintChips(a.sid)
1684
+ let last = 0
1685
+ const f = await uploadFile({ api, sessionId: a.sid, file: a.prepared, thumb: a.thumb, isCancelled: gone,
1686
+ onProgress: (sent, total) => { a.sent = sent; a.total = total; const t = Date.now(); if (t - last > 120 || sent >= total) { last = t; paintChips(a.sid) } } })
1687
+ a.id = f.id; a.size = f.size; a.kind = f.kind; a.status = 'done'
1688
+ if (a.preview) attachUi.urls.set(a.sid + '/' + f.id, a.preview) // the bubble shows it without asking the Mac
1689
+ } catch (e) {
1690
+ if (e.code === 'removed' || gone()) return
1691
+ a.status = 'failed'; a.error = e.message || 'Upload failed'
1692
+ }
1693
+ paintChips(a.sid)
1694
+ attachWaiters()
1695
+ }
1696
+ let attachWaitList = []
1697
+ function attachWaiters() { const w = attachWaitList; attachWaitList = []; for (const f of w) f() }
1698
+ // Wait until nothing of this session's is still uploading.
1699
+ async function uploadsSettled(sid) {
1700
+ while (attachList(sid).some((a) => a.status === 'prep' || a.status === 'up')) await new Promise((r) => { attachWaitList.push(r); setTimeout(r, 1000) })
1701
+ }
1702
+ function removeAttachment(lid) {
1703
+ for (const [sid, list] of attachUi.by) {
1704
+ const a = list.find((x) => x.lid === lid)
1705
+ if (!a) continue
1706
+ attachUi.by.set(sid, list.filter((x) => x !== a))
1707
+ paintChips(sid)
1708
+ const ta = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"] .composer-input')
1709
+ if (ta && !isTouchUi(window.matchMedia)) ta.focus({ preventScroll: true })
1710
+ return
1711
+ }
1712
+ }
1713
+ function retryAttachment(lid) {
1714
+ for (const list of attachUi.by.values()) { const a = list.find((x) => x.lid === lid); if (a) { startUpload(a); paintChips(a.sid); return } }
1715
+ }
1716
+ // The composer a drop or a paste is for: the session page's own (enabled).
1717
+ function composerFor(el) {
1718
+ const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
1719
+ return box && !box.classList.contains('off') ? box : null
1720
+ }
1721
+ document.addEventListener('change', (ev) => {
1722
+ const inp = ev.target
1723
+ if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
1724
+ addFiles(inp.dataset.sid, inp.files)
1725
+ inp.value = '' // the same file can be picked again
1726
+ })
1727
+ document.addEventListener('paste', (ev) => {
1728
+ const t = ev.target
1729
+ const inComposer = t && t.classList && t.classList.contains('composer-input')
1730
+ if (!inComposer && t && t.closest && t.closest('input, textarea, [contenteditable]')) return
1731
+ const cd = ev.clipboardData
1732
+ const files = cd ? Array.from(cd.files || []).length ? Array.from(cd.files) : Array.from(cd.items || []).filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter(Boolean) : []
1733
+ if (!files.length) return
1734
+ const box = composerFor(inComposer ? t : null)
1735
+ if (!box) return
1736
+ ev.preventDefault()
1737
+ addFiles(box.dataset.sid, files, { pasted: true })
1738
+ })
1739
+ {
1740
+ // Drag files onto the session page: the box lights up; drop attaches.
1741
+ const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
1742
+ let leaveT = 0
1743
+ const lit = (on) => { const b = document.querySelector('#app .composer[data-sid]'); if (b) b.classList.toggle('att-drop', on) }
1744
+ document.addEventListener('dragover', (e) => { if (!hasFiles(e) || !composerFor(null)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; lit(true); clearTimeout(leaveT); leaveT = setTimeout(() => lit(false), 300) })
1745
+ document.addEventListener('drop', (e) => {
1746
+ if (!hasFiles(e)) return
1747
+ const box = composerFor(null)
1748
+ e.preventDefault() // never let a stray drop navigate away from the board
1749
+ lit(false)
1750
+ if (box) addFiles(box.dataset.sid, e.dataTransfer.files)
1751
+ })
1752
+ }
1753
+ // A sent message's attachments in its bubble: thumbnails (tap for full size) or file chips.
1754
+ function attBubbleHtml(atts, sid) {
1755
+ return '<div class="lv-atts">' + atts.map((a) => {
1756
+ const s0 = a.sessionId || sid, key = s0 + '/' + a.id
1757
+ const label = esc(a.name) + ' · ' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '')
1758
+ const data = ' data-action="att-open" data-sid="' + esc(s0) + '" data-att="' + esc(a.id) + '" data-name="' + esc(a.name) + '" data-kind="' + esc(a.kind) + '" data-thumb="' + (a.thumb === false ? '0' : '1') + '" data-size="' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '') + '"'
1759
+ if (a.kind === 'image') { const u = attachUi.urls.get(key); return '<button type="button" class="lv-att lv-att-img"' + data + ' aria-label="Open ' + esc(a.name) + '" title="' + label + '"><img alt="' + esc(a.name) + '" data-att-thumb="' + esc(key) + '"' + (u ? ' src="' + esc(u) + '"' : '') + '></button>' }
1760
+ const firstPage = a.kind === 'pdf' && a.thumb !== false ? '<img alt="" data-att-thumb="' + esc(key) + '"' + (attachUi.urls.get(key) ? ' src="' + esc(attachUi.urls.get(key)) + '"' : '') + '>' : '' // its first page, when there is one
1761
+ return '<button type="button" class="lv-att lv-att-file"' + data + ' aria-label="Open ' + esc(a.name) + '" title="' + label + '"><span class="att-thumb' + (firstPage ? ' att-thumb-pdf' : '') + '">' + FILE_IC + firstPage + '</span><span class="att-meta"><span class="att-name">' + esc(a.name) + '</span><span class="att-size">' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '') + '</span></span></button>'
1762
+ }).join('') + '</div>'
1763
+ }
1764
+ // Thumbnails come from the Mac once, then from memory. data: URLs, because the
1765
+ // board's CSP allows images from itself and data: only (no blob:).
1766
+ const blobToDataUrl = (b) => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = () => reject(r.error || new Error('unreadable')); r.readAsDataURL(b) })
1767
+ function attData(sid, id, { thumb = false } = {}) {
1768
+ const key = sid + '/' + id + (thumb ? '' : '#full')
1769
+ if (attachUi.urls.has(key)) return Promise.resolve({ url: attachUi.urls.get(key), blob: attachUi.blobs.get(key) || null })
1770
+ if (attachUi.loading.has(key)) return attachUi.loading.get(key)
1771
+ const p = fetchAttachment({ api, sessionId: sid, id, thumb }).then(async (b) => { const url = await blobToDataUrl(b); attachUi.urls.set(key, url); attachUi.blobs.set(key, b); return { url, blob: b } }).finally(() => attachUi.loading.delete(key))
1772
+ attachUi.loading.set(key, p)
1773
+ return p
1774
+ }
1775
+ function hydrateAttThumbs() {
1776
+ for (const img of document.querySelectorAll('#app img[data-att-thumb]:not([src])')) {
1777
+ const key = img.dataset.attThumb
1778
+ if (attachUi.urls.has(key)) { img.src = attachUi.urls.get(key); continue }
1779
+ const btn = img.closest('.lv-att'); if (!btn || btn.dataset.loading) continue
1780
+ btn.dataset.loading = '1'
1781
+ const thumb = btn.dataset.thumb !== '0'
1782
+ attData(btn.dataset.sid, btn.dataset.att, { thumb }).then(({ url }) => { attachUi.urls.set(key, url); for (const i of document.querySelectorAll('#app img[data-att-thumb="' + CSS.escape(key) + '"]')) i.src = url })
1783
+ .catch((e) => {
1784
+ // a PDF sent without a first-page picture (before 0.9.8, or it couldn't be drawn) keeps its icon
1785
+ if (btn.dataset.kind === 'pdf' && e && e.code === 'no_thumb') { for (const i of btn.querySelectorAll('img[data-att-thumb]')) i.remove(); return }
1786
+ btn.classList.add('gone'); btn.title = 'Not on that Mac any more'
1787
+ })
1788
+ }
1789
+ }
1790
+ // An image opens full screen (ui/lightbox.js) with the other pictures of the
1791
+ // same message beside it; the thumbnail shows (blurred) while the full one
1792
+ // comes from the Mac. Text and other files keep the docs viewer.
1793
+ const attSel = (sid, id) => '.lv-att[data-sid="' + CSS.escape(sid) + '"][data-att="' + CSS.escape(id) + '"]'
1794
+ function openImageAttachment(btn) {
1795
+ const group = btn.closest('.lv-atts')
1796
+ const btns = group ? [...group.querySelectorAll('.lv-att[data-kind="image"]')] : [btn]
1797
+ const items = btns.map((b) => {
1798
+ const im = b.querySelector('img'), sid = b.dataset.sid, id = b.dataset.att
1799
+ const full = attachUi.urls.get(sid + '/' + id + '#full')
1800
+ return { name: b.dataset.name || 'image', sizeLabel: b.dataset.size || '', thumb: (im && im.getAttribute('src')) || null, url: full || null,
1801
+ w: im && im.naturalWidth, h: im && im.naturalHeight, load: () => attData(sid, id).then((r) => r.url) }
1802
+ })
1803
+ const sid = btn.dataset.sid, id = btn.dataset.att
1804
+ openLightbox({ items, index: Math.max(0, btns.indexOf(btn)), from: btn.querySelector('img') || btn, refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1805
+ }
1806
+ // A chip in the box before it's sent: the picture from the file you picked
1807
+ // (a data: URL; the CSP takes no blob:), the other pictures in the box beside it.
1808
+ // Any other file: its name and size in the docs viewer (nothing to download yet).
1809
+ function openChip(lid) {
1810
+ let a = null
1811
+ for (const list of attachUi.by.values()) { a = list.find((x) => x.lid === lid); if (a) break }
1812
+ if (!a) return
1813
+ const chip = document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1814
+ const isImg = (x) => x.kind === 'image' && (x.preview || /^image\/(png|jpeg|gif|webp|bmp|svg\+xml|avif)$/i.test((x.prepared || x.file || {}).type || ''))
1815
+ const refocus = () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1816
+ if (a.kind === 'pdf') {
1817
+ const f = a.prepared || a.file
1818
+ openPdfView({ name: a.name, sizeLabel: fmtSize(a.size), download: false, from: chip, refocus, load: async () => ({ bytes: new Uint8Array(await f.arrayBuffer()) }) })
1819
+ return
1820
+ }
1821
+ if (isImg(a)) {
1822
+ const list = attachList(a.sid).filter(isImg)
1823
+ const items = list.map((x) => ({ name: x.name, sizeLabel: fmtSize(x.size), thumb: x.preview || null, download: false,
1824
+ load: () => { if (!x.fullUrl) x.fullUrl = blobToDataUrl(x.prepared || x.file); return x.fullUrl } }))
1825
+ openLightbox({ items, index: Math.max(0, list.indexOf(a)), from: (chip && chip.querySelector('img')) || chip, refocus: () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]') })
1826
+ return
1827
+ }
1828
+ const el = docViewerEl()
1829
+ el.querySelector('.dv-title').textContent = a.name
1830
+ el.querySelector('.dv-sub').textContent = ''
1831
+ el.querySelector('.dv-dl').hidden = true
1832
+ docUi.r = null; docUi.name = a.name
1833
+ el.querySelector('.dv-body').innerHTML = '<div class="empty att-chip-info"><b>' + esc(a.name) + '</b><br>' + esc(fmtSize(a.size)) + (a.status === 'done' ? ' · goes with your next message' : a.status === 'failed' ? ' · didn’t upload' : ' · uploading') + '</div>'
1834
+ el.hidden = false
1835
+ document.documentElement.classList.add('dv-open')
1836
+ el.querySelector('.dv-x') && el.querySelector('.dv-x').focus({ preventScroll: true })
1837
+ }
1838
+ // A PDF opens in its own full-screen preview (ui/pdfview.js): the pages drawn
1839
+ // from the file the Mac sends back (the same chunked read as a picture).
1840
+ function openPdfAttachment(btn) {
1841
+ const sid = btn.dataset.sid, id = btn.dataset.att
1842
+ openPdfView({ name: btn.dataset.name || 'file.pdf', sizeLabel: btn.dataset.size || '', from: btn,
1843
+ load: async () => { const r = await attData(sid, id); return { bytes: new Uint8Array(await r.blob.arrayBuffer()), url: r.url } },
1844
+ refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1845
+ }
1846
+ async function openAttachment(btn) {
1847
+ const sid = btn.dataset.sid, id = btn.dataset.att, name = btn.dataset.name || 'file', kind = btn.dataset.kind
1848
+ if (kind === 'image') return openImageAttachment(btn)
1849
+ if (kind === 'pdf') return openPdfAttachment(btn)
1850
+ const el = docViewerEl()
1851
+ el.querySelector('.dv-title').textContent = name
1852
+ el.querySelector('.dv-sub').textContent = ''
1853
+ el.querySelector('.dv-dl').hidden = true
1854
+ const body = el.querySelector('.dv-body')
1855
+ body.innerHTML = '<div class="empty">Opening…</div>'
1856
+ docUi.r = null; docUi.name = name
1857
+ el.hidden = false
1858
+ document.documentElement.classList.add('dv-open')
1859
+ try {
1860
+ const { url: u, blob } = await attData(sid, id)
1861
+ if (el.hidden) return
1862
+ const dl = '<a class="btn" href="' + esc(u) + '" download="' + esc(name) + '">' + icon('download') + 'Download</a>'
1863
+ if (kind === 'image') body.innerHTML = '<div class="att-full"><img src="' + esc(u) + '" alt="' + esc(name) + '"></div><div class="att-full-actions">' + dl + '</div>'
1864
+ else if (kind === 'text') { const t = blob ? await blob.text() : ''; body.innerHTML = '<pre class="att-text">' + esc(t.slice(0, 200000)) + '</pre><div class="att-full-actions">' + dl + '</div>' }
1865
+ else body.innerHTML = '<div class="empty">' + esc(name) + '</div><div class="att-full-actions">' + dl + '</div>'
1866
+ } catch (e) { if (!el.hidden) body.innerHTML = '<div class="empty">Couldn’t open it: ' + esc(e.message) + '</div>' }
1867
+ }
1868
+ // Fit the box to its words (one line up to COMPOSER_MAX_LINES, then it scrolls) and light the ↑ when there's something to send.
1869
+ function fitComposer(ta) {
1870
+ if (!ta || !ta.isConnected) return
1871
+ const cs = getComputedStyle(ta)
1872
+ const lh = parseFloat(cs.lineHeight) || 22
1873
+ const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
1874
+ const keep = ta.scrollTop
1875
+ ta.style.height = 'auto'
1876
+ const r = composerHeight({ scrollHeight: ta.scrollHeight, lineHeight: lh, padY })
1877
+ ta.style.height = r.height + 'px'
1878
+ ta.style.overflowY = r.scroll ? 'auto' : 'hidden'
1879
+ if (r.scroll) ta.scrollTop = keep
1880
+ syncComposer(ta)
1881
+ }
1882
+ function syncComposer(ta) {
1883
+ const box = ta && ta.closest('.composer')
1884
+ const btn = box && box.querySelector('.composer-send')
1885
+ if (!btn || btn.classList.contains('sending')) return
1886
+ btn.disabled = ta.disabled || (isBlank(ta.value) && !attachList(box.dataset.sid).length)
1887
+ }
1888
+ document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
1889
+ // Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
1890
+ // The same for Live and the Needs-you Reply composer: the key presses its own box's ↑.
1891
+ document.addEventListener('keydown', (ev) => {
1892
+ const ta = ev.target
1893
+ if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
1894
+ if (ev.metaKey || ev.ctrlKey) return // ⌘↵ / Ctrl+↵: the Needs-you handler presses the button
1895
+ if (composerKey(ev, { touch: isTouchUi(window.matchMedia) }) !== 'send') return
1896
+ ev.preventDefault()
1897
+ const cb = ta.closest('.composer')
1898
+ if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
1899
+ const box = ta.closest('.live-box') || ta.closest('.need')
1900
+ const btn = box && box.querySelector('[data-submit-for="' + CSS.escape(ta.dataset.submit || 'live') + '"]')
1901
+ if (!btn || btn.disabled) return
1902
+ const last = Number(box.dataset.kbdAt || 0)
1903
+ if (Date.now() - last < 1500) return
1904
+ box.dataset.kbdAt = String(Date.now())
1905
+ btn.click()
1906
+ })
1907
+ // A tap anywhere on the box (its padding) is a tap in the textarea.
1908
+ document.addEventListener('click', (ev) => { const b = ev.target && ev.target.classList && ev.target.classList.contains('composer') ? ev.target : null; const ta = b && b.querySelector('.composer-input'); if (ta && !ta.disabled) ta.focus() })
1909
+ // Pressing ↑ keeps the keyboard up (the textarea keeps focus) on a phone.
1910
+ document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.composer-send') && document.activeElement && document.activeElement.classList.contains('composer-input')) ev.preventDefault() })
1412
1911
  // After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
1413
1912
  function liveAfterRender(sid) {
1913
+ for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
1414
1914
  const r = liveKeeper.after($app)
1915
+ hydrateAttThumbs(); barWidths($app)
1916
+ { const w = document.getElementById('what-it-did'); if (w && widUi.has(sid) && hydrateDiffs(w, (p) => widSlot(widOf(sid), p))) widStickyTop() }
1415
1917
  if (!r) return
1416
1918
  const ol = r.thread.querySelector('.live-log')
1417
1919
  const empty = r.thread.querySelector('.live-empty')
@@ -1479,34 +1981,141 @@ async function liveEarlierClick(btn) {
1479
1981
  await render()
1480
1982
  }
1481
1983
  // The elapsed time beside "Claude is working…", every second while something is in flight.
1984
+ // 0.9.8: when what the page shows is older than STALE_MS (the stream died, the
1985
+ // phone slept), a running bubble says "Reconnecting…" instead of a timer.
1482
1986
  let liveTimer = null
1483
1987
  function liveTick() {
1484
1988
  const els = [...document.querySelectorAll('.lv-status[data-since]')]
1989
+ const box = document.querySelector('#live[data-id]')
1990
+ const stale = !!box && isStale(liveUi.okAt[box.dataset.id], Date.now())
1485
1991
  for (const el of els) {
1486
1992
  const since = Number(el.dataset.since) || 0
1487
1993
  const end = Number(el.dataset.final) || 0
1488
1994
  const t = el.querySelector('.lvs-time')
1489
1995
  if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
1996
+ if (!end) {
1997
+ el.classList.toggle('lv-reconnecting', stale)
1998
+ let hint = el.querySelector('.lvs-hint')
1999
+ if (stale && !hint) { hint = document.createElement('span'); hint.className = 'lvs-hint'; hint.textContent = 'Reconnecting…'; (el.querySelector('.lvs-line') || el).append(hint) }
2000
+ if (hint) hint.hidden = !stale
2001
+ }
1490
2002
  }
1491
2003
  const running = els.some((el) => !el.dataset.final)
1492
2004
  if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
1493
2005
  }
2006
+
2007
+ // 0.9.8: Live keeps up after the phone sleeps (ui/live-wake.js). Every Live
2008
+ // fetch notes when it answered and whether a message is still going; while
2009
+ // one is, on a visible page, the thread is fetched again every POLL_MS
2010
+ // whatever the event stream does.
2011
+ const liveUi = { okAt: {}, sig: {}, pending: {}, polling: false }
2012
+ function noteLive(id, v) {
2013
+ if (!v || v.error) return
2014
+ liveUi.okAt[id] = Date.now()
2015
+ liveUi.pending[id] = anyPending(v.messages)
2016
+ }
2017
+ const liveOnScreen = () => { const r = state.route; const box = document.querySelector('#live[data-id]'); return r && r.name === 's' && box && box.dataset.id === r.arg ? r.arg : null }
2018
+ // Fetch the thread and diff it into the page, even while you type (busy()
2019
+ // holds full redraws back, never the thread). A turn that ended also redraws
2020
+ // the rest of the page (the pill stops pulsing) once you're not busy.
2021
+ async function refreshLive(reason) {
2022
+ const id = liveOnScreen()
2023
+ if (!id || liveUi.polling) return false
2024
+ liveUi.polling = true; liveUi.triedAt = Date.now()
2025
+ let v
2026
+ try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
2027
+ if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
2028
+ const s = state.sessions.find((x) => x.id === id)
2029
+ const sig = liveSignature(v.messages)
2030
+ const before = liveUi.sig[id]
2031
+ liveUi.sig[id] = sig
2032
+ if (s && before !== sig) {
2033
+ const box = document.querySelector('#live')
2034
+ const cur = box && box.querySelector('.live-thread')
2035
+ if (cur) {
2036
+ const tmp = document.createElement('div')
2037
+ tmp.innerHTML = liveBoxHtml(s, v)
2038
+ const fresh = tmp.querySelector('.live-thread')
2039
+ if (fresh) {
2040
+ const next = cur.nextSibling
2041
+ liveKeeper.before(box)
2042
+ box.insertBefore(fresh, next)
2043
+ const r = liveKeeper.after(box)
2044
+ hydrateAttThumbs()
2045
+ if (r) liveJumpButton(r.thread, liveKeeper.unseen)
2046
+ }
2047
+ }
2048
+ const c = sessionCache.get(id); if (c) c.live = v
2049
+ if (!liveUi.pending[id] && !busy()) soon() // settled: the pill, the tab, the card
2050
+ }
2051
+ liveTick()
2052
+ return true
2053
+ }
2054
+ // The safety net, and the stream's watchdog, on one slow timer.
2055
+ const streamDog = createWatchdog()
2056
+ const wakeGate = createWake()
2057
+ setInterval(() => {
2058
+ if (document.visibilityState === 'hidden') return
2059
+ const id = liveOnScreen()
2060
+ if (shouldPoll({ visible: true, onLive: !!id, pending: !!(id && liveUi.pending[id]) }) && Date.now() - Math.max(liveUi.okAt[id] || 0, liveUi.triedAt || 0) >= POLL_MS - 250) refreshLive('poll')
2061
+ if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
2062
+ }, 1000)
2063
+ // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
2064
+ // bfcache restore, the network returns): fetch what's on screen at once, and
2065
+ // open a new stream if the old one missed its pings.
2066
+ function refreshView(reason) {
2067
+ if (liveOnScreen()) refreshLive(reason)
2068
+ if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
2069
+ refreshHealth()
2070
+ }
2071
+ let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
2072
+ function onWake(ev) {
2073
+ if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
2074
+ const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
2075
+ if (!why) return
2076
+ liveTick() // a stale timer turns into "Reconnecting…" before anything else
2077
+ const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
2078
+ const away = awayAt ? Date.now() - awayAt : 0
2079
+ awayAt = 0
2080
+ // a click back into the window a moment later isn't a wake-up; coming back after a while, a restore or the network is
2081
+ if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
2082
+ if (!wakeGate.due()) return
2083
+ if (why === 'pageshow-bfcache' || dead) connect()
2084
+ refreshView(why)
2085
+ }
2086
+ for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
2087
+ document.addEventListener('visibilitychange', onWake)
1494
2088
  async function liveSendClick(btn) {
1495
2089
  const box = btn.closest('.live-box')
1496
2090
  const ta = box && box.querySelector('.live-text')
2091
+ const sid = btn.dataset.id
1497
2092
  const text = ta ? ta.value.trim() : ''
1498
- if (!text) { toast('Type a message first'); return }
1499
- if (btn.getAttribute('aria-disabled') === 'true') return
1500
- btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Sending…'
2093
+ if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
2094
+ if (btn.disabled || btn.classList.contains('sending')) return
2095
+ const hadFocus = !!(ta && document.activeElement === ta)
2096
+ btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
1501
2097
  try {
1502
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/live', { text })
1503
- if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
1504
- if (ta) ta.value = ''
2098
+ // 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
2099
+ await uploadsSettled(sid)
2100
+ const atts = attachList(sid)
2101
+ const failed = atts.filter((a) => a.status === 'failed').length
2102
+ if (failed) { toast(failed === 1 ? 'A file didn’t upload: retry it or remove it.' : failed + ' files didn’t upload: retry or remove them.', 4200); return }
2103
+ const ids = atts.filter((a) => a.status === 'done' && a.id).map((a) => a.id)
2104
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live', { text, ...(ids.length ? { attachments: ids } : {}) })
2105
+ // what you typed while it went stays; what was sent goes
2106
+ if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
2107
+ if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
1505
2108
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
1506
2109
  const ol = box && box.querySelector('.live-log')
1507
2110
  if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
1508
2111
  setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
1509
- } catch (e) { toast(e.message, 4200) } finally { await render(); const nb = document.querySelector('.live-box [data-action="live-send"]'); if (nb) { nb.removeAttribute('aria-disabled'); nb.textContent = 'Send' } }
2112
+ } catch (e) { toast(e.message, 4200) } finally {
2113
+ await render()
2114
+ const nb = document.querySelector('.live-box [data-action="live-send"]')
2115
+ if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
2116
+ const nt = document.querySelector('.live-box .composer-input')
2117
+ if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
2118
+ }
1510
2119
  }
1511
2120
 
1512
2121
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
@@ -1669,6 +2278,11 @@ async function togglePr(url, { retry = false } = {}) {
1669
2278
  const was = prsUi.open.has(url)
1670
2279
  if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
1671
2280
  const repaint = () => {
2281
+ if (state.route && state.route.name === 's') { // 0.9.8: the session page's PRs open in place too
2282
+ const s = state.sessions.find((x) => x.id === state.route.arg), sec = document.getElementById('prs')
2283
+ if (s && sec) sec.innerHTML = sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details })
2284
+ return
2285
+ }
1672
2286
  const d = prsUi.data[prsUi.view]
1673
2287
  const r = prRows(d).find((x) => x.pr.url === url)
1674
2288
  const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
@@ -1682,7 +2296,7 @@ async function togglePr(url, { retry = false } = {}) {
1682
2296
  try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
1683
2297
  catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
1684
2298
  prsUi.details[url] = { ...res, at: Date.now() }
1685
- if (state.route && state.route.name === 'prs') repaint()
2299
+ if (state.route && (state.route.name === 'prs' || state.route.name === 's')) repaint()
1686
2300
  }
1687
2301
  function setPrsFilter(f) {
1688
2302
  const h = prsHash(prsUi.view, f)
@@ -1743,29 +2357,67 @@ async function viewTimeline() {
1743
2357
  $app.innerHTML = html
1744
2358
  }
1745
2359
 
1746
- function renderMd(md, { spaces = [] } = {}) {
2360
+ // 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
2361
+ // links there and Start here gets that session's Go / Resume.
2362
+ const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
2363
+ function briefAction(id, sessions, viewer) {
2364
+ const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
2365
+ const s = sessions && sessions.get(id)
2366
+ if (!s) return open
2367
+ let b = ''
2368
+ // 0.9.8: closed: Resume opens it in a new tab where it can, else (on the Mac) copies the resume command
2369
+ if (s.live === 'closed' || s.status === 'ended') b = resumeButton(s, viewer || {}) || (s.agent || !s.cwd || (viewer && viewer.local === false) || state.viewerLocal === false ? '' : '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy the resume command">' + icon('copy') + 'Resume</button>')
2370
+ else { const g = goButton(s); b = g && !/disabled/.test(g) ? g : '' }
2371
+ return b || open
2372
+ }
2373
+
2374
+ function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
1747
2375
  const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
1748
2376
  const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
1749
2377
  const out = []
1750
- let inList = false, inSec = false
2378
+ let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
2379
+ const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
2380
+ const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
2381
+ const href = (id) => '#/s/' + encodeURIComponent(id)
1751
2382
  // v0.5.8: each "## <space>" opens a section with its own Copy for standup;
1752
2383
  // other "## " headings (Lists, Top of mind) are whole-folder sections.
2384
+ // 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
1753
2385
  const openSec = (h) => {
1754
2386
  if (inSec) out.push('</section>')
1755
2387
  inSec = true
1756
- if (spaces.includes(h)) {
2388
+ if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
2389
+ else if (spaces.includes(h)) {
1757
2390
  out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
1758
2391
  '<button type="button" class="btn standup-btn' + (h === 'Work' ? ' primary' : '') + '" data-action="standup-copy" data-space="' + esc(h) + '">' + icon('clipboard') + 'Copy for standup</button></div>')
1759
2392
  } else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
1760
2393
  }
1761
- for (const line of body.split('\n')) {
2394
+ for (const raw of body.split('\n')) {
2395
+ const mid = raw.match(MD_SID)
2396
+ const id = mid ? mid[1] : null
2397
+ const line = mid ? raw.slice(0, mid.index) : raw
2398
+ // 0.9.8: Start here: "1. <step> — **Title** (repo)"
2399
+ const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
2400
+ if (st) {
2401
+ openList('ol', 'start')
2402
+ const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
2403
+ out.push('<li' + (id ? ' data-sid="' + esc(id) + '"' : '') + '><div class="sh-main"><span class="sh-step">' + inline(st[1]) + '</span><span class="sh-sess">' + t + (st[3] ? ' <span class="sh-repo">' + esc(st[3]) + '</span>' : '') + '</span></div>' +
2404
+ (id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
2405
+ continue
2406
+ }
1762
2407
  if (/^- /.test(line)) {
1763
- if (!inList) { out.push('<ul>'); inList = true }
2408
+ openList('ul')
1764
2409
  // Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
1765
2410
  const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
2411
+ const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
2412
+ // 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
2413
+ // session: the title links to it, the sentence under it, the tags quiet.
2414
+ const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
2415
+ // 0.9.8: an Unfinished one is closed, so it has its Resume beside it
2416
+ if (bi && inUnf) { out.push('<li class="bi unf"' + attrs + '><div class="bi-body"><div class="bi-head"><a class="bi-title" href="' + href(id) + '">' + inline(bi[1]) + '</a><span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span></div><div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>'); continue }
2417
+ if (bi) { out.push('<li class="bi"' + attrs + '><div class="bi-head"><a class="bi-title" href="' + href(id) + '">' + inline(bi[1]) + '</a><span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span>'); continue }
1766
2418
  // v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
1767
2419
  const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
1768
- out.push('<li' + (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + '>' + li); continue
2420
+ out.push('<li' + attrs + '>' + li); continue
1769
2421
  }
1770
2422
  if (/^\s{2,}\S/.test(line) && inList) {
1771
2423
  // "Stopped at: … Next: …" is two lines, each with its label.
@@ -1774,13 +2426,17 @@ function renderMd(md, { spaces = [] } = {}) {
1774
2426
  else out.push('<span class="sub">' + inline(t) + '</span>')
1775
2427
  continue
1776
2428
  }
1777
- if (inList) { out.push('</ul>'); inList = false }
2429
+ closeList()
2430
+ if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
1778
2431
  if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
1779
- else if (/^## /.test(line)) { if (spaces.length) openSec(line.slice(3).trim()); else out.push('<h2>' + inline(line.slice(3)) + '</h2>') }
2432
+ else if (/^## /.test(line)) { seenH2 = true; const h = line.slice(3).trim(); if (spaces.length || h === 'Start here') openSec(h); else { if (inSec) { out.push('</section>'); inSec = false } out.push('<h2>' + inline(h) + '</h2>') } }
2433
+ // 0.9.8: Finished folds away (older digests' "Finished or closed" too)
2434
+ else if (/^### Finished(?: or closed)? \(\d+\)\s*$/.test(line)) { out.push('<details class="fin"><summary>' + inline(line.slice(4).replace(/ or closed/, '')) + '</summary>'); inFin = true }
1780
2435
  else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
1781
- else if (line.trim()) out.push('<p>' + inline(line) + '</p>')
2436
+ // 0.9.8: the Yesterday / Today opener, above the first section
2437
+ else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
1782
2438
  }
1783
- if (inList) out.push('</ul>')
2439
+ closeList()
1784
2440
  if (inSec) out.push('</section>')
1785
2441
  return out.join('\n')
1786
2442
  }
@@ -1791,6 +2447,81 @@ function shortDay(iso) {
1791
2447
  return isNaN(d) ? iso : d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '')
1792
2448
  }
1793
2449
 
2450
+ // The past-digests calendar (0.9.8): one month at a time, Sunday first. A day
2451
+ // with a digest is a link (the shared one, else a Mac's own from before one
2452
+ // digest a day); the rest are plain numbers. Opens on the picked digest's
2453
+ // month; next stops at this month, prev at the first month with a digest.
2454
+ const monthOf = (iso) => String(iso).slice(0, 7)
2455
+ const localDay = (t = new Date()) => t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0')
2456
+ function shiftMonth(ym, by) {
2457
+ const [y, m] = ym.split('-').map(Number)
2458
+ const d = new Date(y, m - 1 + by, 1)
2459
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
2460
+ }
2461
+ function digestCalendar() {
2462
+ const { days = [], pick = null } = digestUi.cal || {}
2463
+ const today = localDay()
2464
+ const max = monthOf(today)
2465
+ const min = days.length ? days.map((x) => monthOf(x.day)).sort()[0] : max
2466
+ let ym = digestUi.month || max
2467
+ if (ym > max) ym = max
2468
+ if (ym < min) ym = min
2469
+ digestUi.month = ym
2470
+ const [y, m] = ym.split('-').map(Number)
2471
+ const first = new Date(y, m - 1, 1)
2472
+ const label = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
2473
+ const by = new Map(days.map((x) => [x.day, x.d]))
2474
+ const prev = shiftMonth(ym, -1), next = shiftMonth(ym, 1)
2475
+ const name = (x) => new Date(x + '-01T12:00:00').toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
2476
+ const navBtn = (to, ok, dir) => '<button type="button" class="cal-nav" data-action="digest-month" data-to="' + to + '"' + (ok ? '' : ' disabled') + ' aria-label="' + (dir < 0 ? 'Previous month, ' : 'Next month, ') + esc(name(to)) + '">' + icon(dir < 0 ? 'chevron-left' : 'chevron-right') + '</button>'
2477
+ // phones fold it to one week (the picked day's) until you open the month
2478
+ const open = !!digestUi.calOpen
2479
+ let h = '<div class="cal-head">' + navBtn(prev, prev >= min, -1) + '<span class="cal-month" aria-live="polite">' + esc(label) + '</span>' +
2480
+ '<button type="button" class="cal-month cal-fold" data-action="digest-cal-fold" aria-expanded="' + open + '" aria-label="' + esc(label) + (open ? ', show one week' : ', show the whole month') + '">' + esc(label) + icon('chevron-down') + '</button>' + navBtn(next, next <= max, 1) + '</div>'
2481
+ h += '<div class="cal-grid' + (open ? ' open' : '') + '">' + ['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((w) => '<span class="cal-wd" aria-hidden="true">' + w + '</span>').join('')
2482
+ const n = new Date(y, m, 0).getDate()
2483
+ const lead0 = first.getDay()
2484
+ const week = (dn) => Math.floor((lead0 + dn - 1) / 7)
2485
+ const inMonth = (x) => x && x.slice(0, 7) === ym
2486
+ const firstHas = days.map((x) => x.day).filter(inMonth).sort()[0]
2487
+ const wk = week(inMonth(pick) ? Number(pick.slice(8)) : inMonth(today) ? Number(today.slice(8)) : firstHas ? Number(firstHas.slice(8)) : 1)
2488
+ for (let i = 0; i < lead0; i++) h += '<span class="cal-day pad' + (wk === 0 ? ' wk' : '') + '" aria-hidden="true"></span>'
2489
+ let count = 0
2490
+ for (let dn = 1; dn <= n; dn++) {
2491
+ const day = ym + '-' + String(dn).padStart(2, '0')
2492
+ const d = by.get(day)
2493
+ const cls = ['cal-day', d ? 'has' : 'none', day === pick ? 'on' : '', day === today ? 'today' : '', week(dn) === wk ? 'wk' : ''].filter(Boolean).join(' ')
2494
+ const long = shortDay(day)
2495
+ if (d) {
2496
+ count++
2497
+ const whose = d.shared ? '' : ', ' + d.machine + '’s own digest'
2498
+ h += '<a class="' + cls + '" href="#/digest/' + day + (d.shared ? '' : '?machine=' + encodeURIComponent(d.machine)) + '" data-day="' + day + '" aria-label="' + esc(long + whose + (day === today ? ', today' : '')) + '"' + (day === pick ? ' aria-current="date"' : '') + '><span class="n">' + dn + '</span><span class="dot" aria-hidden="true"></span></a>'
2499
+ } else h += '<span class="' + cls + '" data-day="' + day + '"><span class="n">' + dn + '</span></span>'
2500
+ }
2501
+ h += '</div>'
2502
+ if (!count) h += '<div class="cal-none">' + (days.length ? 'No digests in ' + esc(first.toLocaleDateString('en-US', { month: 'long' })) + '.' : 'None yet') + '</div>'
2503
+ return h
2504
+ }
2505
+
2506
+ // Month or fold changed: patch the calendar in place, so the arrows (and the
2507
+ // focus on them, with its keyboard ring) stay put.
2508
+ function repaintCalendar() {
2509
+ const nav = document.querySelector('nav.digest-cal')
2510
+ if (!nav) return null
2511
+ const t = document.createElement('div')
2512
+ t.innerHTML = digestCalendar()
2513
+ const now = [...nav.querySelectorAll('.cal-head > *')], fresh = [...t.querySelectorAll('.cal-head > *')]
2514
+ now.forEach((el, i) => {
2515
+ const f = fresh[i]
2516
+ for (const a of ['data-to', 'aria-label', 'aria-expanded']) if (f.hasAttribute(a)) el.setAttribute(a, f.getAttribute(a))
2517
+ el.disabled = !!f.disabled
2518
+ if (!el.classList.contains('cal-nav')) el.innerHTML = f.innerHTML
2519
+ })
2520
+ for (const el of nav.querySelectorAll('.cal-grid, .cal-none')) el.remove()
2521
+ nav.append(...t.querySelectorAll('.cal-grid, .cal-none'))
2522
+ return nav
2523
+ }
2524
+
1794
2525
  // Digest (v0.5.8): one digest a day for the whole folder. The strip has one
1795
2526
  // pill per day: the shared digest, else (days from before 0.5.8) a Mac's own,
1796
2527
  // labelled with that Mac. Space tabs (All / Work / Personal …) sit above the
@@ -1815,16 +2546,16 @@ async function viewDigest(date, machine) {
1815
2546
  const who = dg.order && dg.order.length > 1 ? ' Made by ' + esc(dg.order[0]) + ' (first in line), or the next Mac awake ' + esc(dg.graceMin) + ' min later.' : ''
1816
2547
  const nextLine = todayLine + (dg.time ? 'Next automatic digest: ' + esc(when(dg.nextAt) || 'at ' + dg.time) + ' · ' + esc(dg.daysLabel || 'Every day') + ', ' + esc(dg.label || dg.time) + '.' + who : 'Automatic digests are off (<a href="#/settings/digest">Settings → Digest</a>).')
1817
2548
  if (dg.lastError) html += '<div class="banner warn" role="status"><span class="grow"><b>The last digest failed</b> (' + esc(ago(dg.lastError.at)) + '): ' + esc(dg.lastError.message) + '. Generate now tries again; <code>leftoff doctor</code> shows it too.</span></div>'
1818
- html += '<div class="digest-wrap"><nav class="dates digest-dates" aria-label="Past digests">' + (days.length ? days.map((day) => {
1819
- const d = lead(day)
1820
- const on = pick === day
1821
- const label = d.shared ? '' : d.machine
1822
- return '<a href="#/digest/' + day + (d.shared ? '' : '?machine=' + encodeURIComponent(d.machine)) + '" class="' + (on ? 'on' : '') + '" title="' + esc(day + (d.shared ? '' : ' · ' + d.machine + '’s own digest (from before one digest a day)')) + '"><span class="d-iso">' + day + '</span><span class="d-short">' + esc(shortDay(day)) + '</span>' + (label ? ' <span class="m">' + esc(label) + '</span>' : '') + '</a>'
1823
- }).join('') : '<span class="nocp">None yet</span>') + '</nav>'
2549
+ // 0.9.8: a month calendar, not a long list: days with a digest are links
2550
+ if (digestUi.date !== pick || !digestUi.month) { digestUi.month = monthOf(pick || localDay()); digestUi.calOpen = false }
2551
+ digestUi.cal = { days: days.map((day) => ({ day, d: lead(day) })), pick }
2552
+ html += '<div class="digest-wrap"><nav class="digest-cal" aria-label="Past digests">' + digestCalendar() + '</nav>'
1824
2553
  let spaces = []
1825
2554
  if (pick) {
1826
2555
  try {
1827
- const d = await api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : ''))
2556
+ // 0.9.8: the sessions too, for Start here's Go / Resume
2557
+ const [d, sv] = await Promise.all([api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : '')), api('GET', '/v1/sessions?limit=500&archived=1').catch(() => null)])
2558
+ if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
1828
2559
  const fm = d.meta || {}
1829
2560
  const madeAt = fm.generated_at
1830
2561
  spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
@@ -1834,10 +2565,14 @@ async function viewDigest(date, machine) {
1834
2565
  const also = others.length ? '<div class="digest-also">Also for this day: ' + others.map((x) => '<a href="#/digest/' + x.date + (x.shared ? '' : '?machine=' + encodeURIComponent(x.machine)) + '">' + esc(x.shared ? 'the shared digest' : x.machine + '’s') + '</a>').join(' · ') + '</div>' : ''
1835
2566
  const unnamed = spv && spaces.some((x) => (spv.machines || []).some((m) => !m.set && m.machine === x)) ? '<div class="digest-hint">Spaces are named after your Macs until you pick one for each: <a href="#/settings/spaces">Settings → Spaces</a> (Work, Personal or your own).</div>' : ''
1836
2567
  html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
1837
- '<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces }) + '</article>'
2568
+ '<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces, sessions: sv ? new Map((sv.sessions || []).map((x) => [x.id, x])) : null, viewer: sv && sv.viewer }) + '</article>'
1838
2569
  } catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
1839
2570
  } else html += '<div class="empty"><p><strong>No digest yet.</strong></p><p>' + nextLine + '</p><p><button type="button" class="btn primary" data-action="gen-digest">Generate now</button> makes one from your sessions so far.</p></div>'
1840
2571
  $app.innerHTML = html + '</div>'
2572
+ // 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
2573
+ const art = $app.querySelector('article.md')
2574
+ const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
2575
+ if (after) after.after(...['.digest-hint', '#digestSpaces', '#digestRepos'].map((q) => art.querySelector(q)).filter(Boolean))
1841
2576
  const params = (state.route && state.route.params) || new URLSearchParams()
1842
2577
  state.digestRepos = readFilter(params, window.localStorage).repos
1843
2578
  state.digestSpaces = spaces
@@ -1859,7 +2594,7 @@ function paintDigestFilter() {
1859
2594
  const spaces = state.digestSpaces || []
1860
2595
  const sp = state.digestSpace
1861
2596
  const secs = [...document.querySelectorAll('.md section')]
1862
- for (const sec of secs) sec.hidden = !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
2597
+ for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
1863
2598
  const tabsBox = document.getElementById('digestSpaces')
1864
2599
  if (tabsBox) {
1865
2600
  const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
@@ -1877,8 +2612,19 @@ function paintDigestFilter() {
1877
2612
  const any = [...ul.children].some((li) => !li.hidden)
1878
2613
  ul.hidden = !any
1879
2614
  if (h && (h.tagName === 'H2' || h.tagName === 'H3')) h.hidden = !any
2615
+ const fin = ul.closest('details.fin')
2616
+ if (fin) fin.hidden = !any
1880
2617
  }
1881
2618
  if (sel.length) for (const sec of secs) if (sec.classList.contains('space-sec') && !sec.hidden && ![...sec.querySelectorAll('li[data-repo]')].some((li) => !li.hidden)) sec.hidden = true
2619
+ // 0.9.8: a Start here step follows its session's line: hidden with it (space or repo)
2620
+ const startSec = document.querySelector('.md section.start-sec')
2621
+ if (startSec) {
2622
+ for (const li of startSec.querySelectorAll('li[data-sid]')) {
2623
+ const twin = [...document.querySelectorAll('.md li[data-repo][data-sid]')].find((x) => x.dataset.sid === li.dataset.sid)
2624
+ li.hidden = !!twin && (twin.hidden || !!(twin.closest('section') && twin.closest('section').hidden))
2625
+ }
2626
+ startSec.hidden = ![...startSec.querySelectorAll('ol.start > li')].some((li) => !li.hidden)
2627
+ }
1882
2628
  if (counts.size < 2) { box.innerHTML = ''; return }
1883
2629
  const on = (r) => sel.some((tok) => repoMatches({ repoKey: r, repoLabel: r }, tok))
1884
2630
  box.innerHTML = '<div class="track"><button type="button" class="rchip all' + (sel.length ? '' : ' on') + '" data-action="digest-repo" data-repo=""><span class="rl">All</span><span class="rc">' + items.length + '</span></button>' +
@@ -2183,11 +2929,13 @@ async function tryLength() {
2183
2929
  const s = list[0]
2184
2930
  if (!s) { toast('No session yet to try it on'); return }
2185
2931
  box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
2932
+ // the page can be redrawn while the summarizer writes (Save re-renders Settings): put the result in the box on screen then
2933
+ const now = () => document.getElementById('f-length-example') || box
2186
2934
  try {
2187
2935
  const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
2188
- box.innerHTML = '<div class="ex-head">Your session · ' + esc(s.title || 'Untitled') + ' <span class="faint">' + esc(k) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') : '') + ' · not saved</span></div>' +
2936
+ now().innerHTML = '<div class="ex-head">Your session · ' + esc(s.title || 'Untitled') + ' <span class="faint">' + esc(k) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') : '') + ' · not saved</span></div>' +
2189
2937
  cpBlock(cp, { showWhen: false, md: true }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
2190
- } catch (e) { box.innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
2938
+ } catch (e) { now().innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
2191
2939
  }
2192
2940
 
2193
2941
  // Am I the iPhone home-screen app (or another installed app window)?
@@ -2651,7 +3399,7 @@ async function viewSettings() {
2651
3399
  updates: '<div class="section">About</div>' +
2652
3400
  '<label>Version</label><div class="aboutline"><span class="chip mono">v' + esc(h.version || '') + '</span><a href="#/whats-new">What’s new</a><a href="' + NPM_URL + '" target="_blank" rel="noopener">npm ↗</a></div>' +
2653
3401
  '<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
2654
- '<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a></div>' +
3402
+ '<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a> · <a href="#/tour">Take the tour</a></div>' +
2655
3403
  advanced('updates', '<label for="f-updates">Check daily</label><label class="check"><input id="f-updates" type="checkbox"' + (!(c.updates && c.updates.check === false) ? ' checked' : '') + '><span>Look for a new version daily. Nothing installs on its own.</span></label>' +
2656
3404
  '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
2657
3405
  '<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
@@ -3350,7 +4098,7 @@ function paintListsCount(lists) {
3350
4098
  // The session page's Lists section: saved lists, the hint, and "Find lists in this session".
3351
4099
  function sessionListsSection(s) {
3352
4100
  const f = listUi.found[s.id]
3353
- let h = '<h2>Lists <span class="count">' + ((s.lists || []).length) + '</span><button type="button" class="helpq" data-action="list-help-go" title="How lists work">?</button></h2>'
4101
+ let h = '<h2>Lists <span class="count">' + ((s.lists || []).length) + '</span><button type="button" class="helpq" data-action="list-help-inline" aria-expanded="' + (listUi.sHelp ? 'true' : 'false') + '" aria-controls="sListsHelp" title="How lists work">?</button></h2>' + (listUi.sHelp ? listsHelp().replace('id="listsHelp"', 'id="sListsHelp"') : '')
3354
4102
  if (s.lists && s.lists.length) h += '<div class="lists">' + s.lists.map((x) => (state.listFull && state.listFull[x.ref] ? listCard(state.listFull[x.ref], { session: false }) : '')).join('') + '</div>'
3355
4103
  if (s.listHint) h += '<div class="lfound hint"><div><strong>Claude wrote a ' + esc(s.listHint.count) + '-step list</strong>: ' + esc(s.listHint.name) + '</div><div class="row"><button type="button" class="btn primary" data-action="list-save-hint" data-id="' + esc(s.id) + '">Save</button><button type="button" class="btn" data-action="list-dismiss" data-id="' + esc(s.id) + '">Dismiss</button></div></div>'
3356
4104
  h += '<div class="lfind"><button type="button" class="btn" data-action="list-find" data-id="' + esc(s.id) + '"' + (listUi.finding[s.id] ? ' disabled' : '') + '>' + icon('list-checks') + (listUi.finding[s.id] ? 'Reading the transcript…' : 'Find lists in this session') + '</button>' +
@@ -3395,8 +4143,11 @@ async function render() {
3395
4143
  else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
3396
4144
  else if (r.name === 'search') await viewSearch(r.q || '')
3397
4145
  else if (r.name === 'setup') await viewSetup()
4146
+ else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
3398
4147
  else await viewBoard()
3399
4148
  tabsAfterRender()
4149
+ // a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
4150
+ for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
3400
4151
  } catch (e) {
3401
4152
  if (e.code === 'login_required') return showLogin()
3402
4153
  $app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
@@ -3407,12 +4158,20 @@ async function render() {
3407
4158
  // it keeps its own step and fetches its own state.
3408
4159
  async function viewSetup() {
3409
4160
  if ($app.querySelector('.wz')) return
3410
- await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
4161
+ await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/'; autoTour() } })
3411
4162
  }
3412
4163
  // Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
4164
+ // Otherwise the product tour gets its turn.
3413
4165
  async function firstRunSetup() {
3414
- if (location.hash.replace(/^#\/?/, '')) return
3415
- try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) location.replace('#/setup') } catch {}
4166
+ if (!location.hash.replace(/^#\/?/, '')) {
4167
+ try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) return location.replace('#/setup') } catch {}
4168
+ }
4169
+ autoTour()
4170
+ }
4171
+ // The product tour (0.9.8, ui/tour.js): by itself once per Mac, after setup, on
4172
+ // the board; #/tour (Settings → Updates & about, the palette) replays it.
4173
+ async function autoTour() {
4174
+ try { const t = await api('GET', '/v1/tour'); if (shouldAutoTour(t, parseRoute().name)) startTour({ api, auto: true }) } catch {}
3416
4175
  }
3417
4176
 
3418
4177
  // A remote browser (Tailscale) that hasn't signed in yet: the iPhone
@@ -3471,7 +4230,9 @@ async function refreshHealth() {
3471
4230
  if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
3472
4231
  paintMoreDots()
3473
4232
  paintSettingsDots()
4233
+ if (updateBar) updateBar.check(h)
3474
4234
  } catch (e) {
4235
+ if (updateBar) updateBar.check(null) // a failed check changes nothing
3475
4236
  el.className = 'health down'
3476
4237
  el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
3477
4238
  el.title = el.querySelector('.label').textContent
@@ -3720,6 +4481,15 @@ async function refreshTabSessions() {
3720
4481
  paintTabs()
3721
4482
  } catch {}
3722
4483
  }
4484
+ // What had focus in the strip, to find it again after a repaint: the same control,
4485
+ // else (Keep open is gone once kept) the same tab's ×, else the tab itself.
4486
+ function tabFocusKey(el) {
4487
+ const d = el.dataset, q = (a, v) => '[' + a + '="' + CSS.escape(v) + '"]'
4488
+ if (d.tabClose) return [q('data-tab-close', d.tabClose), q('data-tab', d.tabClose)]
4489
+ if (d.tabKeep) return [q('data-tab-keep', d.tabKeep), q('data-tab-close', d.tabKeep), q('data-tab', d.tabKeep)]
4490
+ if (d.tab) return [q('data-tab', d.tab)]
4491
+ return el.id ? ['#' + CSS.escape(el.id)] : null
4492
+ }
3723
4493
  function paintTabs() {
3724
4494
  const views = tabViews()
3725
4495
  const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
@@ -3727,10 +4497,10 @@ function paintTabs() {
3727
4497
  const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
3728
4498
  if (sh !== tabsUi.html.strip) {
3729
4499
  const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
3730
- const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
4500
+ const had = strip.contains(document.activeElement) ? tabFocusKey(document.activeElement) : null
3731
4501
  strip.innerHTML = sh; tabsUi.html.strip = sh
3732
4502
  const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
3733
- if (had) { const el = strip.querySelector('[data-tab="' + CSS.escape(had) + '"], [data-tab-close="' + CSS.escape(had) + '"], [data-tab-keep="' + CSS.escape(had) + '"], #' + CSS.escape(had)); if (el) el.focus({ preventScroll: true }) }
4503
+ if (had) { const el = had.map((q) => strip.querySelector(q)).find(Boolean); if (el) el.focus({ preventScroll: true }) }
3734
4504
  }
3735
4505
  strip.hidden = false
3736
4506
  const rh = openRowHtml(views, esc)
@@ -3798,6 +4568,32 @@ document.addEventListener('click', (e) => {
3798
4568
  if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
3799
4569
  })
3800
4570
  document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
4571
+ // The strip's buttons name themselves in a styled tip under them (hover after a
4572
+ // beat, keyboard focus at once), not a native title. The strip scrolls sideways,
4573
+ // which clips anything hanging below it, so the tip is one fixed element on the page.
4574
+ {
4575
+ const tip = document.createElement('span'); tip.className = 'strip-tip'; tip.setAttribute('aria-hidden', 'true'); document.body.appendChild(tip)
4576
+ let cur = null, timer = 0
4577
+ const hide = () => { clearTimeout(timer); cur = null; tip.classList.remove('on') }
4578
+ const show = (el, delay) => {
4579
+ clearTimeout(timer); cur = el
4580
+ timer = setTimeout(() => {
4581
+ if (cur !== el || !el.isConnected) return hide()
4582
+ tip.textContent = el.dataset.tip
4583
+ const r = el.getBoundingClientRect(), w = tip.offsetWidth
4584
+ tip.style.left = Math.round(Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2))) + 'px'
4585
+ tip.style.top = Math.round(r.bottom + 4) + 'px'
4586
+ tip.classList.add('on')
4587
+ }, delay)
4588
+ }
4589
+ const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip]')
4590
+ document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
4591
+ document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
4592
+ document.addEventListener('focusin', (e) => { const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
4593
+ document.addEventListener('focusout', (e) => { if (tipped(e) === cur) hide() })
4594
+ for (const ev of ['pointerdown', 'scroll', 'hashchange', 'resize']) window.addEventListener(ev, hide, true)
4595
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) hide() })
4596
+ }
3801
4597
  // Working in a session keeps its tab: a Live message, typing there, answering Claude there
3802
4598
  const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
3803
4599
  for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
@@ -3819,7 +4615,7 @@ document.addEventListener('click', (e) => { if (e.target.closest && $app.contain
3819
4615
  sw = null
3820
4616
  if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
3821
4617
  const t = e.target
3822
- if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .tabs, .moremenu') || scrollsSideways(t)) return
4618
+ if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .moremenu') || scrollsSideways(t)) return
3823
4619
  sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
3824
4620
  }, { passive: true })
3825
4621
  document.addEventListener('touchend', (e) => {
@@ -3884,7 +4680,7 @@ function keepPlace() {
3884
4680
  ptr.innerHTML = '<span class="ptr-ring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></span>'
3885
4681
  document.body.appendChild(ptr)
3886
4682
  const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
3887
- const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
4683
+ const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden]), .emoji-sheet-wrap')
3888
4684
  let pull = null, refreshing = false
3889
4685
  const paint = (step) => {
3890
4686
  ptr.hidden = !step || (!step.pulling && !refreshing)
@@ -3934,6 +4730,43 @@ function keepPlace() {
3934
4730
  document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
3935
4731
  }
3936
4732
 
4733
+ // Hide the nav on scroll (phones, 0.9.8): under 700px the top row and the bottom
4734
+ // bar slide away while you scroll down and come back when you scroll up, reach
4735
+ // the top or the very bottom (ui/navhide.js). Only your own scrolling hides
4736
+ // them: a route change, the ‹ › pager or a swipe shows them and holds them until
4737
+ // you next touch, wheel or key; typing, a menu or a sheet keeps them shown. The
4738
+ // page never reflows: the top row stays in the flow and the bar is an overlay
4739
+ // over padding that stays.
4740
+ {
4741
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
4742
+ const top = document.querySelector('.top')
4743
+ let st = { hidden: false, anchor: 0 }, raf = 0, held = true
4744
+ const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
4745
+ const overlay = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .lo-backdrop, .doc-viewer:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden]), .emoji-sheet-wrap, .relay-screen:not([hidden])')
4746
+ const apply = (hidden) => {
4747
+ if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
4748
+ document.body.classList.toggle('nav-hidden', hidden)
4749
+ }
4750
+ const read = () => {
4751
+ raf = 0
4752
+ const se = document.scrollingElement || document.documentElement
4753
+ const y = window.scrollY || se.scrollTop || 0
4754
+ const next = navHideStep(st, { y, max: se.scrollHeight - window.innerHeight, blocked: held || !narrowQ.matches || typing() || overlay() })
4755
+ if (next.hidden !== st.hidden) apply(next.hidden)
4756
+ st = next
4757
+ }
4758
+ const soon = () => { if (!raf) raf = requestAnimationFrame(read) }
4759
+ // no layout read here: this runs on every route change, ahead of the page's own render
4760
+ const showNav = (hold = true) => { if (hold) held = true; const was = st.hidden; st = { hidden: false, anchor: NaN }; if (was) apply(false) }
4761
+ window.addEventListener('scroll', soon, { passive: true })
4762
+ for (const ev of ['touchstart', 'wheel', 'keydown']) window.addEventListener(ev, () => { held = false }, { passive: true, capture: true })
4763
+ window.addEventListener('hashchange', () => showNav(true))
4764
+ document.addEventListener('focusin', (e) => { if (typing() || (top && top.contains(e.target))) showNav(false) })
4765
+ document.addEventListener('click', soon, { passive: true, capture: true }) // a menu or sheet just opened: back they come
4766
+ narrowQ.addEventListener('change', () => showNav(false))
4767
+ window.leftoffNav = { show: () => showNav(true), hidden: () => document.body.classList.contains('nav-hidden') }
4768
+ }
4769
+
3937
4770
  // Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
3938
4771
  const statusOpen = () => { const p = document.getElementById('statusPop'); return p && !p.hidden }
3939
4772
  function syncLine() {
@@ -4067,6 +4900,7 @@ function paletteActions() {
4067
4900
  }
4068
4901
  if (projectsUi.mayEdit) out.push({ action: 'project-new', title: 'New project…', sub: 'a named group of repos', icon: 'plus' })
4069
4902
  out.push({ action: 'gen-digest-go', title: 'Today’s digest', sub: 'open it', icon: 'newspaper', href: '#/digest' })
4903
+ out.push({ action: 'tour', title: 'Take the tour', sub: 'a quick look around the board', icon: 'sparkles', href: '#/tour' })
4070
4904
  return out
4071
4905
  }
4072
4906
  function searchGroups(q, r) {
@@ -4216,11 +5050,27 @@ document.addEventListener('keydown', (e) => {
4216
5050
 
4217
5051
  // A card opens its session's page from anywhere on it, except its buttons,
4218
5052
  // links, fields and selected text (0.4.3: the timeline was too hidden).
5053
+ // 0.9.8: the tap shows at once (a pressed card with a spinner); the page's
5054
+ // shell follows in the same frame (viewSession).
5055
+ function cardOpening(c) {
5056
+ for (const x of document.querySelectorAll('.card.opening')) if (x !== c) x.classList.remove('opening')
5057
+ c.classList.add('opening')
5058
+ setTimeout(() => { if (c.isConnected) c.classList.remove('opening') }, 4000)
5059
+ }
5060
+ document.addEventListener('pointerdown', (ev) => {
5061
+ const c = ev.target.closest && ev.target.closest('.card[data-href]')
5062
+ if (!c || ev.button > 0 || ev.target.closest('button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
5063
+ const a = ev.target.closest('a')
5064
+ if (a && !/^#\/s\//.test(a.getAttribute('href') || '')) return // a repo chip, a PR link: not opening the session
5065
+ cardOpening(c)
5066
+ }, { passive: true })
5067
+ document.addEventListener('pointercancel', () => { for (const x of document.querySelectorAll('.card.opening')) x.classList.remove('opening') }, true)
4219
5068
  document.addEventListener('click', (ev) => {
4220
5069
  const c = ev.target.closest('.card[data-href]')
4221
5070
  if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
4222
5071
  if (String(window.getSelection && window.getSelection()).trim()) return
4223
5072
  if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
5073
+ cardOpening(c)
4224
5074
  location.hash = c.dataset.href
4225
5075
  })
4226
5076
 
@@ -4267,7 +5117,44 @@ document.addEventListener('click', async (ev) => {
4267
5117
  const id = btn.dataset.id || (card && card.dataset.id)
4268
5118
  const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
4269
5119
  try {
4270
- if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
5120
+ if (action === 'copy-cmd') {
5121
+ const ok = await copy(btn.dataset.cmd)
5122
+ if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
5123
+ clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
5124
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy Live resume'); btn.dataset.tip = 'Copy' }, 1500)
5125
+ }
5126
+ return
5127
+ }
5128
+ // 0.9.8 What it did
5129
+ if (action === 'wid-toggle') { const ui = widOf(id); ui.open = !ui.open; paintWid(id); if (ui.open && !ui.tools && !ui.diff && !ui.loading) loadWid(id); return }
5130
+ if (action === 'wid-refresh') return loadWid(id)
5131
+ if (action === 'wid-tool') {
5132
+ const ui = widOf(id), t = btn.dataset.tool
5133
+ ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
5134
+ // a command's output ends with what matters (a failure, the summary): open it at the end
5135
+ const v = (ui.tools || []).find((x) => x.id === t)
5136
+ const pre = ui.toolOpen[t] && v && (v.tool === 'Bash' || v.error) && document.querySelector('#wid-t-' + CSS.escape(t) + ' .wid-pre')
5137
+ if (pre) pre.scrollTop = pre.scrollHeight
5138
+ return
5139
+ }
5140
+ if (action === 'wid-tool-more') {
5141
+ const ui = widOf(id), t = btn.dataset.tool
5142
+ if (ui.toolLoading[t] === true) return
5143
+ ui.toolLoading[t] = true; paintWid(id)
5144
+ try { const r = await api('GET', widPath(id, 'tools') + '?tool=' + encodeURIComponent(t)); if (r.tool) ui.toolFull[t] = r.tool; delete ui.toolLoading[t] } catch { ui.toolLoading[t] = 'error' }
5145
+ return paintWid(id)
5146
+ }
5147
+ if (action === 'wid-file') { const ui = widOf(id), p = btn.dataset.path; ui.fileOpen[p] = !ui.fileOpen[p]; paintWid(id); if (ui.fileOpen[p] && !ui.files[p]) loadWidFile(id, p); return }
5148
+ if (action === 'wid-copy') {
5149
+ const pageId = parseRoute().arg // the page's session (the copy button carries no id)
5150
+ const ok = await copy(widCopyText(widOf(pageId), btn.dataset.kind, btn.dataset.key))
5151
+ if (ok) {
5152
+ const label = btn.getAttribute('aria-label')
5153
+ clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.dataset.tip = 'Copied'
5154
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', label); btn.dataset.tip = 'Copy' }, 1500)
5155
+ }
5156
+ return
5157
+ }
4271
5158
  // PRs (0.6.9)
4272
5159
  if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = prsHash(v, prsUi.filter); return }
4273
5160
  // 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
@@ -4370,6 +5257,7 @@ document.addEventListener('click', async (ev) => {
4370
5257
  if (action === 'list-showall') { listUi.showAll = !listUi.showAll; return render() }
4371
5258
  if (action === 'list-help') { listUi.help = !listUi.help; return render() }
4372
5259
  if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
5260
+ if (action === 'list-help-inline') { listUi.sHelp = !listUi.sHelp; render(); return }
4373
5261
  if (action === 'remote-link' || action === 'remote-code') {
4374
5262
  const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
4375
5263
  if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
@@ -4534,7 +5422,8 @@ document.addEventListener('click', async (ev) => {
4534
5422
  if (action === 'tl-session') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: btn.dataset.session || null }); return }
4535
5423
  if (action === 'tl-trigger') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, trigger: btn.dataset.trigger || null }); return }
4536
5424
  if (action === 'tl-clear') { saveRepos(window.localStorage, []); location.hash = '#/timeline?repo='; return }
4537
- if (action === 'clear-filters') { state.moreOpen = false; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: null, project: null }) }
5425
+ if (action === 'clear-filters') { state.moreOpen = false; state.boardSpace = null; saveSpace(null); setMachineFilter(''); return setBoardFilter({ repos: [], status: null, project: null }) }
5426
+ if (action === 'machine-all') { setMachineFilter(''); return render() }
4538
5427
  // Projects (0.9.0): the filter chips, the chip on a card, and the menus
4539
5428
  if (action === 'board-project' || action === 'project-filter') {
4540
5429
  ev.preventDefault(); ev.stopPropagation()
@@ -4557,6 +5446,19 @@ document.addEventListener('click', async (ev) => {
4557
5446
  saveSpace(state.digestSpace)
4558
5447
  return paintDigestFilter()
4559
5448
  }
5449
+ if (action === 'digest-cal-fold') {
5450
+ digestUi.calOpen = !digestUi.calOpen
5451
+ repaintCalendar()
5452
+ return
5453
+ }
5454
+ if (action === 'digest-month') {
5455
+ const back = btn.dataset.to < (digestUi.month || '')
5456
+ digestUi.month = btn.dataset.to
5457
+ digestUi.calOpen = true // another month: show all of it
5458
+ const nav = repaintCalendar()
5459
+ if (nav && btn.disabled) { const [p, n] = nav.querySelectorAll('.cal-nav'); const b = back ? n : p; if (!b.disabled) b.focus() } // the end of the range: focus stays on the arrows
5460
+ return
5461
+ }
4560
5462
  if (action === 'standup-copy') return standupCopy(btn)
4561
5463
  if (action === 'standup-close') { const b = btn.closest('.standup-out'); if (b) b.remove(); digestUi.open = null; return }
4562
5464
  if (action === 'space-quick' || action === 'space-repo-add' || action === 'space-unset') return spacesAction(action, btn)
@@ -4582,6 +5484,11 @@ document.addEventListener('click', async (ev) => {
4582
5484
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
4583
5485
  if (action === 'reply-send') return replyClick(btn)
4584
5486
  if (action === 'live-send') return liveSendClick(btn)
5487
+ if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
5488
+ if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
5489
+ if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
5490
+ if (action === 'att-open') return openAttachment(btn)
5491
+ if (action === 'att-chip-open') return openChip(btn.dataset.lid)
4585
5492
  if (action === 'live-jump') return liveJumpClick(btn)
4586
5493
  if (action === 'live-earlier') return liveEarlierClick(btn)
4587
5494
  if (action === 'quick-reply') return quickReplyClick(btn)
@@ -4595,6 +5502,7 @@ document.addEventListener('click', async (ev) => {
4595
5502
  return render()
4596
5503
  }
4597
5504
  if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
5505
+ if (action === 'lv-tool-open') return openToolOutput(id, btn.dataset.tool, btn.dataset.label) // 0.9.8: a Live tool chip opens its output
4598
5506
  if (action === 'doc-close') return closeDoc()
4599
5507
  if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
4600
5508
  if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
@@ -4754,9 +5662,11 @@ async function replyClick(btn) {
4754
5662
  const need = btn.closest('.need')
4755
5663
  const ta = need && need.querySelector('.reply-text')
4756
5664
  const text = ta ? ta.value.trim() : ''
4757
- if (!text) { toast('Type a reply first'); return }
5665
+ if (!text) { toast('Type a reply first'); if (ta && !ta.disabled) ta.focus(); return }
4758
5666
  if (text.startsWith('/')) { toast('Replies can’t start with “/”'); return }
4759
- btn.disabled = true; btn.textContent = 'Sending…'
5667
+ if (btn.classList.contains('sending')) return
5668
+ // the composer's ↑ (0.9.8): it pulses while the reply goes, like Live's
5669
+ btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
4760
5670
  try {
4761
5671
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
4762
5672
  delete state.drafts['reply:' + need.dataset.id]
@@ -4879,6 +5789,46 @@ async function openDoc(sessionId, { path, plan } = {}) {
4879
5789
  el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(e.message) + '</div>'
4880
5790
  }
4881
5791
  }
5792
+ // 0.9.8 What it did: one tool's output (up to 64 KB, redacted on its Mac) in
5793
+ // the file viewer, from a Live tool chip. Copy and Download as text.
5794
+ async function openToolOutput(sessionId, toolId, label) {
5795
+ if (!sessionId || !toolId) return
5796
+ const el = docViewerEl()
5797
+ const name = label || 'Tool output'
5798
+ el.querySelector('.dv-title').textContent = name
5799
+ el.querySelector('.dv-sub').textContent = ''
5800
+ el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
5801
+ const dl = el.querySelector('.dv-dl'); dl.hidden = true
5802
+ docUi.r = null; docUi.name = name
5803
+ el.hidden = false
5804
+ document.documentElement.classList.add('dv-open')
5805
+ try {
5806
+ const r = await api('GET', '/v1/sessions/' + encodeURIComponent(sessionId) + '/tools?tool=' + encodeURIComponent(toolId))
5807
+ if (el.hidden) return
5808
+ const t = r.tool || {}
5809
+ el.querySelector('.dv-title').textContent = t.label || name
5810
+ el.querySelector('.dv-sub').textContent = [t.what && t.what !== t.label ? t.what : '', t.error ? 'failed' : '', t.clipped ? 'cut to the first and last parts' : '', r.peer ? 'from ' + r.peer : ''].filter(Boolean).join(' · ')
5811
+ const note = t.running ? 'Still running; no output yet.' : t.hidden ? widHidden(t.hidden) : !t.output && !t.patch ? 'No output.' : ''
5812
+ const body = el.querySelector('.dv-body')
5813
+ // an edit shows what it changed, drawn like a diff (DOM, textContent only)
5814
+ if (!note && t.patch) { body.textContent = ''; const box = document.createElement('div'); box.className = 'gd-doc'; box.appendChild(renderDiff(document, t.patch, { label: 'Changes', numbers: t.patchAt != null })); body.appendChild(box) }
5815
+ else body.innerHTML = note ? '<div class="empty">' + esc(note) + '</div>' : '<pre class="doc-pre">' + esc(t.output) + '</pre>'
5816
+ const fname = t.patch ? 'changes.diff' : 'output.txt'
5817
+ el.querySelector('.dv-ptitle').textContent = fname
5818
+ if (!note) {
5819
+ const doc = { type: 'text', mime: 'text/plain', name: fname, text: t.patch || t.output }
5820
+ docUi.r = doc; docUi.name = fname
5821
+ const items = docMenuItems(doc)
5822
+ dl.hidden = false
5823
+ dl.setAttribute('aria-haspopup', items.length > 1 ? 'menu' : 'false')
5824
+ dl.querySelector('.chev').hidden = items.length <= 1
5825
+ dl.removeAttribute('title')
5826
+ dl.setAttribute('aria-label', 'Copy or download')
5827
+ }
5828
+ } catch (e) {
5829
+ el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(widError(e)) + '</div>'
5830
+ }
5831
+ }
4882
5832
  function openDocMenu(btn) {
4883
5833
  const r = docUi.r
4884
5834
  const items = docMenuItems(r)
@@ -5069,16 +6019,21 @@ let stream = null
5069
6019
  function connect() {
5070
6020
  if (stream) { try { stream.close() } catch {} }
5071
6021
  const es = stream = new EventSource('/v1/stream')
6022
+ const again = connect.opened // a reconnect: events may have been missed while it was down
6023
+ streamDog.opened()
6024
+ // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
6025
+ es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
5072
6026
  es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
5073
6027
  es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
5074
- for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'projects.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, soon)
6028
+ for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'projects.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, () => { if (stream === es) streamDog.seen(); soon() })
5075
6029
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
5076
6030
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
5077
6031
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
5078
6032
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
5079
6033
  // port; if the browser gave up on the stream, open a new one.
5080
6034
  es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
5081
- es.onopen = () => refreshHealth()
6035
+ let opens = 0
6036
+ es.onopen = () => { if (stream !== es) return; streamDog.seen(); refreshHealth(); if (again || opens++) refreshView('reconnect'); connect.opened = true }
5082
6037
  }
5083
6038
 
5084
6039
  document.addEventListener('change', async (ev) => {
@@ -5139,7 +6094,7 @@ document.addEventListener('change', async (ev) => {
5139
6094
  return cuLoadOptions().then(render)
5140
6095
  }
5141
6096
  if (ev.target.id === 'tlSession') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: ev.target.value || null }); return }
5142
- if (ev.target.id === 'machineFilter') { state.machine = ev.target.value; localStorage.setItem('cp.machine', state.machine); render() }
6097
+ if (ev.target.id === 'machineFilter') { setMachineFilter(ev.target.value); render() }
5143
6098
  })
5144
6099
 
5145
6100
  // Moving between Settings sections only swaps the section shown (v0.5.9): a
@@ -5181,7 +6136,46 @@ document.addEventListener('pointerdown', (ev) => {
5181
6136
  if (m && !m.contains(ev.target)) m.open = false
5182
6137
  })
5183
6138
  api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
6139
+ // 0.9.8: "Leftoff updated · Refresh" when the Mac serving this board has newer
6140
+ // files than the ones this page runs. It never reloads by itself; Refresh
6141
+ // carries your drafts over and says first what wouldn't come along.
6142
+ updateBar = mountUpdateBar({
6143
+ icon,
6144
+ health: () => refreshHealth(),
6145
+ risks: () => {
6146
+ let attachments = 0, uploading = 0
6147
+ for (const list of attachUi.by.values()) for (const a of list) { attachments++; if (a.status === 'prep' || a.status === 'up') uploading++ }
6148
+ const f = document.activeElement
6149
+ const typing = !!(f && /^(INPUT|TEXTAREA)$/.test(f.tagName) && !f.dataset.draft && !f.readOnly && !/^(checkbox|radio|button|submit|range|color|file|search)$/.test(f.type) && !f.closest('.cmdk, #settingsForm') && String(f.value || '').trim())
6150
+ let unsavedSettings = false
6151
+ try { unsavedSettings = settingsChanged().length > 0 } catch {}
6152
+ return { attachments, uploading, unsavedSettings, typing }
6153
+ },
6154
+ carry: () => {
6155
+ const drafts = { ...state.drafts }
6156
+ for (const t of document.querySelectorAll('textarea[data-draft]')) if (t.value) drafts[t.dataset.draft] = t.value
6157
+ const f = document.activeElement
6158
+ const focus = f && f.dataset && f.dataset.draft ? { key: f.dataset.draft, a: f.selectionStart || 0, b: f.selectionEnd || 0 } : lastDraftFocus
6159
+ return { drafts, focus }
6160
+ },
6161
+ })
6162
+ // The box you were typing in, if you tabbed from it to the bar.
6163
+ let lastDraftFocus = null
6164
+ document.addEventListener('focusin', (ev) => { if (!(ev.target.closest && ev.target.closest('#updateBar'))) lastDraftFocus = null })
6165
+ document.addEventListener('focusout', (ev) => { const t = ev.target; if (t && t.dataset && t.dataset.draft) lastDraftFocus = { key: t.dataset.draft, a: t.selectionStart || 0, b: t.selectionEnd || 0 } })
6166
+ // Back from Refresh: the box you were typing in has its text, and (with a
6167
+ // keyboard and mouse) the cursor where it was.
6168
+ function restoreCarriedFocus(tries = 50) {
6169
+ const f = carried && carried.focus
6170
+ if (!f || isTouchUi(window.matchMedia)) return
6171
+ if (document.activeElement && document.activeElement !== document.body) return // you've moved on
6172
+ const t = [...document.querySelectorAll('textarea[data-draft]')].find((x) => x.dataset.draft === f.key)
6173
+ if (!t || t.disabled) { if (tries > 0) setTimeout(() => restoreCarriedFocus(tries - 1), 100); return } // the session page draws after its data
6174
+ t.focus({ preventScroll: true })
6175
+ try { const n = t.value.length; t.setSelectionRange(Math.min(f.a, n), Math.min(f.b, n)) } catch {}
6176
+ }
5184
6177
  refreshHealth().then(render).then(firstRunSetup)
6178
+ restoreCarriedFocus()
5185
6179
  connect()
5186
6180
 
5187
6181
  // The home-screen app's service worker (v0.4.6): it only serves an offline page