@leftoff/cli 0.9.8-beta.9 → 0.9.8

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 } 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 ''
@@ -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>') +
@@ -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')
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,344 @@ 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 SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
1598
+ const CLIP_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
1599
+ 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>'
1600
+ function composerHtml(s, draft, may) {
1601
+ const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
1602
+ const atts = attachList(s.id)
1603
+ const off = !may || (isBlank(draft) && !atts.length)
1604
+ return '<div class="composer live-row' + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '" data-sid="' + esc(s.id) + '">' +
1605
+ '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
1606
+ '<textarea class="composer-input live-text" rows="1" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" autocomplete="off" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd) + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
1607
+ // the phone's own picker (Photo Library, Take Photo, Choose File): any file
1608
+ '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
1609
+ '<button type="button" class="composer-attach" data-action="attach" data-id="' + esc(s.id) + '" aria-label="Attach" title="Attach photos or files"' + (may ? '' : ' disabled') + '><span class="composer-clip">' + CLIP_IC + '</span></button>' +
1610
+ '<button type="button" class="composer-send" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '" aria-label="Send" title="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
1611
+ }
1612
+
1613
+ // ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
1614
+ // Each file uploads to the Mac that runs the session as soon as you attach it;
1615
+ // the chips live here (not in the DOM), so a redraw of the page keeps them.
1616
+ const attachUi = { by: new Map(), urls: new Map(), blobs: new Map(), loading: new Map(), seq: 0, running: 0, queue: [] }
1617
+ const attachList = (sid) => attachUi.by.get(sid) || []
1618
+ function chipHtml(a) {
1619
+ const pct = a.total ? Math.min(100, Math.round((a.sent / a.total) * 100)) : 0
1620
+ const status = a.status === 'failed' ? 'Didn’t upload' : a.status === 'done' ? fmtSize(a.size) : a.status === 'prep' ? 'Preparing…' : fmtSize(a.size) + ' · ' + pct + '%'
1621
+ return '<div class="att-chip ' + esc(a.status) + '" data-lid="' + esc(a.lid) + '"' + (a.error ? ' title="' + esc(a.error) + '"' : '') + '>' +
1622
+ '<button type="button" class="att-open" data-action="att-chip-open" data-lid="' + esc(a.lid) + '" aria-label="Preview ' + esc(a.name) + '">' +
1623
+ '<span class="att-thumb">' + (a.preview ? '<img src="' + esc(a.preview) + '" alt="">' : FILE_IC) + '</span>' +
1624
+ '<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>' +
1625
+ (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>' : '') +
1626
+ '<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>' +
1627
+ (a.status === 'up' || a.status === 'prep' ? '<span class="att-bar" aria-hidden="true"><span data-pct="' + pct + '"></span></span>' : '') + '</div>'
1628
+ }
1629
+ // The board's CSP takes no inline styles: a progress bar's width is set here.
1630
+ function barWidths(root) { for (const b of root.querySelectorAll('.att-bar > span[data-pct]')) b.style.width = b.dataset.pct + '%' }
1631
+ // Redraw one session's chips in place (and the ↑'s state).
1632
+ function paintChips(sid) {
1633
+ const box = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"]')
1634
+ if (!box) return
1635
+ const atts = attachList(sid)
1636
+ const wrap = box.querySelector('.att-chips')
1637
+ const fa = wrap.contains(document.activeElement) ? focusKey(document.activeElement) : null // a progress tick keeps the chip you're on focused
1638
+ wrap.innerHTML = atts.map(chipHtml).join('')
1639
+ if (fa) { const t = wrap.querySelector(fa); if (t) t.focus({ preventScroll: true }) }
1640
+ barWidths(wrap)
1641
+ wrap.hidden = !atts.length
1642
+ box.classList.toggle('has-atts', !!atts.length)
1643
+ const ta = box.querySelector('.composer-input')
1644
+ if (ta) syncComposer(ta)
1645
+ }
1646
+ function addFiles(sid, files, { pasted = false } = {}) {
1647
+ const list = Array.from(files || []).filter(Boolean)
1648
+ if (!sid || !list.length) return
1649
+ const have = attachList(sid)
1650
+ const plan = attachPlan(list, have.length)
1651
+ if (plan.skipped.length) toast('Not attached: ' + skippedText(plan.skipped), 4200)
1652
+ const add = plan.take.map((f) => {
1653
+ const file = pasted && /^image\.\w+$|^$/.test(f.name || '') ? new File([f], pastedName(f.type), { type: f.type, lastModified: Date.now() }) : f
1654
+ 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 }
1655
+ })
1656
+ if (!add.length) return
1657
+ attachUi.by.set(sid, [...have, ...add])
1658
+ paintChips(sid)
1659
+ try { promoteActive() } catch {}
1660
+ for (const a of add) startUpload(a)
1661
+ }
1662
+ function startUpload(a) {
1663
+ a.status = 'prep'; a.error = null; a.sent = 0
1664
+ attachUi.queue.push(a); pumpUploads()
1665
+ }
1666
+ // Three at a time: a phone on a slow line shouldn't open ten uploads at once.
1667
+ function pumpUploads() {
1668
+ while (attachUi.running < 3 && attachUi.queue.length) {
1669
+ const a = attachUi.queue.shift()
1670
+ if (!attachList(a.sid).includes(a)) continue
1671
+ attachUi.running++
1672
+ runUpload(a).finally(() => { attachUi.running--; pumpUploads() })
1673
+ }
1674
+ }
1675
+ async function runUpload(a) {
1676
+ const gone = () => !attachList(a.sid).includes(a)
1677
+ try {
1678
+ if (!a.prepared) {
1679
+ const p = await prepareFile(a.file)
1680
+ a.prepared = p.file; a.thumb = p.thumb
1681
+ a.name = p.file.name || a.name; a.size = p.file.size; a.total = p.file.size; a.type = p.file.type || a.type
1682
+ if (attachKind(p.file) === 'image' && !a.preview && p.thumb) a.preview = 'data:image/jpeg;base64,' + p.thumb
1683
+ // a PDF's first page, for its chip and its bubble (a few seconds at most; without it, the file icon)
1684
+ if (attachKind(p.file) === 'pdf' && !a.thumb) {
1685
+ const t = await Promise.race([pdfThumb(p.file), new Promise((r) => setTimeout(() => r(null), 5000))]).catch(() => null)
1686
+ if (t) { a.thumb = t; a.preview = 'data:image/jpeg;base64,' + t }
1687
+ }
1688
+ if (p.note === 'heic') a.error = 'This browser can’t convert HEIC; it goes as is'
1689
+ }
1690
+ if (gone()) return
1691
+ a.status = 'up'; paintChips(a.sid)
1692
+ let last = 0
1693
+ const f = await uploadFile({ api, sessionId: a.sid, file: a.prepared, thumb: a.thumb, isCancelled: gone,
1694
+ onProgress: (sent, total) => { a.sent = sent; a.total = total; const t = Date.now(); if (t - last > 120 || sent >= total) { last = t; paintChips(a.sid) } } })
1695
+ a.id = f.id; a.size = f.size; a.kind = f.kind; a.status = 'done'
1696
+ if (a.preview) attachUi.urls.set(a.sid + '/' + f.id, a.preview) // the bubble shows it without asking the Mac
1697
+ } catch (e) {
1698
+ if (e.code === 'removed' || gone()) return
1699
+ a.status = 'failed'; a.error = e.message || 'Upload failed'
1700
+ }
1701
+ paintChips(a.sid)
1702
+ attachWaiters()
1703
+ }
1704
+ let attachWaitList = []
1705
+ function attachWaiters() { const w = attachWaitList; attachWaitList = []; for (const f of w) f() }
1706
+ // Wait until nothing of this session's is still uploading.
1707
+ async function uploadsSettled(sid) {
1708
+ while (attachList(sid).some((a) => a.status === 'prep' || a.status === 'up')) await new Promise((r) => { attachWaitList.push(r); setTimeout(r, 1000) })
1709
+ }
1710
+ function removeAttachment(lid) {
1711
+ for (const [sid, list] of attachUi.by) {
1712
+ const a = list.find((x) => x.lid === lid)
1713
+ if (!a) continue
1714
+ attachUi.by.set(sid, list.filter((x) => x !== a))
1715
+ paintChips(sid)
1716
+ const ta = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"] .composer-input')
1717
+ if (ta && !isTouchUi(window.matchMedia)) ta.focus({ preventScroll: true })
1718
+ return
1719
+ }
1720
+ }
1721
+ function retryAttachment(lid) {
1722
+ for (const list of attachUi.by.values()) { const a = list.find((x) => x.lid === lid); if (a) { startUpload(a); paintChips(a.sid); return } }
1723
+ }
1724
+ // The composer a drop or a paste is for: the session page's own (enabled).
1725
+ function composerFor(el) {
1726
+ const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
1727
+ return box && !box.classList.contains('off') ? box : null
1728
+ }
1729
+ document.addEventListener('change', (ev) => {
1730
+ const inp = ev.target
1731
+ if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
1732
+ addFiles(inp.dataset.sid, inp.files)
1733
+ inp.value = '' // the same file can be picked again
1734
+ })
1735
+ document.addEventListener('paste', (ev) => {
1736
+ const t = ev.target
1737
+ const inComposer = t && t.classList && t.classList.contains('composer-input')
1738
+ if (!inComposer && t && t.closest && t.closest('input, textarea, [contenteditable]')) return
1739
+ const cd = ev.clipboardData
1740
+ 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) : []
1741
+ if (!files.length) return
1742
+ const box = composerFor(inComposer ? t : null)
1743
+ if (!box) return
1744
+ ev.preventDefault()
1745
+ addFiles(box.dataset.sid, files, { pasted: true })
1746
+ })
1747
+ {
1748
+ // Drag files onto the session page: the box lights up; drop attaches.
1749
+ const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
1750
+ let leaveT = 0
1751
+ const lit = (on) => { const b = document.querySelector('#app .composer[data-sid]'); if (b) b.classList.toggle('att-drop', on) }
1752
+ 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) })
1753
+ document.addEventListener('drop', (e) => {
1754
+ if (!hasFiles(e)) return
1755
+ const box = composerFor(null)
1756
+ e.preventDefault() // never let a stray drop navigate away from the board
1757
+ lit(false)
1758
+ if (box) addFiles(box.dataset.sid, e.dataTransfer.files)
1759
+ })
1760
+ }
1761
+ // A sent message's attachments in its bubble: thumbnails (tap for full size) or file chips.
1762
+ function attBubbleHtml(atts, sid) {
1763
+ return '<div class="lv-atts">' + atts.map((a) => {
1764
+ const s0 = a.sessionId || sid, key = s0 + '/' + a.id
1765
+ const label = esc(a.name) + ' · ' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '')
1766
+ 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 || '') + '"'
1767
+ 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>' }
1768
+ 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
1769
+ 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>'
1770
+ }).join('') + '</div>'
1771
+ }
1772
+ // Thumbnails come from the Mac once, then from memory. data: URLs, because the
1773
+ // board's CSP allows images from itself and data: only (no blob:).
1774
+ 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) })
1775
+ function attData(sid, id, { thumb = false } = {}) {
1776
+ const key = sid + '/' + id + (thumb ? '' : '#full')
1777
+ if (attachUi.urls.has(key)) return Promise.resolve({ url: attachUi.urls.get(key), blob: attachUi.blobs.get(key) || null })
1778
+ if (attachUi.loading.has(key)) return attachUi.loading.get(key)
1779
+ 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))
1780
+ attachUi.loading.set(key, p)
1781
+ return p
1782
+ }
1783
+ function hydrateAttThumbs() {
1784
+ for (const img of document.querySelectorAll('#app img[data-att-thumb]:not([src])')) {
1785
+ const key = img.dataset.attThumb
1786
+ if (attachUi.urls.has(key)) { img.src = attachUi.urls.get(key); continue }
1787
+ const btn = img.closest('.lv-att'); if (!btn || btn.dataset.loading) continue
1788
+ btn.dataset.loading = '1'
1789
+ const thumb = btn.dataset.thumb !== '0'
1790
+ 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 })
1791
+ .catch((e) => {
1792
+ // a PDF sent without a first-page picture (before 0.9.8, or it couldn't be drawn) keeps its icon
1793
+ if (btn.dataset.kind === 'pdf' && e && e.code === 'no_thumb') { for (const i of btn.querySelectorAll('img[data-att-thumb]')) i.remove(); return }
1794
+ btn.classList.add('gone'); btn.title = 'Not on that Mac any more'
1795
+ })
1796
+ }
1797
+ }
1798
+ // An image opens full screen (ui/lightbox.js) with the other pictures of the
1799
+ // same message beside it; the thumbnail shows (blurred) while the full one
1800
+ // comes from the Mac. Text and other files keep the docs viewer.
1801
+ const attSel = (sid, id) => '.lv-att[data-sid="' + CSS.escape(sid) + '"][data-att="' + CSS.escape(id) + '"]'
1802
+ function openImageAttachment(btn) {
1803
+ const group = btn.closest('.lv-atts')
1804
+ const btns = group ? [...group.querySelectorAll('.lv-att[data-kind="image"]')] : [btn]
1805
+ const items = btns.map((b) => {
1806
+ const im = b.querySelector('img'), sid = b.dataset.sid, id = b.dataset.att
1807
+ const full = attachUi.urls.get(sid + '/' + id + '#full')
1808
+ return { name: b.dataset.name || 'image', sizeLabel: b.dataset.size || '', thumb: (im && im.getAttribute('src')) || null, url: full || null,
1809
+ w: im && im.naturalWidth, h: im && im.naturalHeight, load: () => attData(sid, id).then((r) => r.url) }
1810
+ })
1811
+ const sid = btn.dataset.sid, id = btn.dataset.att
1812
+ openLightbox({ items, index: Math.max(0, btns.indexOf(btn)), from: btn.querySelector('img') || btn, refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1813
+ }
1814
+ // A chip in the box before it's sent: the picture from the file you picked
1815
+ // (a data: URL; the CSP takes no blob:), the other pictures in the box beside it.
1816
+ // Any other file: its name and size in the docs viewer (nothing to download yet).
1817
+ function openChip(lid) {
1818
+ let a = null
1819
+ for (const list of attachUi.by.values()) { a = list.find((x) => x.lid === lid); if (a) break }
1820
+ if (!a) return
1821
+ const chip = document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1822
+ const isImg = (x) => x.kind === 'image' && (x.preview || /^image\/(png|jpeg|gif|webp|bmp|svg\+xml|avif)$/i.test((x.prepared || x.file || {}).type || ''))
1823
+ const refocus = () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1824
+ if (a.kind === 'pdf') {
1825
+ const f = a.prepared || a.file
1826
+ openPdfView({ name: a.name, sizeLabel: fmtSize(a.size), download: false, from: chip, refocus, load: async () => ({ bytes: new Uint8Array(await f.arrayBuffer()) }) })
1827
+ return
1828
+ }
1829
+ if (isImg(a)) {
1830
+ const list = attachList(a.sid).filter(isImg)
1831
+ const items = list.map((x) => ({ name: x.name, sizeLabel: fmtSize(x.size), thumb: x.preview || null, download: false,
1832
+ load: () => { if (!x.fullUrl) x.fullUrl = blobToDataUrl(x.prepared || x.file); return x.fullUrl } }))
1833
+ 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) + '"]') })
1834
+ return
1835
+ }
1836
+ const el = docViewerEl()
1837
+ el.querySelector('.dv-title').textContent = a.name
1838
+ el.querySelector('.dv-sub').textContent = ''
1839
+ el.querySelector('.dv-dl').hidden = true
1840
+ docUi.r = null; docUi.name = a.name
1841
+ 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>'
1842
+ el.hidden = false
1843
+ document.documentElement.classList.add('dv-open')
1844
+ el.querySelector('.dv-x') && el.querySelector('.dv-x').focus({ preventScroll: true })
1845
+ }
1846
+ // A PDF opens in its own full-screen preview (ui/pdfview.js): the pages drawn
1847
+ // from the file the Mac sends back (the same chunked read as a picture).
1848
+ function openPdfAttachment(btn) {
1849
+ const sid = btn.dataset.sid, id = btn.dataset.att
1850
+ openPdfView({ name: btn.dataset.name || 'file.pdf', sizeLabel: btn.dataset.size || '', from: btn,
1851
+ load: async () => { const r = await attData(sid, id); return { bytes: new Uint8Array(await r.blob.arrayBuffer()), url: r.url } },
1852
+ refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1853
+ }
1854
+ async function openAttachment(btn) {
1855
+ const sid = btn.dataset.sid, id = btn.dataset.att, name = btn.dataset.name || 'file', kind = btn.dataset.kind
1856
+ if (kind === 'image') return openImageAttachment(btn)
1857
+ if (kind === 'pdf') return openPdfAttachment(btn)
1858
+ const el = docViewerEl()
1859
+ el.querySelector('.dv-title').textContent = name
1860
+ el.querySelector('.dv-sub').textContent = ''
1861
+ el.querySelector('.dv-dl').hidden = true
1862
+ const body = el.querySelector('.dv-body')
1863
+ body.innerHTML = '<div class="empty">Opening…</div>'
1864
+ docUi.r = null; docUi.name = name
1865
+ el.hidden = false
1866
+ document.documentElement.classList.add('dv-open')
1867
+ try {
1868
+ const { url: u, blob } = await attData(sid, id)
1869
+ if (el.hidden) return
1870
+ const dl = '<a class="btn" href="' + esc(u) + '" download="' + esc(name) + '">' + icon('download') + 'Download</a>'
1871
+ if (kind === 'image') body.innerHTML = '<div class="att-full"><img src="' + esc(u) + '" alt="' + esc(name) + '"></div><div class="att-full-actions">' + dl + '</div>'
1872
+ 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>' }
1873
+ else body.innerHTML = '<div class="empty">' + esc(name) + '</div><div class="att-full-actions">' + dl + '</div>'
1874
+ } catch (e) { if (!el.hidden) body.innerHTML = '<div class="empty">Couldn’t open it: ' + esc(e.message) + '</div>' }
1875
+ }
1876
+ // 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.
1877
+ function fitComposer(ta) {
1878
+ if (!ta || !ta.isConnected) return
1879
+ const cs = getComputedStyle(ta)
1880
+ const lh = parseFloat(cs.lineHeight) || 22
1881
+ const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
1882
+ const keep = ta.scrollTop
1883
+ ta.style.height = 'auto'
1884
+ const r = composerHeight({ scrollHeight: ta.scrollHeight, lineHeight: lh, padY })
1885
+ ta.style.height = r.height + 'px'
1886
+ ta.style.overflowY = r.scroll ? 'auto' : 'hidden'
1887
+ if (r.scroll) ta.scrollTop = keep
1888
+ syncComposer(ta)
1889
+ }
1890
+ function syncComposer(ta) {
1891
+ const box = ta && ta.closest('.composer')
1892
+ const btn = box && box.querySelector('.composer-send')
1893
+ if (!btn || btn.classList.contains('sending')) return
1894
+ btn.disabled = ta.disabled || (isBlank(ta.value) && !attachList(box.dataset.sid).length)
1895
+ }
1896
+ document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
1897
+ // Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
1898
+ document.addEventListener('keydown', (ev) => {
1899
+ const ta = ev.target
1900
+ if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
1901
+ if (ev.metaKey || ev.ctrlKey) return // ⌘↵ / Ctrl+↵: the Needs-you handler presses the button
1902
+ if (composerKey(ev, { touch: isTouchUi(window.matchMedia) }) !== 'send') return
1903
+ ev.preventDefault()
1904
+ const cb = ta.closest('.composer')
1905
+ if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
1906
+ const box = ta.closest('.live-box')
1907
+ const btn = box && box.querySelector('[data-submit-for="live"]')
1908
+ if (!btn || btn.disabled) return
1909
+ const last = Number(box.dataset.kbdAt || 0)
1910
+ if (Date.now() - last < 1500) return
1911
+ box.dataset.kbdAt = String(Date.now())
1912
+ btn.click()
1913
+ })
1914
+ // A tap anywhere on the box (its padding) is a tap in the textarea.
1915
+ 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() })
1916
+ // Pressing ↑ keeps the keyboard up (the textarea keeps focus) on a phone.
1917
+ 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
1918
  // After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
1413
1919
  function liveAfterRender(sid) {
1920
+ for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
1414
1921
  const r = liveKeeper.after($app)
1922
+ hydrateAttThumbs(); barWidths($app)
1923
+ { const w = document.getElementById('what-it-did'); if (w && widUi.has(sid) && hydrateDiffs(w, (p) => widSlot(widOf(sid), p))) widStickyTop() }
1415
1924
  if (!r) return
1416
1925
  const ol = r.thread.querySelector('.live-log')
1417
1926
  const empty = r.thread.querySelector('.live-empty')
@@ -1479,34 +1988,141 @@ async function liveEarlierClick(btn) {
1479
1988
  await render()
1480
1989
  }
1481
1990
  // The elapsed time beside "Claude is working…", every second while something is in flight.
1991
+ // 0.9.8: when what the page shows is older than STALE_MS (the stream died, the
1992
+ // phone slept), a running bubble says "Reconnecting…" instead of a timer.
1482
1993
  let liveTimer = null
1483
1994
  function liveTick() {
1484
1995
  const els = [...document.querySelectorAll('.lv-status[data-since]')]
1996
+ const box = document.querySelector('#live[data-id]')
1997
+ const stale = !!box && isStale(liveUi.okAt[box.dataset.id], Date.now())
1485
1998
  for (const el of els) {
1486
1999
  const since = Number(el.dataset.since) || 0
1487
2000
  const end = Number(el.dataset.final) || 0
1488
2001
  const t = el.querySelector('.lvs-time')
1489
2002
  if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
2003
+ if (!end) {
2004
+ el.classList.toggle('lv-reconnecting', stale)
2005
+ let hint = el.querySelector('.lvs-hint')
2006
+ if (stale && !hint) { hint = document.createElement('span'); hint.className = 'lvs-hint'; hint.textContent = 'Reconnecting…'; (el.querySelector('.lvs-line') || el).append(hint) }
2007
+ if (hint) hint.hidden = !stale
2008
+ }
1490
2009
  }
1491
2010
  const running = els.some((el) => !el.dataset.final)
1492
2011
  if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
1493
2012
  }
2013
+
2014
+ // 0.9.8: Live keeps up after the phone sleeps (ui/live-wake.js). Every Live
2015
+ // fetch notes when it answered and whether a message is still going; while
2016
+ // one is, on a visible page, the thread is fetched again every POLL_MS
2017
+ // whatever the event stream does.
2018
+ const liveUi = { okAt: {}, sig: {}, pending: {}, polling: false }
2019
+ function noteLive(id, v) {
2020
+ if (!v || v.error) return
2021
+ liveUi.okAt[id] = Date.now()
2022
+ liveUi.pending[id] = anyPending(v.messages)
2023
+ }
2024
+ 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 }
2025
+ // Fetch the thread and diff it into the page, even while you type (busy()
2026
+ // holds full redraws back, never the thread). A turn that ended also redraws
2027
+ // the rest of the page (the pill stops pulsing) once you're not busy.
2028
+ async function refreshLive(reason) {
2029
+ const id = liveOnScreen()
2030
+ if (!id || liveUi.polling) return false
2031
+ liveUi.polling = true; liveUi.triedAt = Date.now()
2032
+ let v
2033
+ try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
2034
+ if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
2035
+ const s = state.sessions.find((x) => x.id === id)
2036
+ const sig = liveSignature(v.messages)
2037
+ const before = liveUi.sig[id]
2038
+ liveUi.sig[id] = sig
2039
+ if (s && before !== sig) {
2040
+ const box = document.querySelector('#live')
2041
+ const cur = box && box.querySelector('.live-thread')
2042
+ if (cur) {
2043
+ const tmp = document.createElement('div')
2044
+ tmp.innerHTML = liveBoxHtml(s, v)
2045
+ const fresh = tmp.querySelector('.live-thread')
2046
+ if (fresh) {
2047
+ const next = cur.nextSibling
2048
+ liveKeeper.before(box)
2049
+ box.insertBefore(fresh, next)
2050
+ const r = liveKeeper.after(box)
2051
+ hydrateAttThumbs()
2052
+ if (r) liveJumpButton(r.thread, liveKeeper.unseen)
2053
+ }
2054
+ }
2055
+ const c = sessionCache.get(id); if (c) c.live = v
2056
+ if (!liveUi.pending[id] && !busy()) soon() // settled: the pill, the tab, the card
2057
+ }
2058
+ liveTick()
2059
+ return true
2060
+ }
2061
+ // The safety net, and the stream's watchdog, on one slow timer.
2062
+ const streamDog = createWatchdog()
2063
+ const wakeGate = createWake()
2064
+ setInterval(() => {
2065
+ if (document.visibilityState === 'hidden') return
2066
+ const id = liveOnScreen()
2067
+ 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')
2068
+ if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
2069
+ }, 1000)
2070
+ // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
2071
+ // bfcache restore, the network returns): fetch what's on screen at once, and
2072
+ // open a new stream if the old one missed its pings.
2073
+ function refreshView(reason) {
2074
+ if (liveOnScreen()) refreshLive(reason)
2075
+ if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
2076
+ refreshHealth()
2077
+ }
2078
+ let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
2079
+ function onWake(ev) {
2080
+ if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
2081
+ const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
2082
+ if (!why) return
2083
+ liveTick() // a stale timer turns into "Reconnecting…" before anything else
2084
+ const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
2085
+ const away = awayAt ? Date.now() - awayAt : 0
2086
+ awayAt = 0
2087
+ // 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
2088
+ if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
2089
+ if (!wakeGate.due()) return
2090
+ if (why === 'pageshow-bfcache' || dead) connect()
2091
+ refreshView(why)
2092
+ }
2093
+ for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
2094
+ document.addEventListener('visibilitychange', onWake)
1494
2095
  async function liveSendClick(btn) {
1495
2096
  const box = btn.closest('.live-box')
1496
2097
  const ta = box && box.querySelector('.live-text')
2098
+ const sid = btn.dataset.id
1497
2099
  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…'
2100
+ if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
2101
+ if (btn.disabled || btn.classList.contains('sending')) return
2102
+ const hadFocus = !!(ta && document.activeElement === ta)
2103
+ btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
1501
2104
  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 = ''
2105
+ // 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
2106
+ await uploadsSettled(sid)
2107
+ const atts = attachList(sid)
2108
+ const failed = atts.filter((a) => a.status === 'failed').length
2109
+ 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 }
2110
+ const ids = atts.filter((a) => a.status === 'done' && a.id).map((a) => a.id)
2111
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live', { text, ...(ids.length ? { attachments: ids } : {}) })
2112
+ // what you typed while it went stays; what was sent goes
2113
+ if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
2114
+ if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
1505
2115
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
1506
2116
  const ol = box && box.querySelector('.live-log')
1507
2117
  if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
1508
2118
  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' } }
2119
+ } catch (e) { toast(e.message, 4200) } finally {
2120
+ await render()
2121
+ const nb = document.querySelector('.live-box [data-action="live-send"]')
2122
+ if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
2123
+ const nt = document.querySelector('.live-box .composer-input')
2124
+ if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
2125
+ }
1510
2126
  }
1511
2127
 
1512
2128
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
@@ -1669,6 +2285,11 @@ async function togglePr(url, { retry = false } = {}) {
1669
2285
  const was = prsUi.open.has(url)
1670
2286
  if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
1671
2287
  const repaint = () => {
2288
+ if (state.route && state.route.name === 's') { // 0.9.8: the session page's PRs open in place too
2289
+ const s = state.sessions.find((x) => x.id === state.route.arg), sec = document.getElementById('prs')
2290
+ if (s && sec) sec.innerHTML = sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details })
2291
+ return
2292
+ }
1672
2293
  const d = prsUi.data[prsUi.view]
1673
2294
  const r = prRows(d).find((x) => x.pr.url === url)
1674
2295
  const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
@@ -1682,7 +2303,7 @@ async function togglePr(url, { retry = false } = {}) {
1682
2303
  try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
1683
2304
  catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
1684
2305
  prsUi.details[url] = { ...res, at: Date.now() }
1685
- if (state.route && state.route.name === 'prs') repaint()
2306
+ if (state.route && (state.route.name === 'prs' || state.route.name === 's')) repaint()
1686
2307
  }
1687
2308
  function setPrsFilter(f) {
1688
2309
  const h = prsHash(prsUi.view, f)
@@ -1743,29 +2364,67 @@ async function viewTimeline() {
1743
2364
  $app.innerHTML = html
1744
2365
  }
1745
2366
 
1746
- function renderMd(md, { spaces = [] } = {}) {
2367
+ // 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
2368
+ // links there and Start here gets that session's Go / Resume.
2369
+ const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
2370
+ function briefAction(id, sessions, viewer) {
2371
+ const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
2372
+ const s = sessions && sessions.get(id)
2373
+ if (!s) return open
2374
+ let b = ''
2375
+ // 0.9.8: closed: Resume opens it in a new tab where it can, else (on the Mac) copies the resume command
2376
+ 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>')
2377
+ else { const g = goButton(s); b = g && !/disabled/.test(g) ? g : '' }
2378
+ return b || open
2379
+ }
2380
+
2381
+ function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
1747
2382
  const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
1748
2383
  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
2384
  const out = []
1750
- let inList = false, inSec = false
2385
+ let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
2386
+ const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
2387
+ const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
2388
+ const href = (id) => '#/s/' + encodeURIComponent(id)
1751
2389
  // v0.5.8: each "## <space>" opens a section with its own Copy for standup;
1752
2390
  // other "## " headings (Lists, Top of mind) are whole-folder sections.
2391
+ // 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
1753
2392
  const openSec = (h) => {
1754
2393
  if (inSec) out.push('</section>')
1755
2394
  inSec = true
1756
- if (spaces.includes(h)) {
2395
+ if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
2396
+ else if (spaces.includes(h)) {
1757
2397
  out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
1758
2398
  '<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
2399
  } else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
1760
2400
  }
1761
- for (const line of body.split('\n')) {
2401
+ for (const raw of body.split('\n')) {
2402
+ const mid = raw.match(MD_SID)
2403
+ const id = mid ? mid[1] : null
2404
+ const line = mid ? raw.slice(0, mid.index) : raw
2405
+ // 0.9.8: Start here: "1. <step> — **Title** (repo)"
2406
+ const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
2407
+ if (st) {
2408
+ openList('ol', 'start')
2409
+ const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
2410
+ 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>' +
2411
+ (id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
2412
+ continue
2413
+ }
1762
2414
  if (/^- /.test(line)) {
1763
- if (!inList) { out.push('<ul>'); inList = true }
2415
+ openList('ul')
1764
2416
  // Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
1765
2417
  const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
2418
+ const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
2419
+ // 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
2420
+ // session: the title links to it, the sentence under it, the tags quiet.
2421
+ const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
2422
+ // 0.9.8: an Unfinished one is closed, so it has its Resume beside it
2423
+ 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 }
2424
+ 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
2425
  // v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
1767
2426
  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
2427
+ out.push('<li' + attrs + '>' + li); continue
1769
2428
  }
1770
2429
  if (/^\s{2,}\S/.test(line) && inList) {
1771
2430
  // "Stopped at: … Next: …" is two lines, each with its label.
@@ -1774,13 +2433,17 @@ function renderMd(md, { spaces = [] } = {}) {
1774
2433
  else out.push('<span class="sub">' + inline(t) + '</span>')
1775
2434
  continue
1776
2435
  }
1777
- if (inList) { out.push('</ul>'); inList = false }
2436
+ closeList()
2437
+ if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
1778
2438
  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>') }
2439
+ 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>') } }
2440
+ // 0.9.8: Finished folds away (older digests' "Finished or closed" too)
2441
+ 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
2442
  else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
1781
- else if (line.trim()) out.push('<p>' + inline(line) + '</p>')
2443
+ // 0.9.8: the Yesterday / Today opener, above the first section
2444
+ else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
1782
2445
  }
1783
- if (inList) out.push('</ul>')
2446
+ closeList()
1784
2447
  if (inSec) out.push('</section>')
1785
2448
  return out.join('\n')
1786
2449
  }
@@ -1791,6 +2454,81 @@ function shortDay(iso) {
1791
2454
  return isNaN(d) ? iso : d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '')
1792
2455
  }
1793
2456
 
2457
+ // The past-digests calendar (0.9.8): one month at a time, Sunday first. A day
2458
+ // with a digest is a link (the shared one, else a Mac's own from before one
2459
+ // digest a day); the rest are plain numbers. Opens on the picked digest's
2460
+ // month; next stops at this month, prev at the first month with a digest.
2461
+ const monthOf = (iso) => String(iso).slice(0, 7)
2462
+ const localDay = (t = new Date()) => t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0')
2463
+ function shiftMonth(ym, by) {
2464
+ const [y, m] = ym.split('-').map(Number)
2465
+ const d = new Date(y, m - 1 + by, 1)
2466
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
2467
+ }
2468
+ function digestCalendar() {
2469
+ const { days = [], pick = null } = digestUi.cal || {}
2470
+ const today = localDay()
2471
+ const max = monthOf(today)
2472
+ const min = days.length ? days.map((x) => monthOf(x.day)).sort()[0] : max
2473
+ let ym = digestUi.month || max
2474
+ if (ym > max) ym = max
2475
+ if (ym < min) ym = min
2476
+ digestUi.month = ym
2477
+ const [y, m] = ym.split('-').map(Number)
2478
+ const first = new Date(y, m - 1, 1)
2479
+ const label = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
2480
+ const by = new Map(days.map((x) => [x.day, x.d]))
2481
+ const prev = shiftMonth(ym, -1), next = shiftMonth(ym, 1)
2482
+ const name = (x) => new Date(x + '-01T12:00:00').toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
2483
+ 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>'
2484
+ // phones fold it to one week (the picked day's) until you open the month
2485
+ const open = !!digestUi.calOpen
2486
+ let h = '<div class="cal-head">' + navBtn(prev, prev >= min, -1) + '<span class="cal-month" aria-live="polite">' + esc(label) + '</span>' +
2487
+ '<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>'
2488
+ 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('')
2489
+ const n = new Date(y, m, 0).getDate()
2490
+ const lead0 = first.getDay()
2491
+ const week = (dn) => Math.floor((lead0 + dn - 1) / 7)
2492
+ const inMonth = (x) => x && x.slice(0, 7) === ym
2493
+ const firstHas = days.map((x) => x.day).filter(inMonth).sort()[0]
2494
+ const wk = week(inMonth(pick) ? Number(pick.slice(8)) : inMonth(today) ? Number(today.slice(8)) : firstHas ? Number(firstHas.slice(8)) : 1)
2495
+ for (let i = 0; i < lead0; i++) h += '<span class="cal-day pad' + (wk === 0 ? ' wk' : '') + '" aria-hidden="true"></span>'
2496
+ let count = 0
2497
+ for (let dn = 1; dn <= n; dn++) {
2498
+ const day = ym + '-' + String(dn).padStart(2, '0')
2499
+ const d = by.get(day)
2500
+ const cls = ['cal-day', d ? 'has' : 'none', day === pick ? 'on' : '', day === today ? 'today' : '', week(dn) === wk ? 'wk' : ''].filter(Boolean).join(' ')
2501
+ const long = shortDay(day)
2502
+ if (d) {
2503
+ count++
2504
+ const whose = d.shared ? '' : ', ' + d.machine + '’s own digest'
2505
+ 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>'
2506
+ } else h += '<span class="' + cls + '" data-day="' + day + '"><span class="n">' + dn + '</span></span>'
2507
+ }
2508
+ h += '</div>'
2509
+ if (!count) h += '<div class="cal-none">' + (days.length ? 'No digests in ' + esc(first.toLocaleDateString('en-US', { month: 'long' })) + '.' : 'None yet') + '</div>'
2510
+ return h
2511
+ }
2512
+
2513
+ // Month or fold changed: patch the calendar in place, so the arrows (and the
2514
+ // focus on them, with its keyboard ring) stay put.
2515
+ function repaintCalendar() {
2516
+ const nav = document.querySelector('nav.digest-cal')
2517
+ if (!nav) return null
2518
+ const t = document.createElement('div')
2519
+ t.innerHTML = digestCalendar()
2520
+ const now = [...nav.querySelectorAll('.cal-head > *')], fresh = [...t.querySelectorAll('.cal-head > *')]
2521
+ now.forEach((el, i) => {
2522
+ const f = fresh[i]
2523
+ for (const a of ['data-to', 'aria-label', 'aria-expanded']) if (f.hasAttribute(a)) el.setAttribute(a, f.getAttribute(a))
2524
+ el.disabled = !!f.disabled
2525
+ if (!el.classList.contains('cal-nav')) el.innerHTML = f.innerHTML
2526
+ })
2527
+ for (const el of nav.querySelectorAll('.cal-grid, .cal-none')) el.remove()
2528
+ nav.append(...t.querySelectorAll('.cal-grid, .cal-none'))
2529
+ return nav
2530
+ }
2531
+
1794
2532
  // Digest (v0.5.8): one digest a day for the whole folder. The strip has one
1795
2533
  // pill per day: the shared digest, else (days from before 0.5.8) a Mac's own,
1796
2534
  // labelled with that Mac. Space tabs (All / Work / Personal …) sit above the
@@ -1815,16 +2553,16 @@ async function viewDigest(date, machine) {
1815
2553
  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
2554
  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
2555
  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>'
2556
+ // 0.9.8: a month calendar, not a long list: days with a digest are links
2557
+ if (digestUi.date !== pick || !digestUi.month) { digestUi.month = monthOf(pick || localDay()); digestUi.calOpen = false }
2558
+ digestUi.cal = { days: days.map((day) => ({ day, d: lead(day) })), pick }
2559
+ html += '<div class="digest-wrap"><nav class="digest-cal" aria-label="Past digests">' + digestCalendar() + '</nav>'
1824
2560
  let spaces = []
1825
2561
  if (pick) {
1826
2562
  try {
1827
- const d = await api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : ''))
2563
+ // 0.9.8: the sessions too, for Start here's Go / Resume
2564
+ 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)])
2565
+ if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
1828
2566
  const fm = d.meta || {}
1829
2567
  const madeAt = fm.generated_at
1830
2568
  spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
@@ -1834,10 +2572,14 @@ async function viewDigest(date, machine) {
1834
2572
  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
2573
  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
2574
  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>'
2575
+ '<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
2576
  } catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
1839
2577
  } 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
2578
  $app.innerHTML = html + '</div>'
2579
+ // 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
2580
+ const art = $app.querySelector('article.md')
2581
+ const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
2582
+ if (after) after.after(...['.digest-hint', '#digestSpaces', '#digestRepos'].map((q) => art.querySelector(q)).filter(Boolean))
1841
2583
  const params = (state.route && state.route.params) || new URLSearchParams()
1842
2584
  state.digestRepos = readFilter(params, window.localStorage).repos
1843
2585
  state.digestSpaces = spaces
@@ -1859,7 +2601,7 @@ function paintDigestFilter() {
1859
2601
  const spaces = state.digestSpaces || []
1860
2602
  const sp = state.digestSpace
1861
2603
  const secs = [...document.querySelectorAll('.md section')]
1862
- for (const sec of secs) sec.hidden = !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
2604
+ for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
1863
2605
  const tabsBox = document.getElementById('digestSpaces')
1864
2606
  if (tabsBox) {
1865
2607
  const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
@@ -1877,8 +2619,19 @@ function paintDigestFilter() {
1877
2619
  const any = [...ul.children].some((li) => !li.hidden)
1878
2620
  ul.hidden = !any
1879
2621
  if (h && (h.tagName === 'H2' || h.tagName === 'H3')) h.hidden = !any
2622
+ const fin = ul.closest('details.fin')
2623
+ if (fin) fin.hidden = !any
1880
2624
  }
1881
2625
  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
2626
+ // 0.9.8: a Start here step follows its session's line: hidden with it (space or repo)
2627
+ const startSec = document.querySelector('.md section.start-sec')
2628
+ if (startSec) {
2629
+ for (const li of startSec.querySelectorAll('li[data-sid]')) {
2630
+ const twin = [...document.querySelectorAll('.md li[data-repo][data-sid]')].find((x) => x.dataset.sid === li.dataset.sid)
2631
+ li.hidden = !!twin && (twin.hidden || !!(twin.closest('section') && twin.closest('section').hidden))
2632
+ }
2633
+ startSec.hidden = ![...startSec.querySelectorAll('ol.start > li')].some((li) => !li.hidden)
2634
+ }
1882
2635
  if (counts.size < 2) { box.innerHTML = ''; return }
1883
2636
  const on = (r) => sel.some((tok) => repoMatches({ repoKey: r, repoLabel: r }, tok))
1884
2637
  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 +2936,13 @@ async function tryLength() {
2183
2936
  const s = list[0]
2184
2937
  if (!s) { toast('No session yet to try it on'); return }
2185
2938
  box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
2939
+ // the page can be redrawn while the summarizer writes (Save re-renders Settings): put the result in the box on screen then
2940
+ const now = () => document.getElementById('f-length-example') || box
2186
2941
  try {
2187
2942
  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>' +
2943
+ 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
2944
  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) }
2945
+ } catch (e) { now().innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
2191
2946
  }
2192
2947
 
2193
2948
  // Am I the iPhone home-screen app (or another installed app window)?
@@ -2651,7 +3406,7 @@ async function viewSettings() {
2651
3406
  updates: '<div class="section">About</div>' +
2652
3407
  '<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
3408
  '<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>' +
3409
+ '<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a> · <a href="#/tour">Take the tour</a></div>' +
2655
3410
  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
3411
  '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
2657
3412
  '<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
@@ -3350,7 +4105,7 @@ function paintListsCount(lists) {
3350
4105
  // The session page's Lists section: saved lists, the hint, and "Find lists in this session".
3351
4106
  function sessionListsSection(s) {
3352
4107
  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>'
4108
+ 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
4109
  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
4110
  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
4111
  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,6 +4150,7 @@ async function render() {
3395
4150
  else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
3396
4151
  else if (r.name === 'search') await viewSearch(r.q || '')
3397
4152
  else if (r.name === 'setup') await viewSetup()
4153
+ else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
3398
4154
  else await viewBoard()
3399
4155
  tabsAfterRender()
3400
4156
  } catch (e) {
@@ -3407,12 +4163,20 @@ async function render() {
3407
4163
  // it keeps its own step and fetches its own state.
3408
4164
  async function viewSetup() {
3409
4165
  if ($app.querySelector('.wz')) return
3410
- await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
4166
+ await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/'; autoTour() } })
3411
4167
  }
3412
4168
  // Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
4169
+ // Otherwise the product tour gets its turn.
3413
4170
  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 {}
4171
+ if (!location.hash.replace(/^#\/?/, '')) {
4172
+ try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) return location.replace('#/setup') } catch {}
4173
+ }
4174
+ autoTour()
4175
+ }
4176
+ // The product tour (0.9.8, ui/tour.js): by itself once per Mac, after setup, on
4177
+ // the board; #/tour (Settings → Updates & about, the palette) replays it.
4178
+ async function autoTour() {
4179
+ try { const t = await api('GET', '/v1/tour'); if (shouldAutoTour(t, parseRoute().name)) startTour({ api, auto: true }) } catch {}
3416
4180
  }
3417
4181
 
3418
4182
  // A remote browser (Tailscale) that hasn't signed in yet: the iPhone
@@ -3471,7 +4235,9 @@ async function refreshHealth() {
3471
4235
  if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
3472
4236
  paintMoreDots()
3473
4237
  paintSettingsDots()
4238
+ if (updateBar) updateBar.check(h)
3474
4239
  } catch (e) {
4240
+ if (updateBar) updateBar.check(null) // a failed check changes nothing
3475
4241
  el.className = 'health down'
3476
4242
  el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
3477
4243
  el.title = el.querySelector('.label').textContent
@@ -3720,6 +4486,15 @@ async function refreshTabSessions() {
3720
4486
  paintTabs()
3721
4487
  } catch {}
3722
4488
  }
4489
+ // What had focus in the strip, to find it again after a repaint: the same control,
4490
+ // else (Keep open is gone once kept) the same tab's ×, else the tab itself.
4491
+ function tabFocusKey(el) {
4492
+ const d = el.dataset, q = (a, v) => '[' + a + '="' + CSS.escape(v) + '"]'
4493
+ if (d.tabClose) return [q('data-tab-close', d.tabClose), q('data-tab', d.tabClose)]
4494
+ if (d.tabKeep) return [q('data-tab-keep', d.tabKeep), q('data-tab-close', d.tabKeep), q('data-tab', d.tabKeep)]
4495
+ if (d.tab) return [q('data-tab', d.tab)]
4496
+ return el.id ? ['#' + CSS.escape(el.id)] : null
4497
+ }
3723
4498
  function paintTabs() {
3724
4499
  const views = tabViews()
3725
4500
  const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
@@ -3727,10 +4502,10 @@ function paintTabs() {
3727
4502
  const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
3728
4503
  if (sh !== tabsUi.html.strip) {
3729
4504
  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
4505
+ const had = strip.contains(document.activeElement) ? tabFocusKey(document.activeElement) : null
3731
4506
  strip.innerHTML = sh; tabsUi.html.strip = sh
3732
4507
  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 }) }
4508
+ if (had) { const el = had.map((q) => strip.querySelector(q)).find(Boolean); if (el) el.focus({ preventScroll: true }) }
3734
4509
  }
3735
4510
  strip.hidden = false
3736
4511
  const rh = openRowHtml(views, esc)
@@ -3798,6 +4573,32 @@ document.addEventListener('click', (e) => {
3798
4573
  if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
3799
4574
  })
3800
4575
  document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
4576
+ // The strip's buttons name themselves in a styled tip under them (hover after a
4577
+ // beat, keyboard focus at once), not a native title. The strip scrolls sideways,
4578
+ // which clips anything hanging below it, so the tip is one fixed element on the page.
4579
+ {
4580
+ const tip = document.createElement('span'); tip.className = 'strip-tip'; tip.setAttribute('aria-hidden', 'true'); document.body.appendChild(tip)
4581
+ let cur = null, timer = 0
4582
+ const hide = () => { clearTimeout(timer); cur = null; tip.classList.remove('on') }
4583
+ const show = (el, delay) => {
4584
+ clearTimeout(timer); cur = el
4585
+ timer = setTimeout(() => {
4586
+ if (cur !== el || !el.isConnected) return hide()
4587
+ tip.textContent = el.dataset.tip
4588
+ const r = el.getBoundingClientRect(), w = tip.offsetWidth
4589
+ tip.style.left = Math.round(Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2))) + 'px'
4590
+ tip.style.top = Math.round(r.bottom + 4) + 'px'
4591
+ tip.classList.add('on')
4592
+ }, delay)
4593
+ }
4594
+ const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip]')
4595
+ document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
4596
+ document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
4597
+ document.addEventListener('focusin', (e) => { const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
4598
+ document.addEventListener('focusout', (e) => { if (tipped(e) === cur) hide() })
4599
+ for (const ev of ['pointerdown', 'scroll', 'hashchange', 'resize']) window.addEventListener(ev, hide, true)
4600
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) hide() })
4601
+ }
3801
4602
  // Working in a session keeps its tab: a Live message, typing there, answering Claude there
3802
4603
  const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
3803
4604
  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 +4620,7 @@ document.addEventListener('click', (e) => { if (e.target.closest && $app.contain
3819
4620
  sw = null
3820
4621
  if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
3821
4622
  const t = e.target
3822
- if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .tabs, .moremenu') || scrollsSideways(t)) return
4623
+ if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .moremenu') || scrollsSideways(t)) return
3823
4624
  sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
3824
4625
  }, { passive: true })
3825
4626
  document.addEventListener('touchend', (e) => {
@@ -3884,7 +4685,7 @@ function keepPlace() {
3884
4685
  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
4686
  document.body.appendChild(ptr)
3886
4687
  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')
4688
+ 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
4689
  let pull = null, refreshing = false
3889
4690
  const paint = (step) => {
3890
4691
  ptr.hidden = !step || (!step.pulling && !refreshing)
@@ -3934,6 +4735,43 @@ function keepPlace() {
3934
4735
  document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
3935
4736
  }
3936
4737
 
4738
+ // Hide the nav on scroll (phones, 0.9.8): under 700px the top row and the bottom
4739
+ // bar slide away while you scroll down and come back when you scroll up, reach
4740
+ // the top or the very bottom (ui/navhide.js). Only your own scrolling hides
4741
+ // them: a route change, the ‹ › pager or a swipe shows them and holds them until
4742
+ // you next touch, wheel or key; typing, a menu or a sheet keeps them shown. The
4743
+ // page never reflows: the top row stays in the flow and the bar is an overlay
4744
+ // over padding that stays.
4745
+ {
4746
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
4747
+ const top = document.querySelector('.top')
4748
+ let st = { hidden: false, anchor: 0 }, raf = 0, held = true
4749
+ const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
4750
+ 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])')
4751
+ const apply = (hidden) => {
4752
+ if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
4753
+ document.body.classList.toggle('nav-hidden', hidden)
4754
+ }
4755
+ const read = () => {
4756
+ raf = 0
4757
+ const se = document.scrollingElement || document.documentElement
4758
+ const y = window.scrollY || se.scrollTop || 0
4759
+ const next = navHideStep(st, { y, max: se.scrollHeight - window.innerHeight, blocked: held || !narrowQ.matches || typing() || overlay() })
4760
+ if (next.hidden !== st.hidden) apply(next.hidden)
4761
+ st = next
4762
+ }
4763
+ const soon = () => { if (!raf) raf = requestAnimationFrame(read) }
4764
+ // no layout read here: this runs on every route change, ahead of the page's own render
4765
+ const showNav = (hold = true) => { if (hold) held = true; const was = st.hidden; st = { hidden: false, anchor: NaN }; if (was) apply(false) }
4766
+ window.addEventListener('scroll', soon, { passive: true })
4767
+ for (const ev of ['touchstart', 'wheel', 'keydown']) window.addEventListener(ev, () => { held = false }, { passive: true, capture: true })
4768
+ window.addEventListener('hashchange', () => showNav(true))
4769
+ document.addEventListener('focusin', (e) => { if (typing() || (top && top.contains(e.target))) showNav(false) })
4770
+ document.addEventListener('click', soon, { passive: true, capture: true }) // a menu or sheet just opened: back they come
4771
+ narrowQ.addEventListener('change', () => showNav(false))
4772
+ window.leftoffNav = { show: () => showNav(true), hidden: () => document.body.classList.contains('nav-hidden') }
4773
+ }
4774
+
3937
4775
  // Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
3938
4776
  const statusOpen = () => { const p = document.getElementById('statusPop'); return p && !p.hidden }
3939
4777
  function syncLine() {
@@ -4067,6 +4905,7 @@ function paletteActions() {
4067
4905
  }
4068
4906
  if (projectsUi.mayEdit) out.push({ action: 'project-new', title: 'New project…', sub: 'a named group of repos', icon: 'plus' })
4069
4907
  out.push({ action: 'gen-digest-go', title: 'Today’s digest', sub: 'open it', icon: 'newspaper', href: '#/digest' })
4908
+ out.push({ action: 'tour', title: 'Take the tour', sub: 'a quick look around the board', icon: 'sparkles', href: '#/tour' })
4070
4909
  return out
4071
4910
  }
4072
4911
  function searchGroups(q, r) {
@@ -4216,11 +5055,27 @@ document.addEventListener('keydown', (e) => {
4216
5055
 
4217
5056
  // A card opens its session's page from anywhere on it, except its buttons,
4218
5057
  // links, fields and selected text (0.4.3: the timeline was too hidden).
5058
+ // 0.9.8: the tap shows at once (a pressed card with a spinner); the page's
5059
+ // shell follows in the same frame (viewSession).
5060
+ function cardOpening(c) {
5061
+ for (const x of document.querySelectorAll('.card.opening')) if (x !== c) x.classList.remove('opening')
5062
+ c.classList.add('opening')
5063
+ setTimeout(() => { if (c.isConnected) c.classList.remove('opening') }, 4000)
5064
+ }
5065
+ document.addEventListener('pointerdown', (ev) => {
5066
+ const c = ev.target.closest && ev.target.closest('.card[data-href]')
5067
+ if (!c || ev.button > 0 || ev.target.closest('button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
5068
+ const a = ev.target.closest('a')
5069
+ if (a && !/^#\/s\//.test(a.getAttribute('href') || '')) return // a repo chip, a PR link: not opening the session
5070
+ cardOpening(c)
5071
+ }, { passive: true })
5072
+ document.addEventListener('pointercancel', () => { for (const x of document.querySelectorAll('.card.opening')) x.classList.remove('opening') }, true)
4219
5073
  document.addEventListener('click', (ev) => {
4220
5074
  const c = ev.target.closest('.card[data-href]')
4221
5075
  if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
4222
5076
  if (String(window.getSelection && window.getSelection()).trim()) return
4223
5077
  if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
5078
+ cardOpening(c)
4224
5079
  location.hash = c.dataset.href
4225
5080
  })
4226
5081
 
@@ -4267,7 +5122,44 @@ document.addEventListener('click', async (ev) => {
4267
5122
  const id = btn.dataset.id || (card && card.dataset.id)
4268
5123
  const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
4269
5124
  try {
4270
- if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
5125
+ if (action === 'copy-cmd') {
5126
+ const ok = await copy(btn.dataset.cmd)
5127
+ if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
5128
+ clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
5129
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy Live resume'); btn.dataset.tip = 'Copy' }, 1500)
5130
+ }
5131
+ return
5132
+ }
5133
+ // 0.9.8 What it did
5134
+ 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 }
5135
+ if (action === 'wid-refresh') return loadWid(id)
5136
+ if (action === 'wid-tool') {
5137
+ const ui = widOf(id), t = btn.dataset.tool
5138
+ ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
5139
+ // a command's output ends with what matters (a failure, the summary): open it at the end
5140
+ const v = (ui.tools || []).find((x) => x.id === t)
5141
+ const pre = ui.toolOpen[t] && v && (v.tool === 'Bash' || v.error) && document.querySelector('#wid-t-' + CSS.escape(t) + ' .wid-pre')
5142
+ if (pre) pre.scrollTop = pre.scrollHeight
5143
+ return
5144
+ }
5145
+ if (action === 'wid-tool-more') {
5146
+ const ui = widOf(id), t = btn.dataset.tool
5147
+ if (ui.toolLoading[t] === true) return
5148
+ ui.toolLoading[t] = true; paintWid(id)
5149
+ 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' }
5150
+ return paintWid(id)
5151
+ }
5152
+ 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 }
5153
+ if (action === 'wid-copy') {
5154
+ const pageId = parseRoute().arg // the page's session (the copy button carries no id)
5155
+ const ok = await copy(widCopyText(widOf(pageId), btn.dataset.kind, btn.dataset.key))
5156
+ if (ok) {
5157
+ const label = btn.getAttribute('aria-label')
5158
+ clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.dataset.tip = 'Copied'
5159
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', label); btn.dataset.tip = 'Copy' }, 1500)
5160
+ }
5161
+ return
5162
+ }
4271
5163
  // PRs (0.6.9)
4272
5164
  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
5165
  // 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
@@ -4370,6 +5262,7 @@ document.addEventListener('click', async (ev) => {
4370
5262
  if (action === 'list-showall') { listUi.showAll = !listUi.showAll; return render() }
4371
5263
  if (action === 'list-help') { listUi.help = !listUi.help; return render() }
4372
5264
  if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
5265
+ if (action === 'list-help-inline') { listUi.sHelp = !listUi.sHelp; render(); return }
4373
5266
  if (action === 'remote-link' || action === 'remote-code') {
4374
5267
  const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
4375
5268
  if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
@@ -4534,7 +5427,8 @@ document.addEventListener('click', async (ev) => {
4534
5427
  if (action === 'tl-session') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: btn.dataset.session || null }); return }
4535
5428
  if (action === 'tl-trigger') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, trigger: btn.dataset.trigger || null }); return }
4536
5429
  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 }) }
5430
+ if (action === 'clear-filters') { state.moreOpen = false; state.boardSpace = null; saveSpace(null); setMachineFilter(''); return setBoardFilter({ repos: [], status: null, project: null }) }
5431
+ if (action === 'machine-all') { setMachineFilter(''); return render() }
4538
5432
  // Projects (0.9.0): the filter chips, the chip on a card, and the menus
4539
5433
  if (action === 'board-project' || action === 'project-filter') {
4540
5434
  ev.preventDefault(); ev.stopPropagation()
@@ -4557,6 +5451,19 @@ document.addEventListener('click', async (ev) => {
4557
5451
  saveSpace(state.digestSpace)
4558
5452
  return paintDigestFilter()
4559
5453
  }
5454
+ if (action === 'digest-cal-fold') {
5455
+ digestUi.calOpen = !digestUi.calOpen
5456
+ repaintCalendar()
5457
+ return
5458
+ }
5459
+ if (action === 'digest-month') {
5460
+ const back = btn.dataset.to < (digestUi.month || '')
5461
+ digestUi.month = btn.dataset.to
5462
+ digestUi.calOpen = true // another month: show all of it
5463
+ const nav = repaintCalendar()
5464
+ 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
5465
+ return
5466
+ }
4560
5467
  if (action === 'standup-copy') return standupCopy(btn)
4561
5468
  if (action === 'standup-close') { const b = btn.closest('.standup-out'); if (b) b.remove(); digestUi.open = null; return }
4562
5469
  if (action === 'space-quick' || action === 'space-repo-add' || action === 'space-unset') return spacesAction(action, btn)
@@ -4582,6 +5489,11 @@ document.addEventListener('click', async (ev) => {
4582
5489
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
4583
5490
  if (action === 'reply-send') return replyClick(btn)
4584
5491
  if (action === 'live-send') return liveSendClick(btn)
5492
+ if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
5493
+ if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
5494
+ if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
5495
+ if (action === 'att-open') return openAttachment(btn)
5496
+ if (action === 'att-chip-open') return openChip(btn.dataset.lid)
4585
5497
  if (action === 'live-jump') return liveJumpClick(btn)
4586
5498
  if (action === 'live-earlier') return liveEarlierClick(btn)
4587
5499
  if (action === 'quick-reply') return quickReplyClick(btn)
@@ -4595,6 +5507,7 @@ document.addEventListener('click', async (ev) => {
4595
5507
  return render()
4596
5508
  }
4597
5509
  if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
5510
+ 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
5511
  if (action === 'doc-close') return closeDoc()
4599
5512
  if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
4600
5513
  if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
@@ -4879,6 +5792,46 @@ async function openDoc(sessionId, { path, plan } = {}) {
4879
5792
  el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(e.message) + '</div>'
4880
5793
  }
4881
5794
  }
5795
+ // 0.9.8 What it did: one tool's output (up to 64 KB, redacted on its Mac) in
5796
+ // the file viewer, from a Live tool chip. Copy and Download as text.
5797
+ async function openToolOutput(sessionId, toolId, label) {
5798
+ if (!sessionId || !toolId) return
5799
+ const el = docViewerEl()
5800
+ const name = label || 'Tool output'
5801
+ el.querySelector('.dv-title').textContent = name
5802
+ el.querySelector('.dv-sub').textContent = ''
5803
+ el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
5804
+ const dl = el.querySelector('.dv-dl'); dl.hidden = true
5805
+ docUi.r = null; docUi.name = name
5806
+ el.hidden = false
5807
+ document.documentElement.classList.add('dv-open')
5808
+ try {
5809
+ const r = await api('GET', '/v1/sessions/' + encodeURIComponent(sessionId) + '/tools?tool=' + encodeURIComponent(toolId))
5810
+ if (el.hidden) return
5811
+ const t = r.tool || {}
5812
+ el.querySelector('.dv-title').textContent = t.label || name
5813
+ 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(' · ')
5814
+ const note = t.running ? 'Still running; no output yet.' : t.hidden ? widHidden(t.hidden) : !t.output && !t.patch ? 'No output.' : ''
5815
+ const body = el.querySelector('.dv-body')
5816
+ // an edit shows what it changed, drawn like a diff (DOM, textContent only)
5817
+ 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) }
5818
+ else body.innerHTML = note ? '<div class="empty">' + esc(note) + '</div>' : '<pre class="doc-pre">' + esc(t.output) + '</pre>'
5819
+ const fname = t.patch ? 'changes.diff' : 'output.txt'
5820
+ el.querySelector('.dv-ptitle').textContent = fname
5821
+ if (!note) {
5822
+ const doc = { type: 'text', mime: 'text/plain', name: fname, text: t.patch || t.output }
5823
+ docUi.r = doc; docUi.name = fname
5824
+ const items = docMenuItems(doc)
5825
+ dl.hidden = false
5826
+ dl.setAttribute('aria-haspopup', items.length > 1 ? 'menu' : 'false')
5827
+ dl.querySelector('.chev').hidden = items.length <= 1
5828
+ dl.removeAttribute('title')
5829
+ dl.setAttribute('aria-label', 'Copy or download')
5830
+ }
5831
+ } catch (e) {
5832
+ el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(widError(e)) + '</div>'
5833
+ }
5834
+ }
4882
5835
  function openDocMenu(btn) {
4883
5836
  const r = docUi.r
4884
5837
  const items = docMenuItems(r)
@@ -5069,16 +6022,21 @@ let stream = null
5069
6022
  function connect() {
5070
6023
  if (stream) { try { stream.close() } catch {} }
5071
6024
  const es = stream = new EventSource('/v1/stream')
6025
+ const again = connect.opened // a reconnect: events may have been missed while it was down
6026
+ streamDog.opened()
6027
+ // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
6028
+ es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
5072
6029
  es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
5073
6030
  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)
6031
+ 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
6032
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
5076
6033
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
5077
6034
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
5078
6035
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
5079
6036
  // port; if the browser gave up on the stream, open a new one.
5080
6037
  es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
5081
- es.onopen = () => refreshHealth()
6038
+ let opens = 0
6039
+ es.onopen = () => { if (stream !== es) return; streamDog.seen(); refreshHealth(); if (again || opens++) refreshView('reconnect'); connect.opened = true }
5082
6040
  }
5083
6041
 
5084
6042
  document.addEventListener('change', async (ev) => {
@@ -5139,7 +6097,7 @@ document.addEventListener('change', async (ev) => {
5139
6097
  return cuLoadOptions().then(render)
5140
6098
  }
5141
6099
  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() }
6100
+ if (ev.target.id === 'machineFilter') { setMachineFilter(ev.target.value); render() }
5143
6101
  })
5144
6102
 
5145
6103
  // Moving between Settings sections only swaps the section shown (v0.5.9): a
@@ -5181,7 +6139,46 @@ document.addEventListener('pointerdown', (ev) => {
5181
6139
  if (m && !m.contains(ev.target)) m.open = false
5182
6140
  })
5183
6141
  api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
6142
+ // 0.9.8: "Leftoff updated · Refresh" when the Mac serving this board has newer
6143
+ // files than the ones this page runs. It never reloads by itself; Refresh
6144
+ // carries your drafts over and says first what wouldn't come along.
6145
+ updateBar = mountUpdateBar({
6146
+ icon,
6147
+ health: () => refreshHealth(),
6148
+ risks: () => {
6149
+ let attachments = 0, uploading = 0
6150
+ for (const list of attachUi.by.values()) for (const a of list) { attachments++; if (a.status === 'prep' || a.status === 'up') uploading++ }
6151
+ const f = document.activeElement
6152
+ 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())
6153
+ let unsavedSettings = false
6154
+ try { unsavedSettings = settingsChanged().length > 0 } catch {}
6155
+ return { attachments, uploading, unsavedSettings, typing }
6156
+ },
6157
+ carry: () => {
6158
+ const drafts = { ...state.drafts }
6159
+ for (const t of document.querySelectorAll('textarea[data-draft]')) if (t.value) drafts[t.dataset.draft] = t.value
6160
+ const f = document.activeElement
6161
+ const focus = f && f.dataset && f.dataset.draft ? { key: f.dataset.draft, a: f.selectionStart || 0, b: f.selectionEnd || 0 } : lastDraftFocus
6162
+ return { drafts, focus }
6163
+ },
6164
+ })
6165
+ // The box you were typing in, if you tabbed from it to the bar.
6166
+ let lastDraftFocus = null
6167
+ document.addEventListener('focusin', (ev) => { if (!(ev.target.closest && ev.target.closest('#updateBar'))) lastDraftFocus = null })
6168
+ 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 } })
6169
+ // Back from Refresh: the box you were typing in has its text, and (with a
6170
+ // keyboard and mouse) the cursor where it was.
6171
+ function restoreCarriedFocus(tries = 50) {
6172
+ const f = carried && carried.focus
6173
+ if (!f || isTouchUi(window.matchMedia)) return
6174
+ if (document.activeElement && document.activeElement !== document.body) return // you've moved on
6175
+ const t = [...document.querySelectorAll('textarea[data-draft]')].find((x) => x.dataset.draft === f.key)
6176
+ if (!t || t.disabled) { if (tries > 0) setTimeout(() => restoreCarriedFocus(tries - 1), 100); return } // the session page draws after its data
6177
+ t.focus({ preventScroll: true })
6178
+ try { const n = t.value.length; t.setSelectionRange(Math.min(f.a, n), Math.min(f.b, n)) } catch {}
6179
+ }
5184
6180
  refreshHealth().then(render).then(firstRunSetup)
6181
+ restoreCarriedFocus()
5185
6182
  connect()
5186
6183
 
5187
6184
  // The home-screen app's service worker (v0.4.6): it only serves an offline page