@leftoff/cli 0.9.8-beta.2 → 0.9.8-beta.21

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,13 +5,20 @@ 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'
@@ -19,11 +26,20 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
19
26
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
20
27
  import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
21
28
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
22
- import { sessionTitle, cardPills, foldsAsEmpty, entryHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom } from './live-view.js' // 0.9.8 Live link polish
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
32
+ import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
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
24
34
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
25
35
  const $app = document.getElementById('app')
36
+ // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
37
+ if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
26
38
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
39
+ // 0.9.8: drafts the update bar's Refresh carried over (ui/update-bar.js)
40
+ const carried = takeCarry()
41
+ if (carried) Object.assign(state.drafts, carried.drafts)
42
+ let updateBar = null
27
43
  const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
28
44
  const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
29
45
 
@@ -113,12 +129,14 @@ async function copy(text) {
113
129
  // 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
114
130
  function copiedMark(btn) {
115
131
  clearTimeout(btn._copied)
132
+ const tip = btn._tip || (btn._tip = btn.title || 'Copy resume command')
116
133
  btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.title = 'Copied'
117
- btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = 'Copy resume command' }, 1500)
134
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
118
135
  }
119
136
  const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
120
137
  // 0.9.8 Live link: the session's channel is listening (send it prompts from here)
121
- const liveBadge = (s) => (cardPills(s).live ? ' <span class="pill live-pill" title="Live link: send this session messages from the board or your phone">Live</span>' : '')
138
+ // It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
139
+ const liveBadge = (s) => liveBadgeHtml(s)
122
140
  // The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
123
141
  const statusPill = (s) => { const st = cardPills(s).status; return st ? pill(st) : '' }
124
142
 
@@ -184,11 +202,11 @@ function wtChips(s) {
184
202
  return h
185
203
  }
186
204
 
187
- function where(s) {
205
+ function where(s, { repo = true } = {}) {
188
206
  if (s.agent) return (s.project ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(s.repoLabel) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc(s.repoLabel + ' · show only this project') + '">' + esc(s.repoLabel) + '</button>' : '') + agentChip(s.agent)
189
207
  const branch = s.repo && s.repo.branch
190
208
  const label = s.repoLabel || s.repoName || 'no repo'
191
- return projChip(s) + '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
209
+ return projChip(s) + (repo ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' : '') + wtChips(s) +
192
210
  (multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<button type="button" class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '" data-action="machine-emoji" data-machine="' + esc(s.machine) + '"><span class="memo">' + esc(mEmoji(s.machine)) + '</span> ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</button>' })() : '') + modelChip(s)
193
211
  }
194
212
 
@@ -387,9 +405,13 @@ function liveNote(s) {
387
405
  if (s.live === 'open' && s.liveVia === 'heartbeat' && !s.isLocal) return 'open (heartbeat)'
388
406
  return s.live === 'open' ? 'open' : ''
389
407
  }
390
- function goButton(s) {
408
+ // 0.9.8: { page: true } is the session page's header, where a Go that can't run
409
+ // is left out (its tooltip was the only place the reason showed, and the grey
410
+ // box read as broken); a card keeps the dimmed Go and its short why.
411
+ function goButton(s, { page = false } = {}) {
391
412
  if (s.live === 'closed') return ''
392
413
  const j = s.jump || {}
414
+ if (page && (state.viewerLocal === false || !j.supported)) return ''
393
415
  // Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
394
416
  if (!s.isLocal) {
395
417
  if (!j.peer) return ''
@@ -439,8 +461,12 @@ function agentCard(s) {
439
461
  // closed), a small copy icon for the resume command, and "⋯" for the rest. The
440
462
  // ⋯ menu's items ride in the card as an inert <template> (real buttons with the
441
463
  // same data-action as before), so the one click handler below runs them unchanged.
464
+ // What Resume runs: codex resume, claude --resume, or for a Live session (0.9.8)
465
+ // leftoff live -- --resume, so it comes back Live.
466
+ function resumeVerb(s) { return s.codingAgent === 'codex' ? 'codex resume' : s.resumeLive ? 'leftoff live -- --resume' : 'claude --resume' }
467
+ function resumeTip(s, lead) { return lead + ': ' + resumeVerb(s) + (s.resumeLive ? ' (it comes back Live)' : '') }
442
468
  function copyResumeIcon(s) {
443
- return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
469
+ return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + '</button>'
444
470
  }
445
471
  // items: [{ action, label, icon, title, attrs }]
446
472
  // 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
@@ -557,7 +583,7 @@ function checkpointSplit(s) {
557
583
  '<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
558
584
  }
559
585
  return '<span class="split" role="group" aria-label="Checkpoint now">' +
560
- '<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '">Checkpoint now</button>' +
586
+ '<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '" aria-label="Checkpoint now">' + icon('bookmark-plus') + 'Checkpoint</button>' +
561
587
  '<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
562
588
  }
563
589
  function openLengthMenu(btn, id) {
@@ -581,10 +607,10 @@ function card(s) {
581
607
  const nb = s.needsYou ? cardBadge(s.needsYou) : ''
582
608
  return '<article class="card ' + esc(s.status) + (s.live === 'closed' ? ' is-closed' : '') + (s.needsYou && s.needsYou.blocking ? ' needs-you' : '') + '" data-id="' + esc(s.id) + '" data-href="#/s/' + esc(encodeURIComponent(s.id)) + '">' +
583
609
  '<div class="row1"><div class="title">' + liveDot(s) +
584
- (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + esc(sessionTitle(s)) + '</a>' +
610
+ (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
585
611
  '<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
586
612
  '</div>' + statusPill(s) + liveBadge(s) + '</div>' +
587
- '<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
613
+ '<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(s) }) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
588
614
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
589
615
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
590
616
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
@@ -808,11 +834,13 @@ async function viewBoard() {
808
834
  html += projectRow(spaced, pj, 'board-project')
809
835
  if (filter === 'closed') {
810
836
  html += await closedSection(f, sp)
837
+ if (parseRoute().name === 's') return // 0.9.8: you opened a session while the board loaded
811
838
  $app.innerHTML = banners() + html
812
839
  return closedWire()
813
840
  }
814
841
  html += repoRow(inSpace, f)
815
842
  html += boardSearch(bq, closedHits, list.length)
843
+ if ((state.boardQ || '').trim() !== bq) return // you typed on while this one loaded: the newer search paints
816
844
  if (bq) {
817
845
  if (list.length) html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
818
846
  paintBoardKeepingSearch(banners() + html)
@@ -846,38 +874,63 @@ function boardSearch(q, closedHits, n) {
846
874
  (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') +
847
875
  ' <a class="linkish tx-board-link" href="#/search?q=' + encodeURIComponent(q) + '">' + icon('message-square') + 'Search what was said →</a></div>' : '') + '</div>'
848
876
  }
849
- // A repaint from a live update (or the 30 s refresh) while you type in the
850
- // search box keeps the box focused, with what you typed and the caret.
877
+ // A repaint from a live update (or the 30 s refresh, or your own typing's
878
+ // search) never rebuilds the search box: everything around it is replaced and
879
+ // the box itself stays the same element, so the caret, a selection, a word
880
+ // being composed and the focus are exactly as you left them. (Rebuilding it
881
+ // and putting the caret back moved the caret while you typed: the typing's
882
+ // own search put back where the caret was when it started, after you'd typed on.)
851
883
  function paintBoardKeepingSearch(h) {
884
+ if (parseRoute().name === 's') return // 0.9.8: a slow board answer never paints over the session you opened
852
885
  const old = document.getElementById('boardQ')
853
- const keep = old && document.activeElement === old ? { v: old.value, pos: old.selectionStart } : null
886
+ const row = old && old.closest('.board-search-row')
887
+ if (row && row.parentNode === $app) {
888
+ const tpl = document.createElement('template')
889
+ tpl.innerHTML = h
890
+ const nrow = tpl.content.querySelector('.board-search-row')
891
+ const nbox = nrow && nrow.querySelector('#boardQ')
892
+ if (nbox && nrow.parentNode === tpl.content) {
893
+ const kids = [...tpl.content.childNodes], at = kids.indexOf(nrow)
894
+ while ($app.firstChild && $app.firstChild !== row) $app.removeChild($app.firstChild)
895
+ while (row.nextSibling) $app.removeChild(row.nextSibling)
896
+ row.before(...kids.slice(0, at))
897
+ row.after(...kids.slice(at + 1))
898
+ // the note under the box (n matches, closed matches) follows the new answer
899
+ for (const n of [...row.childNodes]) if (!n.contains(old)) row.removeChild(n)
900
+ for (const n of [...nrow.childNodes]) if (!n.contains(nbox)) row.appendChild(n)
901
+ // the words change only when you aren't typing (the address changed, Esc cleared it)
902
+ if (document.activeElement !== old && !boardQTimer && old.value !== nbox.value) old.value = nbox.value
903
+ return
904
+ }
905
+ }
906
+ const keep = old && document.activeElement === old ? { v: old.value, a: old.selectionStart, b: old.selectionEnd, d: old.selectionDirection } : null
854
907
  $app.innerHTML = h
855
908
  if (!keep) return
856
909
  const q = document.getElementById('boardQ')
857
910
  if (!q) return
858
911
  if (q.value !== keep.v) q.value = keep.v
859
- q.focus(); try { q.setSelectionRange(keep.pos, keep.pos) } catch {}
912
+ q.focus({ preventScroll: true }); try { q.setSelectionRange(keep.a, keep.b, keep.d || 'none') } catch {}
860
913
  }
861
914
  let boardQTimer = null
862
915
  function boardSearchWire() {
863
916
  const q = document.getElementById('boardQ')
864
- if (!q) return
917
+ if (!q || q.dataset.wired) return // the same box across repaints: wired once
918
+ q.dataset.wired = '1'
865
919
  q.addEventListener('input', () => {
866
920
  clearTimeout(boardQTimer)
867
921
  boardQTimer = setTimeout(async () => {
868
- const pos = q.selectionStart
922
+ boardQTimer = null
923
+ if (!q.isConnected) return
869
924
  state.boardQ = q.value
870
925
  try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
871
926
  state.route = parseRoute()
872
- await viewBoard()
873
- const again = document.getElementById('boardQ')
874
- if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
927
+ await viewBoard() // the box isn't rebuilt, so the caret stays wherever you've typed to since
875
928
  }, 160)
876
929
  })
877
930
  q.addEventListener('keydown', (ev) => {
878
931
  if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) }
879
932
  // 0.9.6: Enter (Search on the phone's keyboard) opens the full results, transcripts included
880
- else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
933
+ else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); boardQTimer = null; location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
881
934
  })
882
935
  }
883
936
 
@@ -928,10 +981,10 @@ function resumeButton(s, viewer = {}) {
928
981
  const busy = closedUi.busy.has(s.id)
929
982
  if (s.isLocal) {
930
983
  if (!viewer.ghostty) return ''
931
- return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="Open it in a new Ghostty tab: ' + (s.codingAgent === 'codex' ? 'codex resume' : 'claude --resume') + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
984
+ return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc(resumeTip(s, 'Open it in a new Ghostty tab')) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
932
985
  }
933
986
  if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
934
- return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc('Open it in a new Ghostty tab on ' + s.machine) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume on ' + esc(s.machine)) + '</button>'
987
+ return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc(resumeTip(s, 'Open it in a new Ghostty tab on ' + s.machine)) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume on ' + esc(s.machine)) + '</button>'
935
988
  }
936
989
  function closedCard(s, viewer) {
937
990
  const href = '#/s/' + encodeURIComponent(s.id)
@@ -1248,8 +1301,8 @@ async function viewAgentSession(s) {
1248
1301
  const name = esc(a.display || a.name)
1249
1302
  const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
1250
1303
  let html = '<a class="back" href="#/">← All sessions</a>'
1251
- html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
1252
- '<div class="actions">' + agentReplyButton(a) + '</div></div>'
1304
+ html += '<div class="pagehead s-head"><div class="s-head-main"><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
1305
+ '<div class="actions s-actions">' + agentReplyButton(a) + '</div></div>'
1253
1306
  // v0.5.6: agent.url only links the name; the reply affordance and its
1254
1307
  // words come from the feed (agent.reply, agent.replyHint), or nothing.
1255
1308
  html += '<div class="banner info"><span>Read-only. This is ' + (home ? '<a href="' + esc(home) + '" target="_blank" rel="noopener noreferrer">' + name + '</a>' : name) + '’s work, from its feed' + (a.pullsHere ? ', pulled by this Mac' : a.puller ? ', pulled by ' + esc(a.puller) : '') + (a.lastSuccessAt ? ' (last pull ' + esc(ago(a.lastSuccessAt)) + ')' : '') + '.' + (hint ? ' ' + esc(hint.charAt(0).toUpperCase() + hint.slice(1)) + (/[.!?]$/.test(hint) ? '' : '.') : '') + '</span></div>'
@@ -1270,42 +1323,133 @@ async function viewAgentSession(s) {
1270
1323
  $app.innerHTML = banners() + html
1271
1324
  }
1272
1325
 
1326
+ // 0.9.8: another Mac's session whose Mac is offline says so plainly, once, near the top
1327
+ function ownerOffline(s) {
1328
+ if (!s || s.isLocal !== false || s.agent || !s.machine) return false
1329
+ const st = window.leftoffRelay && state.relayStatus
1330
+ const x = st && (st.macs || []).find((y) => y.machine === s.machine)
1331
+ if (x) return !x.online
1332
+ return !!(s.peer && s.peer.code === 'unreachable')
1333
+ }
1334
+ function ownerOfflineNote(s) {
1335
+ if (!ownerOffline(s)) return ''
1336
+ const m = esc(s.machine), sn = staleNote(s.machine)
1337
+ 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>'
1338
+ }
1339
+ // 0.9.8: opening a session is instant. The tap paints the page's shell at
1340
+ // once (title and pills from the card we already have, or the whole page from
1341
+ // the last visit), then every fetch runs in parallel (the session, its Live
1342
+ // thread, spaces, projects, lists) instead of one after another; on a phone
1343
+ // through the relay each was a full round trip. A newer navigation wins: a
1344
+ // slow answer for a page you already left never paints over the one you're on.
1345
+ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few sessions you opened
1346
+ const SESSION_CACHE_MAX = 8
1347
+ function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
1348
+ const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
1349
+ function sessionShellHtml(s) {
1350
+ const head = s
1351
+ ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + statusPill(s) + liveBadge(s) + '</div>'
1352
+ : '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
1353
+ 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>' +
1354
+ '<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>' +
1355
+ '<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>'
1356
+ }
1357
+ // A selector that finds the same control again after a redraw (null: it can't be told apart).
1358
+ function focusKey(el) {
1359
+ if (!el || !el.tagName) return null
1360
+ if (el.id) return '#' + CSS.escape(el.id)
1361
+ const d = el.dataset || {}
1362
+ const keys = ['action', 'lid', 'sid', 'att', 'id', 'tab', 'submitFor'].filter((k) => d[k] != null)
1363
+ if (!keys.length) return null
1364
+ const attr = (k) => '[data-' + k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + '="' + CSS.escape(d[k]) + '"]'
1365
+ return el.tagName.toLowerCase() + keys.map(attr).join('')
1366
+ }
1367
+ function paintSession(s, data) {
1368
+ if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
1369
+ // a redraw (the live poll, a settled reply) keeps the composer focused with its caret where it was
1370
+ const fa = document.activeElement
1371
+ const kf = fa && fa.classList && fa.classList.contains('composer-input') && $app.contains(fa) ? { d: fa.dataset.draft, a: fa.selectionStart, b: fa.selectionEnd } : null
1372
+ // …and any other control that had focus (a chip, a thumbnail, a button: a
1373
+ // preview closed with Esc puts focus back on one, then a redraw replaced it)
1374
+ const kb = !kf && fa && fa !== document.body && $app.contains(fa) ? focusKey(fa) : null
1375
+ liveKeeper.before($app)
1376
+ $app.innerHTML = sessionPageHtml(s, data)
1377
+ liveAfterRender(s.id)
1378
+ if (kf) {
1379
+ const nt = $app.querySelector('.composer-input')
1380
+ 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 {} }
1381
+ } else if (kb && !$app.contains(document.activeElement)) {
1382
+ const hits = $app.querySelectorAll(kb)
1383
+ if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true })
1384
+ }
1385
+ aroundWire(s.id)
1386
+ }
1273
1387
  async function viewSession(id) {
1274
1388
  if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
1275
- const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
1389
+ const showing = $app.querySelector('.s-head[data-sid="' + CSS.escape(id) + '"]') // a re-render of the page you're on: no shell
1390
+ const cached = sessionCache.get(id)
1391
+ const known = (cached && cached.s) || state.sessions.find((x) => x.id === id) || null
1392
+ if (!showing) {
1393
+ if (cached) paintSession(cached.s, cached) // the last visit, at once; fresh data replaces it below
1394
+ else { liveKeeper.before($app); $app.innerHTML = sessionShellHtml(known) }
1395
+ }
1396
+ // on leftoff.dev, another Mac's session loads its data from that Mac: say so before the first call
1397
+ const owner = (x) => { if (x && window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(x.id, x.isLocal === false && !x.agent ? x.machine : null) }
1398
+ owner(known)
1399
+ const pS = api('GET', '/v1/sessions/' + encodeURIComponent(id))
1400
+ const pLive = !known || liveWanted(known) ? fetchLive(id) : null // the thread doesn't wait for the session
1401
+ const pLists = known && known.lists && known.lists.length ? fetchLists(id) : null
1402
+ const pRest = Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1403
+ let s
1404
+ try { s = await pS } catch (e) { if (!onSession(id)) return; throw e }
1276
1405
  state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
1406
+ owner(s)
1407
+ const status = s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null
1408
+ const lists = s.lists && s.lists.length ? (pLists || fetchLists(id)) : null
1409
+ const live = liveWanted(s) ? (pLive || fetchLive(id)) : !isLive(s) && !s.endedAt && s.live !== 'closed' ? liveHow().then(() => null) : null
1410
+ let [[spv], v, sl] = await Promise.all([pRest, live, lists, status])
1411
+ if (!onSession(id)) return // you moved on while this loaded
1412
+ // a Live fetch that failed keeps the thread you had (its bubbles say "Reconnecting…"), not an empty one
1413
+ const prev = sessionCache.get(id)
1414
+ if (v && v.error && prev && prev.live && !prev.live.error) v = { ...prev.live, error: null }
1415
+ const data = { spv, live: v, lists: sl }
1416
+ if (v && !v.error) cacheSession(id, { s, ...data, live: v })
1417
+ paintSession(s, data)
1418
+ }
1419
+ const fetchLists = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists').catch(() => null)
1420
+ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {}) {
1277
1421
  const branch = s.repo && s.repo.branch
1278
- const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1279
1422
  let html = '<a class="back" href="#/">← All sessions</a>'
1280
1423
  // 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)
1281
1424
  const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
1282
- html += '<div class="pagehead"><div>' + (canRename
1283
- ? '<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">' + esc(sessionTitle(s)) + '</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>'
1284
- : '<h1>' + esc(sessionTitle(s)) + '</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>' +
1285
- '<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (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>'
1425
+ html += '<div class="pagehead s-head" data-sid="' + esc(s.id) + '"><div class="s-head-main">' + (canRename
1426
+ ? '<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>'
1427
+ : '<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>' +
1428
+ // 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
1429
+ '<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>'
1430
+ html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
1286
1431
  html += '<div class="facts">' +
1287
1432
  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)) +
1288
1433
  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) +
1289
1434
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1290
1435
  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>'
1291
- if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + await liveSection(s)
1436
+ if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
1292
1437
  html += parkRow(s)
1293
1438
  html += spacePicker(s, spv, 'session', s.id)
1294
- html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
1439
+ html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
1295
1440
  // 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
1296
1441
  html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
1297
1442
  html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
1298
1443
  // 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
1299
1444
  if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
1300
- if (!isLive(s)) html += await liveSection(s)
1445
+ if (!isLive(s)) html += liveBoxHtml(s, liveV)
1301
1446
  if (s.statusChanges && s.statusChanges.length) {
1302
1447
  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>'
1303
1448
  }
1304
1449
  // Lists (v0.5.1): its saved lists in full, the hint, and Find lists in this session
1305
1450
  if (s.lists && s.lists.length) {
1306
- const sl = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists').catch(() => null)
1307
1451
  state.listFull = {}
1308
- for (const l of (sl && sl.lists) || []) state.listFull[l.ref] = l
1452
+ for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l
1309
1453
  }
1310
1454
  state.listsMayEdit = state.mayAnswer !== false
1311
1455
  html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
@@ -1323,10 +1467,7 @@ async function viewSession(id) {
1323
1467
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
1324
1468
  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>'
1325
1469
  html += answeredList(s)
1326
- liveKeeper.before($app)
1327
- $app.innerHTML = html
1328
- liveAfterRender(s.id)
1329
- aroundWire(s.id)
1470
+ return html
1330
1471
  }
1331
1472
  // ------------------------------------------------------------ Live link (0.9.8 beta)
1332
1473
 
@@ -1349,17 +1490,28 @@ async function liveHow() {
1349
1490
  if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
1350
1491
  return state.liveHow
1351
1492
  }
1352
- async function liveSection(s) {
1493
+ // Does this session's page show a Live thread (and so fetch one)?
1494
+ const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
1495
+ // GET the Live thread; what it says is the newest the page knows (0.9.8: the
1496
+ // time of the last good answer decides "Reconnecting…" on a pending bubble).
1497
+ async function fetchLive(id, { timeoutMs } = {}) {
1498
+ const turns = (state.liveTurns && state.liveTurns[id]) || 20 // "Show earlier" adds 20 at a time
1499
+ try {
1500
+ const v = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/live' + (turns > 20 ? '?turns=' + turns : ''), null, timeoutMs ? { timeoutMs } : {})
1501
+ noteLive(id, v)
1502
+ return v
1503
+ } catch (e) { return { messages: [], error: e.message } }
1504
+ }
1505
+ function liveBoxHtml(s, v) {
1353
1506
  if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
1354
1507
  if (!isLive(s)) {
1355
1508
  if (s.endedAt || s.live === 'closed') return ''
1356
- const how = await liveHow()
1509
+ const how = state.liveHow
1357
1510
  return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
1358
1511
  '<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>' +
1359
- (how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p></section>'
1512
+ (how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p>' +
1513
+ (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>'
1360
1514
  }
1361
- let v = null
1362
- try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
1363
1515
  const msgs = (v && v.messages) || []
1364
1516
  const may = state.mayAnswer !== false
1365
1517
  const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
@@ -1368,17 +1520,343 @@ async function liveSection(s) {
1368
1520
  if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1369
1521
  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>' +
1370
1522
  '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1371
- msgs.map((m) => entryHtml(m, { esc, ago, md: renderDoc, now })).join('') + '</ol>' +
1523
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
1372
1524
  '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
1373
1525
  '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1374
1526
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1375
- '<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>' +
1376
- '<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>' +
1527
+ composerHtml(s, draft, may) +
1377
1528
  (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>'
1378
1529
  }
1530
+ // The Live composer: one rounded box, a textarea that starts at one line and
1531
+ // grows, and a round ↑ in its corner (dim until there's something to send).
1532
+ // No interrupt reaches Claude Code from the board, so while it works the
1533
+ // button still sends: the message waits for its next turn, as before.
1534
+ 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>'
1535
+ 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>'
1536
+ 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>'
1537
+ function composerHtml(s, draft, may) {
1538
+ const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
1539
+ const atts = attachList(s.id)
1540
+ const off = !may || (isBlank(draft) && !atts.length)
1541
+ return '<div class="composer live-row' + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '" data-sid="' + esc(s.id) + '">' +
1542
+ '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
1543
+ '<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>' +
1544
+ // the phone's own picker (Photo Library, Take Photo, Choose File): any file
1545
+ '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
1546
+ '<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>' +
1547
+ '<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>'
1548
+ }
1549
+
1550
+ // ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
1551
+ // Each file uploads to the Mac that runs the session as soon as you attach it;
1552
+ // the chips live here (not in the DOM), so a redraw of the page keeps them.
1553
+ const attachUi = { by: new Map(), urls: new Map(), blobs: new Map(), loading: new Map(), seq: 0, running: 0, queue: [] }
1554
+ const attachList = (sid) => attachUi.by.get(sid) || []
1555
+ function chipHtml(a) {
1556
+ const pct = a.total ? Math.min(100, Math.round((a.sent / a.total) * 100)) : 0
1557
+ const status = a.status === 'failed' ? 'Didn’t upload' : a.status === 'done' ? fmtSize(a.size) : a.status === 'prep' ? 'Preparing…' : fmtSize(a.size) + ' · ' + pct + '%'
1558
+ return '<div class="att-chip ' + esc(a.status) + '" data-lid="' + esc(a.lid) + '"' + (a.error ? ' title="' + esc(a.error) + '"' : '') + '>' +
1559
+ '<button type="button" class="att-open" data-action="att-chip-open" data-lid="' + esc(a.lid) + '" aria-label="Preview ' + esc(a.name) + '">' +
1560
+ '<span class="att-thumb">' + (a.preview ? '<img src="' + esc(a.preview) + '" alt="">' : FILE_IC) + '</span>' +
1561
+ '<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>' +
1562
+ (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>' : '') +
1563
+ '<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>' +
1564
+ (a.status === 'up' || a.status === 'prep' ? '<span class="att-bar" aria-hidden="true"><span data-pct="' + pct + '"></span></span>' : '') + '</div>'
1565
+ }
1566
+ // The board's CSP takes no inline styles: a progress bar's width is set here.
1567
+ function barWidths(root) { for (const b of root.querySelectorAll('.att-bar > span[data-pct]')) b.style.width = b.dataset.pct + '%' }
1568
+ // Redraw one session's chips in place (and the ↑'s state).
1569
+ function paintChips(sid) {
1570
+ const box = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"]')
1571
+ if (!box) return
1572
+ const atts = attachList(sid)
1573
+ const wrap = box.querySelector('.att-chips')
1574
+ const fa = wrap.contains(document.activeElement) ? focusKey(document.activeElement) : null // a progress tick keeps the chip you're on focused
1575
+ wrap.innerHTML = atts.map(chipHtml).join('')
1576
+ if (fa) { const t = wrap.querySelector(fa); if (t) t.focus({ preventScroll: true }) }
1577
+ barWidths(wrap)
1578
+ wrap.hidden = !atts.length
1579
+ box.classList.toggle('has-atts', !!atts.length)
1580
+ const ta = box.querySelector('.composer-input')
1581
+ if (ta) syncComposer(ta)
1582
+ }
1583
+ function addFiles(sid, files, { pasted = false } = {}) {
1584
+ const list = Array.from(files || []).filter(Boolean)
1585
+ if (!sid || !list.length) return
1586
+ const have = attachList(sid)
1587
+ const plan = attachPlan(list, have.length)
1588
+ if (plan.skipped.length) toast('Not attached: ' + skippedText(plan.skipped), 4200)
1589
+ const add = plan.take.map((f) => {
1590
+ const file = pasted && /^image\.\w+$|^$/.test(f.name || '') ? new File([f], pastedName(f.type), { type: f.type, lastModified: Date.now() }) : f
1591
+ 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 }
1592
+ })
1593
+ if (!add.length) return
1594
+ attachUi.by.set(sid, [...have, ...add])
1595
+ paintChips(sid)
1596
+ try { promoteActive() } catch {}
1597
+ for (const a of add) startUpload(a)
1598
+ }
1599
+ function startUpload(a) {
1600
+ a.status = 'prep'; a.error = null; a.sent = 0
1601
+ attachUi.queue.push(a); pumpUploads()
1602
+ }
1603
+ // Three at a time: a phone on a slow line shouldn't open ten uploads at once.
1604
+ function pumpUploads() {
1605
+ while (attachUi.running < 3 && attachUi.queue.length) {
1606
+ const a = attachUi.queue.shift()
1607
+ if (!attachList(a.sid).includes(a)) continue
1608
+ attachUi.running++
1609
+ runUpload(a).finally(() => { attachUi.running--; pumpUploads() })
1610
+ }
1611
+ }
1612
+ async function runUpload(a) {
1613
+ const gone = () => !attachList(a.sid).includes(a)
1614
+ try {
1615
+ if (!a.prepared) {
1616
+ const p = await prepareFile(a.file)
1617
+ a.prepared = p.file; a.thumb = p.thumb
1618
+ a.name = p.file.name || a.name; a.size = p.file.size; a.total = p.file.size; a.type = p.file.type || a.type
1619
+ if (attachKind(p.file) === 'image' && !a.preview && p.thumb) a.preview = 'data:image/jpeg;base64,' + p.thumb
1620
+ // a PDF's first page, for its chip and its bubble (a few seconds at most; without it, the file icon)
1621
+ if (attachKind(p.file) === 'pdf' && !a.thumb) {
1622
+ const t = await Promise.race([pdfThumb(p.file), new Promise((r) => setTimeout(() => r(null), 5000))]).catch(() => null)
1623
+ if (t) { a.thumb = t; a.preview = 'data:image/jpeg;base64,' + t }
1624
+ }
1625
+ if (p.note === 'heic') a.error = 'This browser can’t convert HEIC; it goes as is'
1626
+ }
1627
+ if (gone()) return
1628
+ a.status = 'up'; paintChips(a.sid)
1629
+ let last = 0
1630
+ const f = await uploadFile({ api, sessionId: a.sid, file: a.prepared, thumb: a.thumb, isCancelled: gone,
1631
+ onProgress: (sent, total) => { a.sent = sent; a.total = total; const t = Date.now(); if (t - last > 120 || sent >= total) { last = t; paintChips(a.sid) } } })
1632
+ a.id = f.id; a.size = f.size; a.kind = f.kind; a.status = 'done'
1633
+ if (a.preview) attachUi.urls.set(a.sid + '/' + f.id, a.preview) // the bubble shows it without asking the Mac
1634
+ } catch (e) {
1635
+ if (e.code === 'removed' || gone()) return
1636
+ a.status = 'failed'; a.error = e.message || 'Upload failed'
1637
+ }
1638
+ paintChips(a.sid)
1639
+ attachWaiters()
1640
+ }
1641
+ let attachWaitList = []
1642
+ function attachWaiters() { const w = attachWaitList; attachWaitList = []; for (const f of w) f() }
1643
+ // Wait until nothing of this session's is still uploading.
1644
+ async function uploadsSettled(sid) {
1645
+ while (attachList(sid).some((a) => a.status === 'prep' || a.status === 'up')) await new Promise((r) => { attachWaitList.push(r); setTimeout(r, 1000) })
1646
+ }
1647
+ function removeAttachment(lid) {
1648
+ for (const [sid, list] of attachUi.by) {
1649
+ const a = list.find((x) => x.lid === lid)
1650
+ if (!a) continue
1651
+ attachUi.by.set(sid, list.filter((x) => x !== a))
1652
+ paintChips(sid)
1653
+ const ta = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"] .composer-input')
1654
+ if (ta && !isTouchUi(window.matchMedia)) ta.focus({ preventScroll: true })
1655
+ return
1656
+ }
1657
+ }
1658
+ function retryAttachment(lid) {
1659
+ for (const list of attachUi.by.values()) { const a = list.find((x) => x.lid === lid); if (a) { startUpload(a); paintChips(a.sid); return } }
1660
+ }
1661
+ // The composer a drop or a paste is for: the session page's own (enabled).
1662
+ function composerFor(el) {
1663
+ const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
1664
+ return box && !box.classList.contains('off') ? box : null
1665
+ }
1666
+ document.addEventListener('change', (ev) => {
1667
+ const inp = ev.target
1668
+ if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
1669
+ addFiles(inp.dataset.sid, inp.files)
1670
+ inp.value = '' // the same file can be picked again
1671
+ })
1672
+ document.addEventListener('paste', (ev) => {
1673
+ const t = ev.target
1674
+ const inComposer = t && t.classList && t.classList.contains('composer-input')
1675
+ if (!inComposer && t && t.closest && t.closest('input, textarea, [contenteditable]')) return
1676
+ const cd = ev.clipboardData
1677
+ 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) : []
1678
+ if (!files.length) return
1679
+ const box = composerFor(inComposer ? t : null)
1680
+ if (!box) return
1681
+ ev.preventDefault()
1682
+ addFiles(box.dataset.sid, files, { pasted: true })
1683
+ })
1684
+ {
1685
+ // Drag files onto the session page: the box lights up; drop attaches.
1686
+ const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
1687
+ let leaveT = 0
1688
+ const lit = (on) => { const b = document.querySelector('#app .composer[data-sid]'); if (b) b.classList.toggle('att-drop', on) }
1689
+ 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) })
1690
+ document.addEventListener('drop', (e) => {
1691
+ if (!hasFiles(e)) return
1692
+ const box = composerFor(null)
1693
+ e.preventDefault() // never let a stray drop navigate away from the board
1694
+ lit(false)
1695
+ if (box) addFiles(box.dataset.sid, e.dataTransfer.files)
1696
+ })
1697
+ }
1698
+ // A sent message's attachments in its bubble: thumbnails (tap for full size) or file chips.
1699
+ function attBubbleHtml(atts, sid) {
1700
+ return '<div class="lv-atts">' + atts.map((a) => {
1701
+ const s0 = a.sessionId || sid, key = s0 + '/' + a.id
1702
+ const label = esc(a.name) + ' · ' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '')
1703
+ 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 || '') + '"'
1704
+ 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>' }
1705
+ 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
1706
+ 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>'
1707
+ }).join('') + '</div>'
1708
+ }
1709
+ // Thumbnails come from the Mac once, then from memory. data: URLs, because the
1710
+ // board's CSP allows images from itself and data: only (no blob:).
1711
+ 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) })
1712
+ function attData(sid, id, { thumb = false } = {}) {
1713
+ const key = sid + '/' + id + (thumb ? '' : '#full')
1714
+ if (attachUi.urls.has(key)) return Promise.resolve({ url: attachUi.urls.get(key), blob: attachUi.blobs.get(key) || null })
1715
+ if (attachUi.loading.has(key)) return attachUi.loading.get(key)
1716
+ 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))
1717
+ attachUi.loading.set(key, p)
1718
+ return p
1719
+ }
1720
+ function hydrateAttThumbs() {
1721
+ for (const img of document.querySelectorAll('#app img[data-att-thumb]:not([src])')) {
1722
+ const key = img.dataset.attThumb
1723
+ if (attachUi.urls.has(key)) { img.src = attachUi.urls.get(key); continue }
1724
+ const btn = img.closest('.lv-att'); if (!btn || btn.dataset.loading) continue
1725
+ btn.dataset.loading = '1'
1726
+ const thumb = btn.dataset.thumb !== '0'
1727
+ 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 })
1728
+ .catch((e) => {
1729
+ // a PDF sent without a first-page picture (before 0.9.8, or it couldn't be drawn) keeps its icon
1730
+ if (btn.dataset.kind === 'pdf' && e && e.code === 'no_thumb') { for (const i of btn.querySelectorAll('img[data-att-thumb]')) i.remove(); return }
1731
+ btn.classList.add('gone'); btn.title = 'Not on that Mac any more'
1732
+ })
1733
+ }
1734
+ }
1735
+ // An image opens full screen (ui/lightbox.js) with the other pictures of the
1736
+ // same message beside it; the thumbnail shows (blurred) while the full one
1737
+ // comes from the Mac. Text and other files keep the docs viewer.
1738
+ const attSel = (sid, id) => '.lv-att[data-sid="' + CSS.escape(sid) + '"][data-att="' + CSS.escape(id) + '"]'
1739
+ function openImageAttachment(btn) {
1740
+ const group = btn.closest('.lv-atts')
1741
+ const btns = group ? [...group.querySelectorAll('.lv-att[data-kind="image"]')] : [btn]
1742
+ const items = btns.map((b) => {
1743
+ const im = b.querySelector('img'), sid = b.dataset.sid, id = b.dataset.att
1744
+ const full = attachUi.urls.get(sid + '/' + id + '#full')
1745
+ return { name: b.dataset.name || 'image', sizeLabel: b.dataset.size || '', thumb: (im && im.getAttribute('src')) || null, url: full || null,
1746
+ w: im && im.naturalWidth, h: im && im.naturalHeight, load: () => attData(sid, id).then((r) => r.url) }
1747
+ })
1748
+ const sid = btn.dataset.sid, id = btn.dataset.att
1749
+ openLightbox({ items, index: Math.max(0, btns.indexOf(btn)), from: btn.querySelector('img') || btn, refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1750
+ }
1751
+ // A chip in the box before it's sent: the picture from the file you picked
1752
+ // (a data: URL; the CSP takes no blob:), the other pictures in the box beside it.
1753
+ // Any other file: its name and size in the docs viewer (nothing to download yet).
1754
+ function openChip(lid) {
1755
+ let a = null
1756
+ for (const list of attachUi.by.values()) { a = list.find((x) => x.lid === lid); if (a) break }
1757
+ if (!a) return
1758
+ const chip = document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1759
+ const isImg = (x) => x.kind === 'image' && (x.preview || /^image\/(png|jpeg|gif|webp|bmp|svg\+xml|avif)$/i.test((x.prepared || x.file || {}).type || ''))
1760
+ const refocus = () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1761
+ if (a.kind === 'pdf') {
1762
+ const f = a.prepared || a.file
1763
+ openPdfView({ name: a.name, sizeLabel: fmtSize(a.size), download: false, from: chip, refocus, load: async () => ({ bytes: new Uint8Array(await f.arrayBuffer()) }) })
1764
+ return
1765
+ }
1766
+ if (isImg(a)) {
1767
+ const list = attachList(a.sid).filter(isImg)
1768
+ const items = list.map((x) => ({ name: x.name, sizeLabel: fmtSize(x.size), thumb: x.preview || null, download: false,
1769
+ load: () => { if (!x.fullUrl) x.fullUrl = blobToDataUrl(x.prepared || x.file); return x.fullUrl } }))
1770
+ 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) + '"]') })
1771
+ return
1772
+ }
1773
+ const el = docViewerEl()
1774
+ el.querySelector('.dv-title').textContent = a.name
1775
+ el.querySelector('.dv-sub').textContent = ''
1776
+ el.querySelector('.dv-dl').hidden = true
1777
+ docUi.r = null; docUi.name = a.name
1778
+ 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>'
1779
+ el.hidden = false
1780
+ document.documentElement.classList.add('dv-open')
1781
+ el.querySelector('.dv-x') && el.querySelector('.dv-x').focus({ preventScroll: true })
1782
+ }
1783
+ // A PDF opens in its own full-screen preview (ui/pdfview.js): the pages drawn
1784
+ // from the file the Mac sends back (the same chunked read as a picture).
1785
+ function openPdfAttachment(btn) {
1786
+ const sid = btn.dataset.sid, id = btn.dataset.att
1787
+ openPdfView({ name: btn.dataset.name || 'file.pdf', sizeLabel: btn.dataset.size || '', from: btn,
1788
+ load: async () => { const r = await attData(sid, id); return { bytes: new Uint8Array(await r.blob.arrayBuffer()), url: r.url } },
1789
+ refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1790
+ }
1791
+ async function openAttachment(btn) {
1792
+ const sid = btn.dataset.sid, id = btn.dataset.att, name = btn.dataset.name || 'file', kind = btn.dataset.kind
1793
+ if (kind === 'image') return openImageAttachment(btn)
1794
+ if (kind === 'pdf') return openPdfAttachment(btn)
1795
+ const el = docViewerEl()
1796
+ el.querySelector('.dv-title').textContent = name
1797
+ el.querySelector('.dv-sub').textContent = ''
1798
+ el.querySelector('.dv-dl').hidden = true
1799
+ const body = el.querySelector('.dv-body')
1800
+ body.innerHTML = '<div class="empty">Opening…</div>'
1801
+ docUi.r = null; docUi.name = name
1802
+ el.hidden = false
1803
+ document.documentElement.classList.add('dv-open')
1804
+ try {
1805
+ const { url: u, blob } = await attData(sid, id)
1806
+ if (el.hidden) return
1807
+ const dl = '<a class="btn" href="' + esc(u) + '" download="' + esc(name) + '">' + icon('download') + 'Download</a>'
1808
+ if (kind === 'image') body.innerHTML = '<div class="att-full"><img src="' + esc(u) + '" alt="' + esc(name) + '"></div><div class="att-full-actions">' + dl + '</div>'
1809
+ 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>' }
1810
+ else body.innerHTML = '<div class="empty">' + esc(name) + '</div><div class="att-full-actions">' + dl + '</div>'
1811
+ } catch (e) { if (!el.hidden) body.innerHTML = '<div class="empty">Couldn’t open it: ' + esc(e.message) + '</div>' }
1812
+ }
1813
+ // 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.
1814
+ function fitComposer(ta) {
1815
+ if (!ta || !ta.isConnected) return
1816
+ const cs = getComputedStyle(ta)
1817
+ const lh = parseFloat(cs.lineHeight) || 22
1818
+ const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
1819
+ const keep = ta.scrollTop
1820
+ ta.style.height = 'auto'
1821
+ const r = composerHeight({ scrollHeight: ta.scrollHeight, lineHeight: lh, padY })
1822
+ ta.style.height = r.height + 'px'
1823
+ ta.style.overflowY = r.scroll ? 'auto' : 'hidden'
1824
+ if (r.scroll) ta.scrollTop = keep
1825
+ syncComposer(ta)
1826
+ }
1827
+ function syncComposer(ta) {
1828
+ const box = ta && ta.closest('.composer')
1829
+ const btn = box && box.querySelector('.composer-send')
1830
+ if (!btn || btn.classList.contains('sending')) return
1831
+ btn.disabled = ta.disabled || (isBlank(ta.value) && !attachList(box.dataset.sid).length)
1832
+ }
1833
+ document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
1834
+ // Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
1835
+ document.addEventListener('keydown', (ev) => {
1836
+ const ta = ev.target
1837
+ if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
1838
+ if (ev.metaKey || ev.ctrlKey) return // ⌘↵ / Ctrl+↵: the Needs-you handler presses the button
1839
+ if (composerKey(ev, { touch: isTouchUi(window.matchMedia) }) !== 'send') return
1840
+ ev.preventDefault()
1841
+ const cb = ta.closest('.composer')
1842
+ if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
1843
+ const box = ta.closest('.live-box')
1844
+ const btn = box && box.querySelector('[data-submit-for="live"]')
1845
+ if (!btn || btn.disabled) return
1846
+ const last = Number(box.dataset.kbdAt || 0)
1847
+ if (Date.now() - last < 1500) return
1848
+ box.dataset.kbdAt = String(Date.now())
1849
+ btn.click()
1850
+ })
1851
+ // A tap anywhere on the box (its padding) is a tap in the textarea.
1852
+ 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() })
1853
+ // Pressing ↑ keeps the keyboard up (the textarea keeps focus) on a phone.
1854
+ 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() })
1379
1855
  // After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
1380
1856
  function liveAfterRender(sid) {
1857
+ for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
1381
1858
  const r = liveKeeper.after($app)
1859
+ hydrateAttThumbs(); barWidths($app)
1382
1860
  if (!r) return
1383
1861
  const ol = r.thread.querySelector('.live-log')
1384
1862
  const empty = r.thread.querySelector('.live-empty')
@@ -1386,8 +1864,16 @@ function liveAfterRender(sid) {
1386
1864
  liveJumpButton(r.thread, liveKeeper.unseen)
1387
1865
  if (ol && !ol.dataset.wired) {
1388
1866
  ol.dataset.wired = '1'
1389
- ol.addEventListener('scroll', () => { if (atBottom(ol)) { liveKeeper.seen(); liveJumpButton(r.thread, 0) } }, { passive: true })
1867
+ ol._stick = atBottom(ol)
1868
+ // the button follows the scroll: there whenever you're away from the bottom, gone once you're back
1869
+ ol.addEventListener('scroll', () => { ol._stick = atBottom(ol); liveJumpButton(r.thread, liveKeeper.unseen) }, { passive: true })
1870
+ // at the bottom, the thread stays there while an entry grows (a streamed reply, a code block, late fonts)
1871
+ if (typeof ResizeObserver === 'function') {
1872
+ ol._ro = new ResizeObserver(() => { if (ol._stick && !atBottom(ol, 2)) ol.scrollTop = ol.scrollHeight; liveJumpButton(r.thread, liveKeeper.unseen) })
1873
+ }
1390
1874
  }
1875
+ if (ol && ol._ro) { ol._ro.disconnect(); ol._ro.observe(ol); for (const li of ol.children) ol._ro.observe(li) }
1876
+ if (ol && r.follow) ol._stick = true
1391
1877
  // #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
1392
1878
  const want = parseLiveRoute(location.hash)
1393
1879
  if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
@@ -1400,48 +1886,179 @@ function liveAfterRender(sid) {
1400
1886
  }
1401
1887
  liveTick()
1402
1888
  }
1889
+ // "Jump to latest ↓" shows whenever the thread is scrolled away from its bottom (with the
1890
+ // count of what came in since, when there is any), and hides once you're back at the bottom.
1403
1891
  function liveJumpButton(thread, n) {
1404
1892
  const b = thread && thread.querySelector('.live-jump')
1405
1893
  if (!b) return
1406
- b.hidden = !(n > 0)
1894
+ const ol = thread.querySelector('.live-log')
1895
+ const v = jumpView(ol, n)
1896
+ if (!v.show && n > 0) liveKeeper.seen()
1897
+ // on its way down after a tap: don't flash back up mid-scroll
1898
+ if (v.show && thread.dataset.jumping && Date.now() < Number(thread.dataset.jumping)) return
1899
+ b.hidden = !v.show
1407
1900
  const c = b.querySelector('.live-jump-n')
1408
- if (c) c.textContent = n > 0 ? ' · ' + n + ' new' : ''
1901
+ if (c) c.textContent = v.count
1902
+ b.setAttribute('aria-label', v.label)
1409
1903
  }
1410
1904
  function liveJumpClick(btn) {
1411
1905
  const thread = btn.closest('.live-thread')
1412
1906
  const ol = thread && thread.querySelector('.live-log')
1413
- if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
1414
- liveKeeper.seen(); liveJumpButton(thread, 0)
1907
+ const smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
1908
+ if (thread) thread.dataset.jumping = String(Date.now() + (smooth ? 900 : 0))
1909
+ if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
1910
+ liveKeeper.seen()
1911
+ btn.hidden = true
1912
+ const c = btn.querySelector('.live-jump-n'); if (c) c.textContent = ''
1913
+ // land exactly at the bottom even if something grew mid-scroll, then the button follows the scroll again
1914
+ setTimeout(() => { if (!thread || !thread.isConnected) return; delete thread.dataset.jumping; if (ol && !atBottom(ol, 4)) ol.scrollTop = ol.scrollHeight; liveJumpButton(thread, liveKeeper.unseen) }, smooth ? 950 : 0)
1915
+ }
1916
+ // "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
1917
+ async function liveEarlierClick(btn) {
1918
+ const box = btn.closest('.live-box')
1919
+ const sid = box && box.dataset.id
1920
+ if (!sid) return
1921
+ state.liveTurns = state.liveTurns || {}
1922
+ state.liveTurns[sid] = ((state.liveTurns[sid]) || 20) + 20
1923
+ btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading…'
1924
+ await render()
1415
1925
  }
1416
1926
  // The elapsed time beside "Claude is working…", every second while something is in flight.
1927
+ // 0.9.8: when what the page shows is older than STALE_MS (the stream died, the
1928
+ // phone slept), a running bubble says "Reconnecting…" instead of a timer.
1417
1929
  let liveTimer = null
1418
1930
  function liveTick() {
1419
1931
  const els = [...document.querySelectorAll('.lv-status[data-since]')]
1932
+ const box = document.querySelector('#live[data-id]')
1933
+ const stale = !!box && isStale(liveUi.okAt[box.dataset.id], Date.now())
1420
1934
  for (const el of els) {
1421
1935
  const since = Number(el.dataset.since) || 0
1422
1936
  const end = Number(el.dataset.final) || 0
1423
1937
  const t = el.querySelector('.lvs-time')
1424
1938
  if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
1939
+ if (!end) {
1940
+ el.classList.toggle('lv-reconnecting', stale)
1941
+ let hint = el.querySelector('.lvs-hint')
1942
+ if (stale && !hint) { hint = document.createElement('span'); hint.className = 'lvs-hint'; hint.textContent = 'Reconnecting…'; (el.querySelector('.lvs-line') || el).append(hint) }
1943
+ if (hint) hint.hidden = !stale
1944
+ }
1425
1945
  }
1426
1946
  const running = els.some((el) => !el.dataset.final)
1427
1947
  if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
1428
1948
  }
1949
+
1950
+ // 0.9.8: Live keeps up after the phone sleeps (ui/live-wake.js). Every Live
1951
+ // fetch notes when it answered and whether a message is still going; while
1952
+ // one is, on a visible page, the thread is fetched again every POLL_MS
1953
+ // whatever the event stream does.
1954
+ const liveUi = { okAt: {}, sig: {}, pending: {}, polling: false }
1955
+ function noteLive(id, v) {
1956
+ if (!v || v.error) return
1957
+ liveUi.okAt[id] = Date.now()
1958
+ liveUi.pending[id] = anyPending(v.messages)
1959
+ }
1960
+ 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 }
1961
+ // Fetch the thread and diff it into the page, even while you type (busy()
1962
+ // holds full redraws back, never the thread). A turn that ended also redraws
1963
+ // the rest of the page (the pill stops pulsing) once you're not busy.
1964
+ async function refreshLive(reason) {
1965
+ const id = liveOnScreen()
1966
+ if (!id || liveUi.polling) return false
1967
+ liveUi.polling = true; liveUi.triedAt = Date.now()
1968
+ let v
1969
+ try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
1970
+ if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
1971
+ const s = state.sessions.find((x) => x.id === id)
1972
+ const sig = liveSignature(v.messages)
1973
+ const before = liveUi.sig[id]
1974
+ liveUi.sig[id] = sig
1975
+ if (s && before !== sig) {
1976
+ const box = document.querySelector('#live')
1977
+ const cur = box && box.querySelector('.live-thread')
1978
+ if (cur) {
1979
+ const tmp = document.createElement('div')
1980
+ tmp.innerHTML = liveBoxHtml(s, v)
1981
+ const fresh = tmp.querySelector('.live-thread')
1982
+ if (fresh) {
1983
+ const next = cur.nextSibling
1984
+ liveKeeper.before(box)
1985
+ box.insertBefore(fresh, next)
1986
+ const r = liveKeeper.after(box)
1987
+ hydrateAttThumbs()
1988
+ if (r) liveJumpButton(r.thread, liveKeeper.unseen)
1989
+ }
1990
+ }
1991
+ const c = sessionCache.get(id); if (c) c.live = v
1992
+ if (!liveUi.pending[id] && !busy()) soon() // settled: the pill, the tab, the card
1993
+ }
1994
+ liveTick()
1995
+ return true
1996
+ }
1997
+ // The safety net, and the stream's watchdog, on one slow timer.
1998
+ const streamDog = createWatchdog()
1999
+ const wakeGate = createWake()
2000
+ setInterval(() => {
2001
+ if (document.visibilityState === 'hidden') return
2002
+ const id = liveOnScreen()
2003
+ 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')
2004
+ if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
2005
+ }, 1000)
2006
+ // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
2007
+ // bfcache restore, the network returns): fetch what's on screen at once, and
2008
+ // open a new stream if the old one missed its pings.
2009
+ function refreshView(reason) {
2010
+ if (liveOnScreen()) refreshLive(reason)
2011
+ if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
2012
+ refreshHealth()
2013
+ }
2014
+ let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
2015
+ function onWake(ev) {
2016
+ if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
2017
+ const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
2018
+ if (!why) return
2019
+ liveTick() // a stale timer turns into "Reconnecting…" before anything else
2020
+ const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
2021
+ const away = awayAt ? Date.now() - awayAt : 0
2022
+ awayAt = 0
2023
+ // 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
2024
+ if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
2025
+ if (!wakeGate.due()) return
2026
+ if (why === 'pageshow-bfcache' || dead) connect()
2027
+ refreshView(why)
2028
+ }
2029
+ for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
2030
+ document.addEventListener('visibilitychange', onWake)
1429
2031
  async function liveSendClick(btn) {
1430
2032
  const box = btn.closest('.live-box')
1431
2033
  const ta = box && box.querySelector('.live-text')
2034
+ const sid = btn.dataset.id
1432
2035
  const text = ta ? ta.value.trim() : ''
1433
- if (!text) { toast('Type a message first'); return }
1434
- if (btn.getAttribute('aria-disabled') === 'true') return
1435
- btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Sending…'
2036
+ if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
2037
+ if (btn.disabled || btn.classList.contains('sending')) return
2038
+ const hadFocus = !!(ta && document.activeElement === ta)
2039
+ btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
1436
2040
  try {
1437
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/live', { text })
1438
- if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
1439
- if (ta) ta.value = ''
2041
+ // 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
2042
+ await uploadsSettled(sid)
2043
+ const atts = attachList(sid)
2044
+ const failed = atts.filter((a) => a.status === 'failed').length
2045
+ 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 }
2046
+ const ids = atts.filter((a) => a.status === 'done' && a.id).map((a) => a.id)
2047
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live', { text, ...(ids.length ? { attachments: ids } : {}) })
2048
+ // what you typed while it went stays; what was sent goes
2049
+ if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
2050
+ if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
1440
2051
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
1441
2052
  const ol = box && box.querySelector('.live-log')
1442
2053
  if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
1443
2054
  setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
1444
- } 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' } }
2055
+ } catch (e) { toast(e.message, 4200) } finally {
2056
+ await render()
2057
+ const nb = document.querySelector('.live-box [data-action="live-send"]')
2058
+ if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
2059
+ const nt = document.querySelector('.live-box .composer-input')
2060
+ if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
2061
+ }
1445
2062
  }
1446
2063
 
1447
2064
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
@@ -1604,6 +2221,11 @@ async function togglePr(url, { retry = false } = {}) {
1604
2221
  const was = prsUi.open.has(url)
1605
2222
  if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
1606
2223
  const repaint = () => {
2224
+ if (state.route && state.route.name === 's') { // 0.9.8: the session page's PRs open in place too
2225
+ const s = state.sessions.find((x) => x.id === state.route.arg), sec = document.getElementById('prs')
2226
+ if (s && sec) sec.innerHTML = sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details })
2227
+ return
2228
+ }
1607
2229
  const d = prsUi.data[prsUi.view]
1608
2230
  const r = prRows(d).find((x) => x.pr.url === url)
1609
2231
  const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
@@ -1617,7 +2239,7 @@ async function togglePr(url, { retry = false } = {}) {
1617
2239
  try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
1618
2240
  catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
1619
2241
  prsUi.details[url] = { ...res, at: Date.now() }
1620
- if (state.route && state.route.name === 'prs') repaint()
2242
+ if (state.route && (state.route.name === 'prs' || state.route.name === 's')) repaint()
1621
2243
  }
1622
2244
  function setPrsFilter(f) {
1623
2245
  const h = prsHash(prsUi.view, f)
@@ -1678,29 +2300,67 @@ async function viewTimeline() {
1678
2300
  $app.innerHTML = html
1679
2301
  }
1680
2302
 
1681
- function renderMd(md, { spaces = [] } = {}) {
2303
+ // 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
2304
+ // links there and Start here gets that session's Go / Resume.
2305
+ const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
2306
+ function briefAction(id, sessions, viewer) {
2307
+ const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
2308
+ const s = sessions && sessions.get(id)
2309
+ if (!s) return open
2310
+ let b = ''
2311
+ // 0.9.8: closed: Resume opens it in a new tab where it can, else (on the Mac) copies the resume command
2312
+ 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>')
2313
+ else { const g = goButton(s); b = g && !/disabled/.test(g) ? g : '' }
2314
+ return b || open
2315
+ }
2316
+
2317
+ function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
1682
2318
  const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
1683
2319
  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)
1684
2320
  const out = []
1685
- let inList = false, inSec = false
2321
+ let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
2322
+ const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
2323
+ const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
2324
+ const href = (id) => '#/s/' + encodeURIComponent(id)
1686
2325
  // v0.5.8: each "## <space>" opens a section with its own Copy for standup;
1687
2326
  // other "## " headings (Lists, Top of mind) are whole-folder sections.
2327
+ // 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
1688
2328
  const openSec = (h) => {
1689
2329
  if (inSec) out.push('</section>')
1690
2330
  inSec = true
1691
- if (spaces.includes(h)) {
2331
+ if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
2332
+ else if (spaces.includes(h)) {
1692
2333
  out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
1693
2334
  '<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>')
1694
2335
  } else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
1695
2336
  }
1696
- for (const line of body.split('\n')) {
2337
+ for (const raw of body.split('\n')) {
2338
+ const mid = raw.match(MD_SID)
2339
+ const id = mid ? mid[1] : null
2340
+ const line = mid ? raw.slice(0, mid.index) : raw
2341
+ // 0.9.8: Start here: "1. <step> — **Title** (repo)"
2342
+ const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
2343
+ if (st) {
2344
+ openList('ol', 'start')
2345
+ const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
2346
+ 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>' +
2347
+ (id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
2348
+ continue
2349
+ }
1697
2350
  if (/^- /.test(line)) {
1698
- if (!inList) { out.push('<ul>'); inList = true }
2351
+ openList('ul')
1699
2352
  // Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
1700
2353
  const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
2354
+ const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
2355
+ // 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
2356
+ // session: the title links to it, the sentence under it, the tags quiet.
2357
+ const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
2358
+ // 0.9.8: an Unfinished one is closed, so it has its Resume beside it
2359
+ 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 }
2360
+ 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 }
1701
2361
  // v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
1702
2362
  const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
1703
- out.push('<li' + (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + '>' + li); continue
2363
+ out.push('<li' + attrs + '>' + li); continue
1704
2364
  }
1705
2365
  if (/^\s{2,}\S/.test(line) && inList) {
1706
2366
  // "Stopped at: … Next: …" is two lines, each with its label.
@@ -1709,13 +2369,17 @@ function renderMd(md, { spaces = [] } = {}) {
1709
2369
  else out.push('<span class="sub">' + inline(t) + '</span>')
1710
2370
  continue
1711
2371
  }
1712
- if (inList) { out.push('</ul>'); inList = false }
2372
+ closeList()
2373
+ if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
1713
2374
  if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
1714
- else if (/^## /.test(line)) { if (spaces.length) openSec(line.slice(3).trim()); else out.push('<h2>' + inline(line.slice(3)) + '</h2>') }
2375
+ 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>') } }
2376
+ // 0.9.8: Finished folds away (older digests' "Finished or closed" too)
2377
+ 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 }
1715
2378
  else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
1716
- else if (line.trim()) out.push('<p>' + inline(line) + '</p>')
2379
+ // 0.9.8: the Yesterday / Today opener, above the first section
2380
+ else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
1717
2381
  }
1718
- if (inList) out.push('</ul>')
2382
+ closeList()
1719
2383
  if (inSec) out.push('</section>')
1720
2384
  return out.join('\n')
1721
2385
  }
@@ -1726,6 +2390,81 @@ function shortDay(iso) {
1726
2390
  return isNaN(d) ? iso : d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '')
1727
2391
  }
1728
2392
 
2393
+ // The past-digests calendar (0.9.8): one month at a time, Sunday first. A day
2394
+ // with a digest is a link (the shared one, else a Mac's own from before one
2395
+ // digest a day); the rest are plain numbers. Opens on the picked digest's
2396
+ // month; next stops at this month, prev at the first month with a digest.
2397
+ const monthOf = (iso) => String(iso).slice(0, 7)
2398
+ const localDay = (t = new Date()) => t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0')
2399
+ function shiftMonth(ym, by) {
2400
+ const [y, m] = ym.split('-').map(Number)
2401
+ const d = new Date(y, m - 1 + by, 1)
2402
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
2403
+ }
2404
+ function digestCalendar() {
2405
+ const { days = [], pick = null } = digestUi.cal || {}
2406
+ const today = localDay()
2407
+ const max = monthOf(today)
2408
+ const min = days.length ? days.map((x) => monthOf(x.day)).sort()[0] : max
2409
+ let ym = digestUi.month || max
2410
+ if (ym > max) ym = max
2411
+ if (ym < min) ym = min
2412
+ digestUi.month = ym
2413
+ const [y, m] = ym.split('-').map(Number)
2414
+ const first = new Date(y, m - 1, 1)
2415
+ const label = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
2416
+ const by = new Map(days.map((x) => [x.day, x.d]))
2417
+ const prev = shiftMonth(ym, -1), next = shiftMonth(ym, 1)
2418
+ const name = (x) => new Date(x + '-01T12:00:00').toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
2419
+ 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>'
2420
+ // phones fold it to one week (the picked day's) until you open the month
2421
+ const open = !!digestUi.calOpen
2422
+ let h = '<div class="cal-head">' + navBtn(prev, prev >= min, -1) + '<span class="cal-month" aria-live="polite">' + esc(label) + '</span>' +
2423
+ '<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>'
2424
+ 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('')
2425
+ const n = new Date(y, m, 0).getDate()
2426
+ const lead0 = first.getDay()
2427
+ const week = (dn) => Math.floor((lead0 + dn - 1) / 7)
2428
+ const inMonth = (x) => x && x.slice(0, 7) === ym
2429
+ const firstHas = days.map((x) => x.day).filter(inMonth).sort()[0]
2430
+ const wk = week(inMonth(pick) ? Number(pick.slice(8)) : inMonth(today) ? Number(today.slice(8)) : firstHas ? Number(firstHas.slice(8)) : 1)
2431
+ for (let i = 0; i < lead0; i++) h += '<span class="cal-day pad' + (wk === 0 ? ' wk' : '') + '" aria-hidden="true"></span>'
2432
+ let count = 0
2433
+ for (let dn = 1; dn <= n; dn++) {
2434
+ const day = ym + '-' + String(dn).padStart(2, '0')
2435
+ const d = by.get(day)
2436
+ const cls = ['cal-day', d ? 'has' : 'none', day === pick ? 'on' : '', day === today ? 'today' : '', week(dn) === wk ? 'wk' : ''].filter(Boolean).join(' ')
2437
+ const long = shortDay(day)
2438
+ if (d) {
2439
+ count++
2440
+ const whose = d.shared ? '' : ', ' + d.machine + '’s own digest'
2441
+ 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>'
2442
+ } else h += '<span class="' + cls + '" data-day="' + day + '"><span class="n">' + dn + '</span></span>'
2443
+ }
2444
+ h += '</div>'
2445
+ if (!count) h += '<div class="cal-none">' + (days.length ? 'No digests in ' + esc(first.toLocaleDateString('en-US', { month: 'long' })) + '.' : 'None yet') + '</div>'
2446
+ return h
2447
+ }
2448
+
2449
+ // Month or fold changed: patch the calendar in place, so the arrows (and the
2450
+ // focus on them, with its keyboard ring) stay put.
2451
+ function repaintCalendar() {
2452
+ const nav = document.querySelector('nav.digest-cal')
2453
+ if (!nav) return null
2454
+ const t = document.createElement('div')
2455
+ t.innerHTML = digestCalendar()
2456
+ const now = [...nav.querySelectorAll('.cal-head > *')], fresh = [...t.querySelectorAll('.cal-head > *')]
2457
+ now.forEach((el, i) => {
2458
+ const f = fresh[i]
2459
+ for (const a of ['data-to', 'aria-label', 'aria-expanded']) if (f.hasAttribute(a)) el.setAttribute(a, f.getAttribute(a))
2460
+ el.disabled = !!f.disabled
2461
+ if (!el.classList.contains('cal-nav')) el.innerHTML = f.innerHTML
2462
+ })
2463
+ for (const el of nav.querySelectorAll('.cal-grid, .cal-none')) el.remove()
2464
+ nav.append(...t.querySelectorAll('.cal-grid, .cal-none'))
2465
+ return nav
2466
+ }
2467
+
1729
2468
  // Digest (v0.5.8): one digest a day for the whole folder. The strip has one
1730
2469
  // pill per day: the shared digest, else (days from before 0.5.8) a Mac's own,
1731
2470
  // labelled with that Mac. Space tabs (All / Work / Personal …) sit above the
@@ -1750,16 +2489,16 @@ async function viewDigest(date, machine) {
1750
2489
  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.' : ''
1751
2490
  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>).')
1752
2491
  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>'
1753
- html += '<div class="digest-wrap"><nav class="dates digest-dates" aria-label="Past digests">' + (days.length ? days.map((day) => {
1754
- const d = lead(day)
1755
- const on = pick === day
1756
- const label = d.shared ? '' : d.machine
1757
- 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>'
1758
- }).join('') : '<span class="nocp">None yet</span>') + '</nav>'
2492
+ // 0.9.8: a month calendar, not a long list: days with a digest are links
2493
+ if (digestUi.date !== pick || !digestUi.month) { digestUi.month = monthOf(pick || localDay()); digestUi.calOpen = false }
2494
+ digestUi.cal = { days: days.map((day) => ({ day, d: lead(day) })), pick }
2495
+ html += '<div class="digest-wrap"><nav class="digest-cal" aria-label="Past digests">' + digestCalendar() + '</nav>'
1759
2496
  let spaces = []
1760
2497
  if (pick) {
1761
2498
  try {
1762
- const d = await api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : ''))
2499
+ // 0.9.8: the sessions too, for Start here's Go / Resume
2500
+ 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)])
2501
+ if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
1763
2502
  const fm = d.meta || {}
1764
2503
  const madeAt = fm.generated_at
1765
2504
  spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
@@ -1769,10 +2508,14 @@ async function viewDigest(date, machine) {
1769
2508
  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>' : ''
1770
2509
  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>' : ''
1771
2510
  html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
1772
- '<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces }) + '</article>'
2511
+ '<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>'
1773
2512
  } catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
1774
2513
  } 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>'
1775
2514
  $app.innerHTML = html + '</div>'
2515
+ // 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
2516
+ const art = $app.querySelector('article.md')
2517
+ const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
2518
+ if (after) after.after(...['.digest-hint', '#digestSpaces', '#digestRepos'].map((q) => art.querySelector(q)).filter(Boolean))
1776
2519
  const params = (state.route && state.route.params) || new URLSearchParams()
1777
2520
  state.digestRepos = readFilter(params, window.localStorage).repos
1778
2521
  state.digestSpaces = spaces
@@ -1794,7 +2537,7 @@ function paintDigestFilter() {
1794
2537
  const spaces = state.digestSpaces || []
1795
2538
  const sp = state.digestSpace
1796
2539
  const secs = [...document.querySelectorAll('.md section')]
1797
- for (const sec of secs) sec.hidden = !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
2540
+ for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
1798
2541
  const tabsBox = document.getElementById('digestSpaces')
1799
2542
  if (tabsBox) {
1800
2543
  const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
@@ -1812,8 +2555,19 @@ function paintDigestFilter() {
1812
2555
  const any = [...ul.children].some((li) => !li.hidden)
1813
2556
  ul.hidden = !any
1814
2557
  if (h && (h.tagName === 'H2' || h.tagName === 'H3')) h.hidden = !any
2558
+ const fin = ul.closest('details.fin')
2559
+ if (fin) fin.hidden = !any
1815
2560
  }
1816
2561
  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
2562
+ // 0.9.8: a Start here step follows its session's line: hidden with it (space or repo)
2563
+ const startSec = document.querySelector('.md section.start-sec')
2564
+ if (startSec) {
2565
+ for (const li of startSec.querySelectorAll('li[data-sid]')) {
2566
+ const twin = [...document.querySelectorAll('.md li[data-repo][data-sid]')].find((x) => x.dataset.sid === li.dataset.sid)
2567
+ li.hidden = !!twin && (twin.hidden || !!(twin.closest('section') && twin.closest('section').hidden))
2568
+ }
2569
+ startSec.hidden = ![...startSec.querySelectorAll('ol.start > li')].some((li) => !li.hidden)
2570
+ }
1817
2571
  if (counts.size < 2) { box.innerHTML = ''; return }
1818
2572
  const on = (r) => sel.some((tok) => repoMatches({ repoKey: r, repoLabel: r }, tok))
1819
2573
  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>' +
@@ -2118,11 +2872,13 @@ async function tryLength() {
2118
2872
  const s = list[0]
2119
2873
  if (!s) { toast('No session yet to try it on'); return }
2120
2874
  box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
2875
+ // the page can be redrawn while the summarizer writes (Save re-renders Settings): put the result in the box on screen then
2876
+ const now = () => document.getElementById('f-length-example') || box
2121
2877
  try {
2122
2878
  const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
2123
- 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>' +
2879
+ 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>' +
2124
2880
  cpBlock(cp, { showWhen: false, md: true }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
2125
- } catch (e) { box.innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
2881
+ } catch (e) { now().innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
2126
2882
  }
2127
2883
 
2128
2884
  // Am I the iPhone home-screen app (or another installed app window)?
@@ -2553,6 +3309,7 @@ async function viewSettings() {
2553
3309
  general: '<div class="section">Needs you</div>' +
2554
3310
  '<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a notification when a prompt or question has waited ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
2555
3311
  '<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
3312
+ '<div class="full push-slot" data-push-slot hidden></div>' +
2556
3313
  '<div class="section">Appearance</div>' +
2557
3314
  '<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>' +
2558
3315
  advanced('general', '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
@@ -2585,7 +3342,7 @@ async function viewSettings() {
2585
3342
  updates: '<div class="section">About</div>' +
2586
3343
  '<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>' +
2587
3344
  '<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
2588
- '<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a></div>' +
3345
+ '<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a> · <a href="#/tour">Take the tour</a></div>' +
2589
3346
  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>' +
2590
3347
  '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
2591
3348
  '<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
@@ -3284,7 +4041,7 @@ function paintListsCount(lists) {
3284
4041
  // The session page's Lists section: saved lists, the hint, and "Find lists in this session".
3285
4042
  function sessionListsSection(s) {
3286
4043
  const f = listUi.found[s.id]
3287
- 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>'
4044
+ 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"') : '')
3288
4045
  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>'
3289
4046
  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>'
3290
4047
  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>' +
@@ -3315,6 +4072,7 @@ function parseRoute() {
3315
4072
  async function render() {
3316
4073
  const r = (state.route = parseRoute())
3317
4074
  paintNav(r)
4075
+ tabsOnRoute(r)
3318
4076
  try {
3319
4077
  if (r.name === 's' && r.arg) await viewSession(r.arg)
3320
4078
  else if (r.name === 'waiting') await viewWaiting()
@@ -3328,7 +4086,9 @@ async function render() {
3328
4086
  else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
3329
4087
  else if (r.name === 'search') await viewSearch(r.q || '')
3330
4088
  else if (r.name === 'setup') await viewSetup()
4089
+ else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
3331
4090
  else await viewBoard()
4091
+ tabsAfterRender()
3332
4092
  } catch (e) {
3333
4093
  if (e.code === 'login_required') return showLogin()
3334
4094
  $app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
@@ -3339,12 +4099,20 @@ async function render() {
3339
4099
  // it keeps its own step and fetches its own state.
3340
4100
  async function viewSetup() {
3341
4101
  if ($app.querySelector('.wz')) return
3342
- await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
4102
+ await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/'; autoTour() } })
3343
4103
  }
3344
4104
  // Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
4105
+ // Otherwise the product tour gets its turn.
3345
4106
  async function firstRunSetup() {
3346
- if (location.hash.replace(/^#\/?/, '')) return
3347
- try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) location.replace('#/setup') } catch {}
4107
+ if (!location.hash.replace(/^#\/?/, '')) {
4108
+ try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) return location.replace('#/setup') } catch {}
4109
+ }
4110
+ autoTour()
4111
+ }
4112
+ // The product tour (0.9.8, ui/tour.js): by itself once per Mac, after setup, on
4113
+ // the board; #/tour (Settings → Updates & about, the palette) replays it.
4114
+ async function autoTour() {
4115
+ try { const t = await api('GET', '/v1/tour'); if (shouldAutoTour(t, parseRoute().name)) startTour({ api, auto: true }) } catch {}
3348
4116
  }
3349
4117
 
3350
4118
  // A remote browser (Tailscale) that hasn't signed in yet: the iPhone
@@ -3403,7 +4171,9 @@ async function refreshHealth() {
3403
4171
  if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
3404
4172
  paintMoreDots()
3405
4173
  paintSettingsDots()
4174
+ if (updateBar) updateBar.check(h)
3406
4175
  } catch (e) {
4176
+ if (updateBar) updateBar.check(null) // a failed check changes nothing
3407
4177
  el.className = 'health down'
3408
4178
  el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
3409
4179
  el.title = el.querySelector('.label').textContent
@@ -3436,12 +4206,12 @@ function paintTopDot() {
3436
4206
  }
3437
4207
 
3438
4208
  // ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
3439
- // From 820px up the sections sit in a rail on the left; under it, in a bottom
4209
+ // From 700px up the sections sit in a rail on the left; under it, in a bottom
3440
4210
  // bar with the rest under More. Hover the Needs you item (or long-press it on a
3441
4211
  // phone, or press ↓/→ on it) for Quick look: the count and the five longest
3442
4212
  // waits, with Allow / Deny on permission prompts (the Needs you page's own
3443
4213
  // buttons, so the same checks apply and nothing is answered without a click).
3444
- const railMode = () => window.matchMedia('(min-width: 820px)').matches
4214
+ const railMode = () => window.matchMedia('(min-width: 700px)').matches
3445
4215
  function paintNav(r = state.route || parseRoute()) {
3446
4216
  const key = navKey(r.name)
3447
4217
  document.querySelectorAll('.tabs a[data-route], .topright a[data-route], #moreMenu a[data-route]').forEach((a) => { const on = a.dataset.route === key; a.classList.toggle('on', on); if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current') })
@@ -3470,6 +4240,7 @@ function openMore() {
3470
4240
  if (!m) return
3471
4241
  closeQuickLook(); toggleStatus(false)
3472
4242
  paintMoreDots()
4243
+ paintPush()
3473
4244
  m.hidden = false
3474
4245
  if (b) b.setAttribute('aria-expanded', 'true')
3475
4246
  const first = m.querySelector('a.on') || m.querySelector('a')
@@ -3598,6 +4369,214 @@ function closeQuickLook(focusTab = false) {
3598
4369
  window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
3599
4370
  }
3600
4371
 
4372
+ // ------------------------------------------------------------ tabs for sessions (0.9.8, ui/tabs.js)
4373
+ // The board is the first tab; a session opens as the one preview tab (italic)
4374
+ // that the next click replaces, and stays open once you send a Live message,
4375
+ // type there, choose Keep open or double-click its tab (⌘/Ctrl-click or a
4376
+ // middle-click opens it permanent straight away). Kept per device in
4377
+ // localStorage. By width: under 700px the Open row on the board and ‹ N of M ›
4378
+ // (or a swipe) on a session page; from 700px the strip. A width change (fold,
4379
+ // Split View, rotation) only changes the CSS: the open set, the session and
4380
+ // where you were on the page stay.
4381
+ const tabsUi = { m: parseStored(localStorage.getItem(TABS_KEY)), active: null, route: null, permanent: null, html: { strip: '', row: '', pager: '' }, anchor: null, logs: new WeakMap(), quietUntil: 0, fetchT: 0, fetchedAt: 0, scrolls: {} }
4382
+ window.leftoffTabs = { model: () => JSON.parse(serialize(tabsUi.m)) } // read by test:tabs-ui
4383
+ function setTabs(m) {
4384
+ if (m === tabsUi.m) return
4385
+ tabsUi.m = m
4386
+ try { localStorage.setItem(TABS_KEY, serialize(m)) } catch {}
4387
+ }
4388
+ const tabSession = (id) => state.sessions.find((s) => s.id === id)
4389
+ const tabViews = () => tabsUi.m.tabs.map((t) => tabView(t, tabSession(t.id), { title: sessionTitle, working: (s) => liveWorking(s) }))
4390
+ function tabsOnRoute(r) {
4391
+ const id = r.name === 's' && r.arg ? String(r.arg) : null
4392
+ if (tabsUi.active && tabsUi.active !== id) tabsUi.scrolls[tabsUi.active] = window.scrollY || 0
4393
+ if (id) {
4394
+ const permanent = tabsUi.permanent === id
4395
+ setTabs(openTab(tabsUi.m, id, { permanent, hash: keepHash(location.hash), title: (tabSession(id) && sessionTitle(tabSession(id))) || '' }))
4396
+ }
4397
+ tabsUi.permanent = null
4398
+ const changed = tabsUi.active !== id || tabsUi.route !== r.name
4399
+ tabsUi.active = id; tabsUi.route = r.name
4400
+ document.body.dataset.route = r.name
4401
+ paintTabs()
4402
+ if (changed) scrollActiveTab()
4403
+ }
4404
+ function tabsAfterRender() {
4405
+ let m = tabsUi.m
4406
+ for (const t of m.tabs) { const s = tabSession(t.id); if (s) m = remember(m, t.id, { title: sessionTitle(s) }) }
4407
+ setTabs(m)
4408
+ paintTabs()
4409
+ const back = tabsUi.active && tabsUi.restoreScroll === tabsUi.active ? tabsUi.scrolls[tabsUi.active] : null
4410
+ tabsUi.restoreScroll = null
4411
+ if (back != null) window.scrollTo(0, back)
4412
+ noteAnchor()
4413
+ // the other open tabs' pulse and dot: read the sessions again now and then while you're off the board
4414
+ if (tabsUi.route !== 'board' && tabsUi.m.tabs.some((t) => t.id !== tabsUi.active) && Date.now() - tabsUi.fetchedAt > 15000) refreshTabSessions()
4415
+ }
4416
+ async function refreshTabSessions() {
4417
+ tabsUi.fetchedAt = Date.now()
4418
+ try {
4419
+ const { sessions = [], parked = [] } = await api('GET', '/v1/sessions?limit=200')
4420
+ const fresh = new Map(sessions.concat(parked).map((s) => [s.id, s]))
4421
+ state.sessions = state.sessions.map((s) => (s.id !== tabsUi.active && fresh.has(s.id) ? fresh.get(s.id) : s)).concat([...fresh.values()].filter((s) => !state.sessions.some((x) => x.id === s.id)))
4422
+ paintTabs()
4423
+ } catch {}
4424
+ }
4425
+ function paintTabs() {
4426
+ const views = tabViews()
4427
+ const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
4428
+ if (!strip) return
4429
+ const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
4430
+ if (sh !== tabsUi.html.strip) {
4431
+ const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
4432
+ const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
4433
+ strip.innerHTML = sh; tabsUi.html.strip = sh
4434
+ const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
4435
+ 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 }) }
4436
+ }
4437
+ strip.hidden = false
4438
+ const rh = openRowHtml(views, esc)
4439
+ if (rh !== tabsUi.html.row) { const c = row.querySelector('.openrow-chips'), l = c ? c.scrollLeft : 0; row.innerHTML = rh; tabsUi.html.row = rh; const nc = row.querySelector('.openrow-chips'); if (nc) nc.scrollLeft = l }
4440
+ row.hidden = !rh
4441
+ const ph = tabsUi.active ? pagerHtml(tabsUi.m, tabsUi.active, views, esc) : ''
4442
+ if (ph !== tabsUi.html.pager) { pager.innerHTML = ph; tabsUi.html.pager = ph }
4443
+ pager.hidden = !ph
4444
+ }
4445
+ function scrollActiveTab() {
4446
+ const el = document.querySelector('#tabStrip .tab-cell.on'), sc = document.querySelector('#tabStrip .tab-scroll')
4447
+ if (!el || !sc) return
4448
+ const r = el.getBoundingClientRect(), b = sc.getBoundingClientRect()
4449
+ if (r.left < b.left) sc.scrollLeft -= b.left - r.left + 8
4450
+ else if (r.right > b.right) sc.scrollLeft += r.right - b.right + 8
4451
+ }
4452
+ function keepTab(id) { setTabs(promote(tabsUi.m, id)); paintTabs() }
4453
+ function closeTabNow(id) {
4454
+ const { model, next } = closeTab(tabsUi.m, id, tabsUi.active)
4455
+ setTabs(model)
4456
+ delete tabsUi.scrolls[id]
4457
+ if (tabsUi.active === String(id)) {
4458
+ const t = next && model.tabs.find((x) => x.id === next)
4459
+ if (t) tabsUi.restoreScroll = t.id
4460
+ location.hash = t ? tabView(t).href : '#/'
4461
+ } else paintTabs()
4462
+ }
4463
+ function goTab(href) {
4464
+ const id = sessionOfHash(href)
4465
+ if (id) tabsUi.restoreScroll = id
4466
+ if (location.hash === href) render(); else location.hash = href
4467
+ }
4468
+ // ⌘/Ctrl-click or a middle-click on any session link: open it as a permanent tab (here, not a new browser tab)
4469
+ function sessionLinkId(a) { return a && a.getAttribute && sessionOfHash(a.getAttribute('href') || '') }
4470
+ document.addEventListener('click', (e) => {
4471
+ const a = e.target.closest && e.target.closest('a[href^="#/s/"]')
4472
+ if (!a || !(e.metaKey || e.ctrlKey) || e.button !== 0) return
4473
+ const id = sessionLinkId(a); if (!id) return
4474
+ e.preventDefault(); e.stopPropagation()
4475
+ if (indexOf(tabsUi.m, id) >= 0) setTabs(promote(tabsUi.m, id)); else tabsUi.permanent = id
4476
+ if (location.hash === a.getAttribute('href')) render(); else location.hash = a.getAttribute('href')
4477
+ }, true)
4478
+ document.addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest && e.target.closest('a[href^="#/s/"], #tabStrip .tab-cell')) e.preventDefault() }) // no autoscroll
4479
+ document.addEventListener('auxclick', (e) => {
4480
+ if (e.button !== 1 || !e.target.closest) return
4481
+ const cell = e.target.closest('#tabStrip .tab-cell')
4482
+ if (cell) { e.preventDefault(); closeTabNow(cell.dataset.tabId); return } // middle-click a tab: close it
4483
+ const a = e.target.closest('a[href^="#/s/"]')
4484
+ const id = sessionLinkId(a); if (!id) return
4485
+ e.preventDefault()
4486
+ if (indexOf(tabsUi.m, id) >= 0) { setTabs(promote(tabsUi.m, id)); paintTabs() }
4487
+ else { setTabs(openTab(tabsUi.m, id, { permanent: true })); paintTabs() } // opened in the background, the way a middle-click does
4488
+ })
4489
+ document.addEventListener('click', (e) => {
4490
+ if (!e.target.closest) return
4491
+ const close = e.target.closest('[data-tab-close]')
4492
+ if (close) { e.preventDefault(); return closeTabNow(close.dataset.tabClose) }
4493
+ const keep = e.target.closest('[data-tab-keep]')
4494
+ if (keep) { e.preventDefault(); return keepTab(keep.dataset.tabKeep) }
4495
+ const go = e.target.closest('[data-tab-go]')
4496
+ if (go) { e.preventDefault(); return goTab(go.dataset.tabGo) }
4497
+ const all = e.target.closest('#tabAll')
4498
+ if (all) { e.preventDefault(); return openActionMenu(all, allTabsItems(tabViews(), tabsUi.active), { title: 'Open sessions' }) }
4499
+ const t = e.target.closest('#tabStrip a[data-tab], #openRow a[data-tab], #tabPager a[data-tab]')
4500
+ if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
4501
+ })
4502
+ document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
4503
+ // Working in a session keeps its tab: a Live message, typing there, answering Claude there
4504
+ const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
4505
+ for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
4506
+ document.addEventListener('click', (e) => { if (e.target.closest && $app.contains(e.target) && e.target.closest('[data-action="live-send"], .live-box [data-action], .need [data-choice], [data-action="ask-answer"], [data-action="rename"]')) promoteActive() }, true)
4507
+ // Drag a tab to reorder
4508
+ {
4509
+ let dragId = null
4510
+ document.addEventListener('dragstart', (e) => { const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (!c) return; dragId = c.dataset.tabId; c.classList.add('dragging'); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragId) } catch {} })
4511
+ document.addEventListener('dragover', (e) => { if (!dragId) return; const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (!c) return; e.preventDefault(); document.querySelectorAll('#tabStrip .drop-before').forEach((x) => x.classList.remove('drop-before')); c.classList.add('drop-before') })
4512
+ document.addEventListener('drop', (e) => { if (!dragId) return; const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (c) { e.preventDefault(); const to = indexOf(tabsUi.m, c.dataset.tabId); const from = indexOf(tabsUi.m, dragId); setTabs(moveTab(tabsUi.m, dragId, from < to ? to - 1 : to)); paintTabs() } })
4513
+ document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('#tabStrip .dragging, #tabStrip .drop-before').forEach((x) => x.classList.remove('dragging', 'drop-before')) })
4514
+ }
4515
+ // Phones: swipe left / right on a session page for the next / previous open session
4516
+ {
4517
+ let sw = null
4518
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
4519
+ const scrollsSideways = (el) => { for (let n = el; n && n !== document.body; n = n.parentElement) { if (n.scrollWidth > n.clientWidth + 2 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true } return false }
4520
+ document.addEventListener('touchstart', (e) => {
4521
+ sw = null
4522
+ if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
4523
+ const t = e.target
4524
+ if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .moremenu') || scrollsSideways(t)) return
4525
+ sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
4526
+ }, { passive: true })
4527
+ document.addEventListener('touchend', (e) => {
4528
+ if (!sw || !e.changedTouches.length) return
4529
+ const p = e.changedTouches[0]
4530
+ const dir = swipeDir(p.clientX - sw.x, p.clientY - sw.y, Date.now() - sw.at)
4531
+ sw = null
4532
+ if (!dir || (window.getSelection && String(window.getSelection()))) return
4533
+ const n = neighbours(tabsUi.m, tabsUi.active)
4534
+ const to = dir === 'next' ? n.next : n.prev
4535
+ if (!to) return
4536
+ const t = tabsUi.m.tabs.find((x) => x.id === to)
4537
+ document.body.classList.remove('swipe-next', 'swipe-prev'); void document.body.offsetWidth
4538
+ document.body.classList.add('swipe-' + dir); setTimeout(() => document.body.classList.remove('swipe-' + dir), 260)
4539
+ goTab(tabView(t).href)
4540
+ }, { passive: true })
4541
+ }
4542
+ // A width change keeps your place: the element at the top of the page stays at
4543
+ // the top, and a Live chat that was at its newest stays there.
4544
+ function anchorY() {
4545
+ let y = 0
4546
+ for (const el of [document.querySelector('.top'), document.getElementById('tabStrip'), document.getElementById('tabPager')]) {
4547
+ if (!el) continue
4548
+ const cs = getComputedStyle(el); if (cs.display === 'none' || !/(fixed|sticky)/.test(cs.position)) continue
4549
+ const r = el.getBoundingClientRect(); if (r.top <= 1 && r.bottom < innerHeight / 2) y = Math.max(y, r.bottom)
4550
+ }
4551
+ return y
4552
+ }
4553
+ function noteAnchor() {
4554
+ if (Date.now() < tabsUi.quietUntil) return
4555
+ const y = anchorY() + 6, m = $app.getBoundingClientRect()
4556
+ if (m.bottom < y) { tabsUi.anchor = null; return }
4557
+ let el = document.elementFromPoint(Math.max(m.left + 24, Math.min(m.left + m.width / 2, innerWidth - 24)), y)
4558
+ if (!el || !$app.contains(el) || el === $app) { const kids = [...$app.querySelectorAll('h1, h2, h3, p, li, .card, .fact, .lv-msg, section, div')]; el = kids.find((k) => { const r = k.getBoundingClientRect(); return r.bottom > y && r.height > 0 }) || null }
4559
+ tabsUi.anchor = el ? { el, top: el.getBoundingClientRect().top, scrollY: window.scrollY } : null
4560
+ }
4561
+ function keepPlace() {
4562
+ const a = tabsUi.anchor
4563
+ tabsUi.quietUntil = Date.now() + 250
4564
+ if (a && a.el.isConnected && a.scrollY > 0) window.scrollBy(0, a.el.getBoundingClientRect().top - a.top)
4565
+ document.querySelectorAll('.live-log').forEach((log) => { const st = tabsUi.logs.get(log); if (st && st.bottom) log.scrollTop = log.scrollHeight })
4566
+ }
4567
+ {
4568
+ let raf = 0
4569
+ window.addEventListener('scroll', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; noteAnchor() }) }, { passive: true })
4570
+ document.addEventListener('scroll', (e) => { const log = e.target; if (log && log.classList && log.classList.contains('live-log') && Date.now() >= tabsUi.quietUntil) tabsUi.logs.set(log, { bottom: log.scrollHeight - log.scrollTop - log.clientHeight < 24 }) }, true)
4571
+ let lay = layoutFor(innerWidth)
4572
+ window.addEventListener('resize', () => {
4573
+ keepPlace()
4574
+ const now = layoutFor(innerWidth)
4575
+ if (now !== lay) { lay = now; document.body.dataset.layout = now; scrollActiveTab() }
4576
+ })
4577
+ document.body.dataset.layout = lay
4578
+ }
4579
+
3601
4580
  // Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
3602
4581
  // past the line; the page reads its data again, the same as a live update (no
3603
4582
  // full reload unless that fails). Never from a field, the Live box or a menu.
@@ -3606,8 +4585,8 @@ function closeQuickLook(focusTab = false) {
3606
4585
  ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
3607
4586
  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>'
3608
4587
  document.body.appendChild(ptr)
3609
- const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 819.98px)')
3610
- const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
4588
+ const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
4589
+ 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')
3611
4590
  let pull = null, refreshing = false
3612
4591
  const paint = (step) => {
3613
4592
  ptr.hidden = !step || (!step.pulling && !refreshing)
@@ -3657,6 +4636,43 @@ function closeQuickLook(focusTab = false) {
3657
4636
  document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
3658
4637
  }
3659
4638
 
4639
+ // Hide the nav on scroll (phones, 0.9.8): under 700px the top row and the bottom
4640
+ // bar slide away while you scroll down and come back when you scroll up, reach
4641
+ // the top or the very bottom (ui/navhide.js). Only your own scrolling hides
4642
+ // them: a route change, the ‹ › pager or a swipe shows them and holds them until
4643
+ // you next touch, wheel or key; typing, a menu or a sheet keeps them shown. The
4644
+ // page never reflows: the top row stays in the flow and the bar is an overlay
4645
+ // over padding that stays.
4646
+ {
4647
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
4648
+ const top = document.querySelector('.top')
4649
+ let st = { hidden: false, anchor: 0 }, raf = 0, held = true
4650
+ const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
4651
+ 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])')
4652
+ const apply = (hidden) => {
4653
+ if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
4654
+ document.body.classList.toggle('nav-hidden', hidden)
4655
+ }
4656
+ const read = () => {
4657
+ raf = 0
4658
+ const se = document.scrollingElement || document.documentElement
4659
+ const y = window.scrollY || se.scrollTop || 0
4660
+ const next = navHideStep(st, { y, max: se.scrollHeight - window.innerHeight, blocked: held || !narrowQ.matches || typing() || overlay() })
4661
+ if (next.hidden !== st.hidden) apply(next.hidden)
4662
+ st = next
4663
+ }
4664
+ const soon = () => { if (!raf) raf = requestAnimationFrame(read) }
4665
+ // no layout read here: this runs on every route change, ahead of the page's own render
4666
+ const showNav = (hold = true) => { if (hold) held = true; const was = st.hidden; st = { hidden: false, anchor: NaN }; if (was) apply(false) }
4667
+ window.addEventListener('scroll', soon, { passive: true })
4668
+ for (const ev of ['touchstart', 'wheel', 'keydown']) window.addEventListener(ev, () => { held = false }, { passive: true, capture: true })
4669
+ window.addEventListener('hashchange', () => showNav(true))
4670
+ document.addEventListener('focusin', (e) => { if (typing() || (top && top.contains(e.target))) showNav(false) })
4671
+ document.addEventListener('click', soon, { passive: true, capture: true }) // a menu or sheet just opened: back they come
4672
+ narrowQ.addEventListener('change', () => showNav(false))
4673
+ window.leftoffNav = { show: () => showNav(true), hidden: () => document.body.classList.contains('nav-hidden') }
4674
+ }
4675
+
3660
4676
  // Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
3661
4677
  const statusOpen = () => { const p = document.getElementById('statusPop'); return p && !p.hidden }
3662
4678
  function syncLine() {
@@ -3790,6 +4806,7 @@ function paletteActions() {
3790
4806
  }
3791
4807
  if (projectsUi.mayEdit) out.push({ action: 'project-new', title: 'New project…', sub: 'a named group of repos', icon: 'plus' })
3792
4808
  out.push({ action: 'gen-digest-go', title: 'Today’s digest', sub: 'open it', icon: 'newspaper', href: '#/digest' })
4809
+ out.push({ action: 'tour', title: 'Take the tour', sub: 'a quick look around the board', icon: 'sparkles', href: '#/tour' })
3793
4810
  return out
3794
4811
  }
3795
4812
  function searchGroups(q, r) {
@@ -3939,11 +4956,27 @@ document.addEventListener('keydown', (e) => {
3939
4956
 
3940
4957
  // A card opens its session's page from anywhere on it, except its buttons,
3941
4958
  // links, fields and selected text (0.4.3: the timeline was too hidden).
4959
+ // 0.9.8: the tap shows at once (a pressed card with a spinner); the page's
4960
+ // shell follows in the same frame (viewSession).
4961
+ function cardOpening(c) {
4962
+ for (const x of document.querySelectorAll('.card.opening')) if (x !== c) x.classList.remove('opening')
4963
+ c.classList.add('opening')
4964
+ setTimeout(() => { if (c.isConnected) c.classList.remove('opening') }, 4000)
4965
+ }
4966
+ document.addEventListener('pointerdown', (ev) => {
4967
+ const c = ev.target.closest && ev.target.closest('.card[data-href]')
4968
+ if (!c || ev.button > 0 || ev.target.closest('button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
4969
+ const a = ev.target.closest('a')
4970
+ if (a && !/^#\/s\//.test(a.getAttribute('href') || '')) return // a repo chip, a PR link: not opening the session
4971
+ cardOpening(c)
4972
+ }, { passive: true })
4973
+ document.addEventListener('pointercancel', () => { for (const x of document.querySelectorAll('.card.opening')) x.classList.remove('opening') }, true)
3942
4974
  document.addEventListener('click', (ev) => {
3943
4975
  const c = ev.target.closest('.card[data-href]')
3944
4976
  if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
3945
4977
  if (String(window.getSelection && window.getSelection()).trim()) return
3946
4978
  if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
4979
+ cardOpening(c)
3947
4980
  location.hash = c.dataset.href
3948
4981
  })
3949
4982
 
@@ -3990,7 +5023,14 @@ document.addEventListener('click', async (ev) => {
3990
5023
  const id = btn.dataset.id || (card && card.dataset.id)
3991
5024
  const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
3992
5025
  try {
3993
- if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
5026
+ if (action === 'copy-cmd') {
5027
+ const ok = await copy(btn.dataset.cmd)
5028
+ if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
5029
+ clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
5030
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy Live resume'); btn.dataset.tip = 'Copy' }, 1500)
5031
+ }
5032
+ return
5033
+ }
3994
5034
  // PRs (0.6.9)
3995
5035
  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 }
3996
5036
  // 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
@@ -4093,6 +5133,7 @@ document.addEventListener('click', async (ev) => {
4093
5133
  if (action === 'list-showall') { listUi.showAll = !listUi.showAll; return render() }
4094
5134
  if (action === 'list-help') { listUi.help = !listUi.help; return render() }
4095
5135
  if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
5136
+ if (action === 'list-help-inline') { listUi.sHelp = !listUi.sHelp; render(); return }
4096
5137
  if (action === 'remote-link' || action === 'remote-code') {
4097
5138
  const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
4098
5139
  if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
@@ -4280,6 +5321,19 @@ document.addEventListener('click', async (ev) => {
4280
5321
  saveSpace(state.digestSpace)
4281
5322
  return paintDigestFilter()
4282
5323
  }
5324
+ if (action === 'digest-cal-fold') {
5325
+ digestUi.calOpen = !digestUi.calOpen
5326
+ repaintCalendar()
5327
+ return
5328
+ }
5329
+ if (action === 'digest-month') {
5330
+ const back = btn.dataset.to < (digestUi.month || '')
5331
+ digestUi.month = btn.dataset.to
5332
+ digestUi.calOpen = true // another month: show all of it
5333
+ const nav = repaintCalendar()
5334
+ 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
5335
+ return
5336
+ }
4283
5337
  if (action === 'standup-copy') return standupCopy(btn)
4284
5338
  if (action === 'standup-close') { const b = btn.closest('.standup-out'); if (b) b.remove(); digestUi.open = null; return }
4285
5339
  if (action === 'space-quick' || action === 'space-repo-add' || action === 'space-unset') return spacesAction(action, btn)
@@ -4305,7 +5359,13 @@ document.addEventListener('click', async (ev) => {
4305
5359
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
4306
5360
  if (action === 'reply-send') return replyClick(btn)
4307
5361
  if (action === 'live-send') return liveSendClick(btn)
5362
+ if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
5363
+ if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
5364
+ if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
5365
+ if (action === 'att-open') return openAttachment(btn)
5366
+ if (action === 'att-chip-open') return openChip(btn.dataset.lid)
4308
5367
  if (action === 'live-jump') return liveJumpClick(btn)
5368
+ if (action === 'live-earlier') return liveEarlierClick(btn)
4309
5369
  if (action === 'quick-reply') return quickReplyClick(btn)
4310
5370
  if (action === 'need-more') return needMoreClick(btn)
4311
5371
  if (action === 'need-dismiss') return needDismissClick(btn)
@@ -4791,16 +5851,21 @@ let stream = null
4791
5851
  function connect() {
4792
5852
  if (stream) { try { stream.close() } catch {} }
4793
5853
  const es = stream = new EventSource('/v1/stream')
5854
+ const again = connect.opened // a reconnect: events may have been missed while it was down
5855
+ streamDog.opened()
5856
+ // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
5857
+ es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
4794
5858
  es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
4795
5859
  es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
4796
- 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)
5860
+ 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() })
4797
5861
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
4798
5862
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
4799
5863
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
4800
5864
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
4801
5865
  // port; if the browser gave up on the stream, open a new one.
4802
5866
  es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
4803
- es.onopen = () => refreshHealth()
5867
+ let opens = 0
5868
+ es.onopen = () => { if (stream !== es) return; streamDog.seen(); refreshHealth(); if (again || opens++) refreshView('reconnect'); connect.opened = true }
4804
5869
  }
4805
5870
 
4806
5871
  document.addEventListener('change', async (ev) => {
@@ -4903,9 +5968,51 @@ document.addEventListener('pointerdown', (ev) => {
4903
5968
  if (m && !m.contains(ev.target)) m.open = false
4904
5969
  })
4905
5970
  api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
5971
+ // 0.9.8: "Leftoff updated · Refresh" when the Mac serving this board has newer
5972
+ // files than the ones this page runs. It never reloads by itself; Refresh
5973
+ // carries your drafts over and says first what wouldn't come along.
5974
+ updateBar = mountUpdateBar({
5975
+ icon,
5976
+ health: () => refreshHealth(),
5977
+ risks: () => {
5978
+ let attachments = 0, uploading = 0
5979
+ for (const list of attachUi.by.values()) for (const a of list) { attachments++; if (a.status === 'prep' || a.status === 'up') uploading++ }
5980
+ const f = document.activeElement
5981
+ 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())
5982
+ let unsavedSettings = false
5983
+ try { unsavedSettings = settingsChanged().length > 0 } catch {}
5984
+ return { attachments, uploading, unsavedSettings, typing }
5985
+ },
5986
+ carry: () => {
5987
+ const drafts = { ...state.drafts }
5988
+ for (const t of document.querySelectorAll('textarea[data-draft]')) if (t.value) drafts[t.dataset.draft] = t.value
5989
+ const f = document.activeElement
5990
+ const focus = f && f.dataset && f.dataset.draft ? { key: f.dataset.draft, a: f.selectionStart || 0, b: f.selectionEnd || 0 } : lastDraftFocus
5991
+ return { drafts, focus }
5992
+ },
5993
+ })
5994
+ // The box you were typing in, if you tabbed from it to the bar.
5995
+ let lastDraftFocus = null
5996
+ document.addEventListener('focusin', (ev) => { if (!(ev.target.closest && ev.target.closest('#updateBar'))) lastDraftFocus = null })
5997
+ 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 } })
5998
+ // Back from Refresh: the box you were typing in has its text, and (with a
5999
+ // keyboard and mouse) the cursor where it was.
6000
+ function restoreCarriedFocus(tries = 50) {
6001
+ const f = carried && carried.focus
6002
+ if (!f || isTouchUi(window.matchMedia)) return
6003
+ if (document.activeElement && document.activeElement !== document.body) return // you've moved on
6004
+ const t = [...document.querySelectorAll('textarea[data-draft]')].find((x) => x.dataset.draft === f.key)
6005
+ if (!t || t.disabled) { if (tries > 0) setTimeout(() => restoreCarriedFocus(tries - 1), 100); return } // the session page draws after its data
6006
+ t.focus({ preventScroll: true })
6007
+ try { const n = t.value.length; t.setSelectionRange(Math.min(f.a, n), Math.min(f.b, n)) } catch {}
6008
+ }
4906
6009
  refreshHealth().then(render).then(firstRunSetup)
6010
+ restoreCarriedFocus()
4907
6011
  connect()
4908
6012
 
4909
6013
  // The home-screen app's service worker (v0.4.6): it only serves an offline page
4910
6014
  // when the Mac can't be reached; it caches no API data and no board pages.
4911
6015
  if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
6016
+ // 0.9.8: phone notifications. The one per-device button (More on a phone,
6017
+ // Settings → General everywhere); the service worker shows the pushes.
6018
+ mountPush({ api })