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

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,6 +834,7 @@ 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
  }
@@ -849,6 +876,7 @@ function boardSearch(q, closedHits, n) {
849
876
  // A repaint from a live update (or the 30 s refresh) while you type in the
850
877
  // search box keeps the box focused, with what you typed and the caret.
851
878
  function paintBoardKeepingSearch(h) {
879
+ if (parseRoute().name === 's') return // 0.9.8: a slow board answer never paints over the session you opened
852
880
  const old = document.getElementById('boardQ')
853
881
  const keep = old && document.activeElement === old ? { v: old.value, pos: old.selectionStart } : null
854
882
  $app.innerHTML = h
@@ -928,10 +956,10 @@ function resumeButton(s, viewer = {}) {
928
956
  const busy = closedUi.busy.has(s.id)
929
957
  if (s.isLocal) {
930
958
  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>'
959
+ 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
960
  }
933
961
  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>'
962
+ 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
963
  }
936
964
  function closedCard(s, viewer) {
937
965
  const href = '#/s/' + encodeURIComponent(s.id)
@@ -1248,8 +1276,8 @@ async function viewAgentSession(s) {
1248
1276
  const name = esc(a.display || a.name)
1249
1277
  const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
1250
1278
  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>'
1279
+ 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>' +
1280
+ '<div class="actions s-actions">' + agentReplyButton(a) + '</div></div>'
1253
1281
  // v0.5.6: agent.url only links the name; the reply affordance and its
1254
1282
  // words come from the feed (agent.reply, agent.replyHint), or nothing.
1255
1283
  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 +1298,133 @@ async function viewAgentSession(s) {
1270
1298
  $app.innerHTML = banners() + html
1271
1299
  }
1272
1300
 
1301
+ // 0.9.8: another Mac's session whose Mac is offline says so plainly, once, near the top
1302
+ function ownerOffline(s) {
1303
+ if (!s || s.isLocal !== false || s.agent || !s.machine) return false
1304
+ const st = window.leftoffRelay && state.relayStatus
1305
+ const x = st && (st.macs || []).find((y) => y.machine === s.machine)
1306
+ if (x) return !x.online
1307
+ return !!(s.peer && s.peer.code === 'unreachable')
1308
+ }
1309
+ function ownerOfflineNote(s) {
1310
+ if (!ownerOffline(s)) return ''
1311
+ const m = esc(s.machine), sn = staleNote(s.machine)
1312
+ 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>'
1313
+ }
1314
+ // 0.9.8: opening a session is instant. The tap paints the page's shell at
1315
+ // once (title and pills from the card we already have, or the whole page from
1316
+ // the last visit), then every fetch runs in parallel (the session, its Live
1317
+ // thread, spaces, projects, lists) instead of one after another; on a phone
1318
+ // through the relay each was a full round trip. A newer navigation wins: a
1319
+ // slow answer for a page you already left never paints over the one you're on.
1320
+ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few sessions you opened
1321
+ const SESSION_CACHE_MAX = 8
1322
+ function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
1323
+ const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
1324
+ function sessionShellHtml(s) {
1325
+ const head = s
1326
+ ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + statusPill(s) + liveBadge(s) + '</div>'
1327
+ : '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
1328
+ 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>' +
1329
+ '<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>' +
1330
+ '<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>'
1331
+ }
1332
+ // A selector that finds the same control again after a redraw (null: it can't be told apart).
1333
+ function focusKey(el) {
1334
+ if (!el || !el.tagName) return null
1335
+ if (el.id) return '#' + CSS.escape(el.id)
1336
+ const d = el.dataset || {}
1337
+ const keys = ['action', 'lid', 'sid', 'att', 'id', 'tab', 'submitFor'].filter((k) => d[k] != null)
1338
+ if (!keys.length) return null
1339
+ const attr = (k) => '[data-' + k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + '="' + CSS.escape(d[k]) + '"]'
1340
+ return el.tagName.toLowerCase() + keys.map(attr).join('')
1341
+ }
1342
+ function paintSession(s, data) {
1343
+ if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
1344
+ // a redraw (the live poll, a settled reply) keeps the composer focused with its caret where it was
1345
+ const fa = document.activeElement
1346
+ const kf = fa && fa.classList && fa.classList.contains('composer-input') && $app.contains(fa) ? { d: fa.dataset.draft, a: fa.selectionStart, b: fa.selectionEnd } : null
1347
+ // …and any other control that had focus (a chip, a thumbnail, a button: a
1348
+ // preview closed with Esc puts focus back on one, then a redraw replaced it)
1349
+ const kb = !kf && fa && fa !== document.body && $app.contains(fa) ? focusKey(fa) : null
1350
+ liveKeeper.before($app)
1351
+ $app.innerHTML = sessionPageHtml(s, data)
1352
+ liveAfterRender(s.id)
1353
+ if (kf) {
1354
+ const nt = $app.querySelector('.composer-input')
1355
+ 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 {} }
1356
+ } else if (kb && !$app.contains(document.activeElement)) {
1357
+ const hits = $app.querySelectorAll(kb)
1358
+ if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true })
1359
+ }
1360
+ aroundWire(s.id)
1361
+ }
1273
1362
  async function viewSession(id) {
1274
1363
  if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
1275
- const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
1364
+ const showing = $app.querySelector('.s-head[data-sid="' + CSS.escape(id) + '"]') // a re-render of the page you're on: no shell
1365
+ const cached = sessionCache.get(id)
1366
+ const known = (cached && cached.s) || state.sessions.find((x) => x.id === id) || null
1367
+ if (!showing) {
1368
+ if (cached) paintSession(cached.s, cached) // the last visit, at once; fresh data replaces it below
1369
+ else { liveKeeper.before($app); $app.innerHTML = sessionShellHtml(known) }
1370
+ }
1371
+ // on leftoff.dev, another Mac's session loads its data from that Mac: say so before the first call
1372
+ const owner = (x) => { if (x && window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(x.id, x.isLocal === false && !x.agent ? x.machine : null) }
1373
+ owner(known)
1374
+ const pS = api('GET', '/v1/sessions/' + encodeURIComponent(id))
1375
+ const pLive = !known || liveWanted(known) ? fetchLive(id) : null // the thread doesn't wait for the session
1376
+ const pLists = known && known.lists && known.lists.length ? fetchLists(id) : null
1377
+ const pRest = Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1378
+ let s
1379
+ try { s = await pS } catch (e) { if (!onSession(id)) return; throw e }
1276
1380
  state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
1381
+ owner(s)
1382
+ const status = s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null
1383
+ const lists = s.lists && s.lists.length ? (pLists || fetchLists(id)) : null
1384
+ const live = liveWanted(s) ? (pLive || fetchLive(id)) : !isLive(s) && !s.endedAt && s.live !== 'closed' ? liveHow().then(() => null) : null
1385
+ let [[spv], v, sl] = await Promise.all([pRest, live, lists, status])
1386
+ if (!onSession(id)) return // you moved on while this loaded
1387
+ // a Live fetch that failed keeps the thread you had (its bubbles say "Reconnecting…"), not an empty one
1388
+ const prev = sessionCache.get(id)
1389
+ if (v && v.error && prev && prev.live && !prev.live.error) v = { ...prev.live, error: null }
1390
+ const data = { spv, live: v, lists: sl }
1391
+ if (v && !v.error) cacheSession(id, { s, ...data, live: v })
1392
+ paintSession(s, data)
1393
+ }
1394
+ const fetchLists = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists').catch(() => null)
1395
+ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {}) {
1277
1396
  const branch = s.repo && s.repo.branch
1278
- const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1279
1397
  let html = '<a class="back" href="#/">← All sessions</a>'
1280
1398
  // 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
1399
  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>'
1400
+ html += '<div class="pagehead s-head" data-sid="' + esc(s.id) + '"><div class="s-head-main">' + (canRename
1401
+ ? '<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>'
1402
+ : '<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>' +
1403
+ // 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
1404
+ '<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>'
1405
+ html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
1286
1406
  html += '<div class="facts">' +
1287
1407
  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
1408
  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
1409
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1290
1410
  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)
1411
+ if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
1292
1412
  html += parkRow(s)
1293
1413
  html += spacePicker(s, spv, 'session', s.id)
1294
- html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
1414
+ html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
1295
1415
  // 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
1296
1416
  html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
1297
1417
  html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
1298
1418
  // 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
1299
1419
  if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
1300
- if (!isLive(s)) html += await liveSection(s)
1420
+ if (!isLive(s)) html += liveBoxHtml(s, liveV)
1301
1421
  if (s.statusChanges && s.statusChanges.length) {
1302
1422
  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
1423
  }
1304
1424
  // Lists (v0.5.1): its saved lists in full, the hint, and Find lists in this session
1305
1425
  if (s.lists && s.lists.length) {
1306
- const sl = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists').catch(() => null)
1307
1426
  state.listFull = {}
1308
- for (const l of (sl && sl.lists) || []) state.listFull[l.ref] = l
1427
+ for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l
1309
1428
  }
1310
1429
  state.listsMayEdit = state.mayAnswer !== false
1311
1430
  html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
@@ -1323,10 +1442,7 @@ async function viewSession(id) {
1323
1442
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
1324
1443
  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
1444
  html += answeredList(s)
1326
- liveKeeper.before($app)
1327
- $app.innerHTML = html
1328
- liveAfterRender(s.id)
1329
- aroundWire(s.id)
1445
+ return html
1330
1446
  }
1331
1447
  // ------------------------------------------------------------ Live link (0.9.8 beta)
1332
1448
 
@@ -1349,17 +1465,28 @@ async function liveHow() {
1349
1465
  if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
1350
1466
  return state.liveHow
1351
1467
  }
1352
- async function liveSection(s) {
1468
+ // Does this session's page show a Live thread (and so fetch one)?
1469
+ const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
1470
+ // GET the Live thread; what it says is the newest the page knows (0.9.8: the
1471
+ // time of the last good answer decides "Reconnecting…" on a pending bubble).
1472
+ async function fetchLive(id, { timeoutMs } = {}) {
1473
+ const turns = (state.liveTurns && state.liveTurns[id]) || 20 // "Show earlier" adds 20 at a time
1474
+ try {
1475
+ const v = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/live' + (turns > 20 ? '?turns=' + turns : ''), null, timeoutMs ? { timeoutMs } : {})
1476
+ noteLive(id, v)
1477
+ return v
1478
+ } catch (e) { return { messages: [], error: e.message } }
1479
+ }
1480
+ function liveBoxHtml(s, v) {
1353
1481
  if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
1354
1482
  if (!isLive(s)) {
1355
1483
  if (s.endedAt || s.live === 'closed') return ''
1356
- const how = await liveHow()
1484
+ const how = state.liveHow
1357
1485
  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
1486
  '<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>'
1487
+ (how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p>' +
1488
+ (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
1489
  }
1361
- let v = null
1362
- try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
1363
1490
  const msgs = (v && v.messages) || []
1364
1491
  const may = state.mayAnswer !== false
1365
1492
  const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
@@ -1368,17 +1495,343 @@ async function liveSection(s) {
1368
1495
  if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1369
1496
  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
1497
  '<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>' +
1498
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
1372
1499
  '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
1373
1500
  '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1374
1501
  (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>' +
1502
+ composerHtml(s, draft, may) +
1377
1503
  (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
1504
  }
1505
+ // The Live composer: one rounded box, a textarea that starts at one line and
1506
+ // grows, and a round ↑ in its corner (dim until there's something to send).
1507
+ // No interrupt reaches Claude Code from the board, so while it works the
1508
+ // button still sends: the message waits for its next turn, as before.
1509
+ 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>'
1510
+ 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>'
1511
+ 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>'
1512
+ function composerHtml(s, draft, may) {
1513
+ const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
1514
+ const atts = attachList(s.id)
1515
+ const off = !may || (isBlank(draft) && !atts.length)
1516
+ return '<div class="composer live-row' + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '" data-sid="' + esc(s.id) + '">' +
1517
+ '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
1518
+ '<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>' +
1519
+ // the phone's own picker (Photo Library, Take Photo, Choose File): any file
1520
+ '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
1521
+ '<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>' +
1522
+ '<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>'
1523
+ }
1524
+
1525
+ // ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
1526
+ // Each file uploads to the Mac that runs the session as soon as you attach it;
1527
+ // the chips live here (not in the DOM), so a redraw of the page keeps them.
1528
+ const attachUi = { by: new Map(), urls: new Map(), blobs: new Map(), loading: new Map(), seq: 0, running: 0, queue: [] }
1529
+ const attachList = (sid) => attachUi.by.get(sid) || []
1530
+ function chipHtml(a) {
1531
+ const pct = a.total ? Math.min(100, Math.round((a.sent / a.total) * 100)) : 0
1532
+ const status = a.status === 'failed' ? 'Didn’t upload' : a.status === 'done' ? fmtSize(a.size) : a.status === 'prep' ? 'Preparing…' : fmtSize(a.size) + ' · ' + pct + '%'
1533
+ return '<div class="att-chip ' + esc(a.status) + '" data-lid="' + esc(a.lid) + '"' + (a.error ? ' title="' + esc(a.error) + '"' : '') + '>' +
1534
+ '<button type="button" class="att-open" data-action="att-chip-open" data-lid="' + esc(a.lid) + '" aria-label="Preview ' + esc(a.name) + '">' +
1535
+ '<span class="att-thumb">' + (a.preview ? '<img src="' + esc(a.preview) + '" alt="">' : FILE_IC) + '</span>' +
1536
+ '<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>' +
1537
+ (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>' : '') +
1538
+ '<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>' +
1539
+ (a.status === 'up' || a.status === 'prep' ? '<span class="att-bar" aria-hidden="true"><span data-pct="' + pct + '"></span></span>' : '') + '</div>'
1540
+ }
1541
+ // The board's CSP takes no inline styles: a progress bar's width is set here.
1542
+ function barWidths(root) { for (const b of root.querySelectorAll('.att-bar > span[data-pct]')) b.style.width = b.dataset.pct + '%' }
1543
+ // Redraw one session's chips in place (and the ↑'s state).
1544
+ function paintChips(sid) {
1545
+ const box = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"]')
1546
+ if (!box) return
1547
+ const atts = attachList(sid)
1548
+ const wrap = box.querySelector('.att-chips')
1549
+ const fa = wrap.contains(document.activeElement) ? focusKey(document.activeElement) : null // a progress tick keeps the chip you're on focused
1550
+ wrap.innerHTML = atts.map(chipHtml).join('')
1551
+ if (fa) { const t = wrap.querySelector(fa); if (t) t.focus({ preventScroll: true }) }
1552
+ barWidths(wrap)
1553
+ wrap.hidden = !atts.length
1554
+ box.classList.toggle('has-atts', !!atts.length)
1555
+ const ta = box.querySelector('.composer-input')
1556
+ if (ta) syncComposer(ta)
1557
+ }
1558
+ function addFiles(sid, files, { pasted = false } = {}) {
1559
+ const list = Array.from(files || []).filter(Boolean)
1560
+ if (!sid || !list.length) return
1561
+ const have = attachList(sid)
1562
+ const plan = attachPlan(list, have.length)
1563
+ if (plan.skipped.length) toast('Not attached: ' + skippedText(plan.skipped), 4200)
1564
+ const add = plan.take.map((f) => {
1565
+ const file = pasted && /^image\.\w+$|^$/.test(f.name || '') ? new File([f], pastedName(f.type), { type: f.type, lastModified: Date.now() }) : f
1566
+ 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 }
1567
+ })
1568
+ if (!add.length) return
1569
+ attachUi.by.set(sid, [...have, ...add])
1570
+ paintChips(sid)
1571
+ try { promoteActive() } catch {}
1572
+ for (const a of add) startUpload(a)
1573
+ }
1574
+ function startUpload(a) {
1575
+ a.status = 'prep'; a.error = null; a.sent = 0
1576
+ attachUi.queue.push(a); pumpUploads()
1577
+ }
1578
+ // Three at a time: a phone on a slow line shouldn't open ten uploads at once.
1579
+ function pumpUploads() {
1580
+ while (attachUi.running < 3 && attachUi.queue.length) {
1581
+ const a = attachUi.queue.shift()
1582
+ if (!attachList(a.sid).includes(a)) continue
1583
+ attachUi.running++
1584
+ runUpload(a).finally(() => { attachUi.running--; pumpUploads() })
1585
+ }
1586
+ }
1587
+ async function runUpload(a) {
1588
+ const gone = () => !attachList(a.sid).includes(a)
1589
+ try {
1590
+ if (!a.prepared) {
1591
+ const p = await prepareFile(a.file)
1592
+ a.prepared = p.file; a.thumb = p.thumb
1593
+ a.name = p.file.name || a.name; a.size = p.file.size; a.total = p.file.size; a.type = p.file.type || a.type
1594
+ if (attachKind(p.file) === 'image' && !a.preview && p.thumb) a.preview = 'data:image/jpeg;base64,' + p.thumb
1595
+ // a PDF's first page, for its chip and its bubble (a few seconds at most; without it, the file icon)
1596
+ if (attachKind(p.file) === 'pdf' && !a.thumb) {
1597
+ const t = await Promise.race([pdfThumb(p.file), new Promise((r) => setTimeout(() => r(null), 5000))]).catch(() => null)
1598
+ if (t) { a.thumb = t; a.preview = 'data:image/jpeg;base64,' + t }
1599
+ }
1600
+ if (p.note === 'heic') a.error = 'This browser can’t convert HEIC; it goes as is'
1601
+ }
1602
+ if (gone()) return
1603
+ a.status = 'up'; paintChips(a.sid)
1604
+ let last = 0
1605
+ const f = await uploadFile({ api, sessionId: a.sid, file: a.prepared, thumb: a.thumb, isCancelled: gone,
1606
+ onProgress: (sent, total) => { a.sent = sent; a.total = total; const t = Date.now(); if (t - last > 120 || sent >= total) { last = t; paintChips(a.sid) } } })
1607
+ a.id = f.id; a.size = f.size; a.kind = f.kind; a.status = 'done'
1608
+ if (a.preview) attachUi.urls.set(a.sid + '/' + f.id, a.preview) // the bubble shows it without asking the Mac
1609
+ } catch (e) {
1610
+ if (e.code === 'removed' || gone()) return
1611
+ a.status = 'failed'; a.error = e.message || 'Upload failed'
1612
+ }
1613
+ paintChips(a.sid)
1614
+ attachWaiters()
1615
+ }
1616
+ let attachWaitList = []
1617
+ function attachWaiters() { const w = attachWaitList; attachWaitList = []; for (const f of w) f() }
1618
+ // Wait until nothing of this session's is still uploading.
1619
+ async function uploadsSettled(sid) {
1620
+ while (attachList(sid).some((a) => a.status === 'prep' || a.status === 'up')) await new Promise((r) => { attachWaitList.push(r); setTimeout(r, 1000) })
1621
+ }
1622
+ function removeAttachment(lid) {
1623
+ for (const [sid, list] of attachUi.by) {
1624
+ const a = list.find((x) => x.lid === lid)
1625
+ if (!a) continue
1626
+ attachUi.by.set(sid, list.filter((x) => x !== a))
1627
+ paintChips(sid)
1628
+ const ta = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"] .composer-input')
1629
+ if (ta && !isTouchUi(window.matchMedia)) ta.focus({ preventScroll: true })
1630
+ return
1631
+ }
1632
+ }
1633
+ function retryAttachment(lid) {
1634
+ for (const list of attachUi.by.values()) { const a = list.find((x) => x.lid === lid); if (a) { startUpload(a); paintChips(a.sid); return } }
1635
+ }
1636
+ // The composer a drop or a paste is for: the session page's own (enabled).
1637
+ function composerFor(el) {
1638
+ const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
1639
+ return box && !box.classList.contains('off') ? box : null
1640
+ }
1641
+ document.addEventListener('change', (ev) => {
1642
+ const inp = ev.target
1643
+ if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
1644
+ addFiles(inp.dataset.sid, inp.files)
1645
+ inp.value = '' // the same file can be picked again
1646
+ })
1647
+ document.addEventListener('paste', (ev) => {
1648
+ const t = ev.target
1649
+ const inComposer = t && t.classList && t.classList.contains('composer-input')
1650
+ if (!inComposer && t && t.closest && t.closest('input, textarea, [contenteditable]')) return
1651
+ const cd = ev.clipboardData
1652
+ 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) : []
1653
+ if (!files.length) return
1654
+ const box = composerFor(inComposer ? t : null)
1655
+ if (!box) return
1656
+ ev.preventDefault()
1657
+ addFiles(box.dataset.sid, files, { pasted: true })
1658
+ })
1659
+ {
1660
+ // Drag files onto the session page: the box lights up; drop attaches.
1661
+ const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
1662
+ let leaveT = 0
1663
+ const lit = (on) => { const b = document.querySelector('#app .composer[data-sid]'); if (b) b.classList.toggle('att-drop', on) }
1664
+ 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) })
1665
+ document.addEventListener('drop', (e) => {
1666
+ if (!hasFiles(e)) return
1667
+ const box = composerFor(null)
1668
+ e.preventDefault() // never let a stray drop navigate away from the board
1669
+ lit(false)
1670
+ if (box) addFiles(box.dataset.sid, e.dataTransfer.files)
1671
+ })
1672
+ }
1673
+ // A sent message's attachments in its bubble: thumbnails (tap for full size) or file chips.
1674
+ function attBubbleHtml(atts, sid) {
1675
+ return '<div class="lv-atts">' + atts.map((a) => {
1676
+ const s0 = a.sessionId || sid, key = s0 + '/' + a.id
1677
+ const label = esc(a.name) + ' · ' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '')
1678
+ 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 || '') + '"'
1679
+ 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>' }
1680
+ 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
1681
+ 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>'
1682
+ }).join('') + '</div>'
1683
+ }
1684
+ // Thumbnails come from the Mac once, then from memory. data: URLs, because the
1685
+ // board's CSP allows images from itself and data: only (no blob:).
1686
+ 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) })
1687
+ function attData(sid, id, { thumb = false } = {}) {
1688
+ const key = sid + '/' + id + (thumb ? '' : '#full')
1689
+ if (attachUi.urls.has(key)) return Promise.resolve({ url: attachUi.urls.get(key), blob: attachUi.blobs.get(key) || null })
1690
+ if (attachUi.loading.has(key)) return attachUi.loading.get(key)
1691
+ 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))
1692
+ attachUi.loading.set(key, p)
1693
+ return p
1694
+ }
1695
+ function hydrateAttThumbs() {
1696
+ for (const img of document.querySelectorAll('#app img[data-att-thumb]:not([src])')) {
1697
+ const key = img.dataset.attThumb
1698
+ if (attachUi.urls.has(key)) { img.src = attachUi.urls.get(key); continue }
1699
+ const btn = img.closest('.lv-att'); if (!btn || btn.dataset.loading) continue
1700
+ btn.dataset.loading = '1'
1701
+ const thumb = btn.dataset.thumb !== '0'
1702
+ 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 })
1703
+ .catch((e) => {
1704
+ // a PDF sent without a first-page picture (before 0.9.8, or it couldn't be drawn) keeps its icon
1705
+ if (btn.dataset.kind === 'pdf' && e && e.code === 'no_thumb') { for (const i of btn.querySelectorAll('img[data-att-thumb]')) i.remove(); return }
1706
+ btn.classList.add('gone'); btn.title = 'Not on that Mac any more'
1707
+ })
1708
+ }
1709
+ }
1710
+ // An image opens full screen (ui/lightbox.js) with the other pictures of the
1711
+ // same message beside it; the thumbnail shows (blurred) while the full one
1712
+ // comes from the Mac. Text and other files keep the docs viewer.
1713
+ const attSel = (sid, id) => '.lv-att[data-sid="' + CSS.escape(sid) + '"][data-att="' + CSS.escape(id) + '"]'
1714
+ function openImageAttachment(btn) {
1715
+ const group = btn.closest('.lv-atts')
1716
+ const btns = group ? [...group.querySelectorAll('.lv-att[data-kind="image"]')] : [btn]
1717
+ const items = btns.map((b) => {
1718
+ const im = b.querySelector('img'), sid = b.dataset.sid, id = b.dataset.att
1719
+ const full = attachUi.urls.get(sid + '/' + id + '#full')
1720
+ return { name: b.dataset.name || 'image', sizeLabel: b.dataset.size || '', thumb: (im && im.getAttribute('src')) || null, url: full || null,
1721
+ w: im && im.naturalWidth, h: im && im.naturalHeight, load: () => attData(sid, id).then((r) => r.url) }
1722
+ })
1723
+ const sid = btn.dataset.sid, id = btn.dataset.att
1724
+ openLightbox({ items, index: Math.max(0, btns.indexOf(btn)), from: btn.querySelector('img') || btn, refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1725
+ }
1726
+ // A chip in the box before it's sent: the picture from the file you picked
1727
+ // (a data: URL; the CSP takes no blob:), the other pictures in the box beside it.
1728
+ // Any other file: its name and size in the docs viewer (nothing to download yet).
1729
+ function openChip(lid) {
1730
+ let a = null
1731
+ for (const list of attachUi.by.values()) { a = list.find((x) => x.lid === lid); if (a) break }
1732
+ if (!a) return
1733
+ const chip = document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1734
+ const isImg = (x) => x.kind === 'image' && (x.preview || /^image\/(png|jpeg|gif|webp|bmp|svg\+xml|avif)$/i.test((x.prepared || x.file || {}).type || ''))
1735
+ const refocus = () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1736
+ if (a.kind === 'pdf') {
1737
+ const f = a.prepared || a.file
1738
+ openPdfView({ name: a.name, sizeLabel: fmtSize(a.size), download: false, from: chip, refocus, load: async () => ({ bytes: new Uint8Array(await f.arrayBuffer()) }) })
1739
+ return
1740
+ }
1741
+ if (isImg(a)) {
1742
+ const list = attachList(a.sid).filter(isImg)
1743
+ const items = list.map((x) => ({ name: x.name, sizeLabel: fmtSize(x.size), thumb: x.preview || null, download: false,
1744
+ load: () => { if (!x.fullUrl) x.fullUrl = blobToDataUrl(x.prepared || x.file); return x.fullUrl } }))
1745
+ 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) + '"]') })
1746
+ return
1747
+ }
1748
+ const el = docViewerEl()
1749
+ el.querySelector('.dv-title').textContent = a.name
1750
+ el.querySelector('.dv-sub').textContent = ''
1751
+ el.querySelector('.dv-dl').hidden = true
1752
+ docUi.r = null; docUi.name = a.name
1753
+ 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>'
1754
+ el.hidden = false
1755
+ document.documentElement.classList.add('dv-open')
1756
+ el.querySelector('.dv-x') && el.querySelector('.dv-x').focus({ preventScroll: true })
1757
+ }
1758
+ // A PDF opens in its own full-screen preview (ui/pdfview.js): the pages drawn
1759
+ // from the file the Mac sends back (the same chunked read as a picture).
1760
+ function openPdfAttachment(btn) {
1761
+ const sid = btn.dataset.sid, id = btn.dataset.att
1762
+ openPdfView({ name: btn.dataset.name || 'file.pdf', sizeLabel: btn.dataset.size || '', from: btn,
1763
+ load: async () => { const r = await attData(sid, id); return { bytes: new Uint8Array(await r.blob.arrayBuffer()), url: r.url } },
1764
+ refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1765
+ }
1766
+ async function openAttachment(btn) {
1767
+ const sid = btn.dataset.sid, id = btn.dataset.att, name = btn.dataset.name || 'file', kind = btn.dataset.kind
1768
+ if (kind === 'image') return openImageAttachment(btn)
1769
+ if (kind === 'pdf') return openPdfAttachment(btn)
1770
+ const el = docViewerEl()
1771
+ el.querySelector('.dv-title').textContent = name
1772
+ el.querySelector('.dv-sub').textContent = ''
1773
+ el.querySelector('.dv-dl').hidden = true
1774
+ const body = el.querySelector('.dv-body')
1775
+ body.innerHTML = '<div class="empty">Opening…</div>'
1776
+ docUi.r = null; docUi.name = name
1777
+ el.hidden = false
1778
+ document.documentElement.classList.add('dv-open')
1779
+ try {
1780
+ const { url: u, blob } = await attData(sid, id)
1781
+ if (el.hidden) return
1782
+ const dl = '<a class="btn" href="' + esc(u) + '" download="' + esc(name) + '">' + icon('download') + 'Download</a>'
1783
+ if (kind === 'image') body.innerHTML = '<div class="att-full"><img src="' + esc(u) + '" alt="' + esc(name) + '"></div><div class="att-full-actions">' + dl + '</div>'
1784
+ 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>' }
1785
+ else body.innerHTML = '<div class="empty">' + esc(name) + '</div><div class="att-full-actions">' + dl + '</div>'
1786
+ } catch (e) { if (!el.hidden) body.innerHTML = '<div class="empty">Couldn’t open it: ' + esc(e.message) + '</div>' }
1787
+ }
1788
+ // 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.
1789
+ function fitComposer(ta) {
1790
+ if (!ta || !ta.isConnected) return
1791
+ const cs = getComputedStyle(ta)
1792
+ const lh = parseFloat(cs.lineHeight) || 22
1793
+ const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
1794
+ const keep = ta.scrollTop
1795
+ ta.style.height = 'auto'
1796
+ const r = composerHeight({ scrollHeight: ta.scrollHeight, lineHeight: lh, padY })
1797
+ ta.style.height = r.height + 'px'
1798
+ ta.style.overflowY = r.scroll ? 'auto' : 'hidden'
1799
+ if (r.scroll) ta.scrollTop = keep
1800
+ syncComposer(ta)
1801
+ }
1802
+ function syncComposer(ta) {
1803
+ const box = ta && ta.closest('.composer')
1804
+ const btn = box && box.querySelector('.composer-send')
1805
+ if (!btn || btn.classList.contains('sending')) return
1806
+ btn.disabled = ta.disabled || (isBlank(ta.value) && !attachList(box.dataset.sid).length)
1807
+ }
1808
+ document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
1809
+ // Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
1810
+ document.addEventListener('keydown', (ev) => {
1811
+ const ta = ev.target
1812
+ if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
1813
+ if (ev.metaKey || ev.ctrlKey) return // ⌘↵ / Ctrl+↵: the Needs-you handler presses the button
1814
+ if (composerKey(ev, { touch: isTouchUi(window.matchMedia) }) !== 'send') return
1815
+ ev.preventDefault()
1816
+ const cb = ta.closest('.composer')
1817
+ if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
1818
+ const box = ta.closest('.live-box')
1819
+ const btn = box && box.querySelector('[data-submit-for="live"]')
1820
+ if (!btn || btn.disabled) return
1821
+ const last = Number(box.dataset.kbdAt || 0)
1822
+ if (Date.now() - last < 1500) return
1823
+ box.dataset.kbdAt = String(Date.now())
1824
+ btn.click()
1825
+ })
1826
+ // A tap anywhere on the box (its padding) is a tap in the textarea.
1827
+ 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() })
1828
+ // Pressing ↑ keeps the keyboard up (the textarea keeps focus) on a phone.
1829
+ 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
1830
  // After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
1380
1831
  function liveAfterRender(sid) {
1832
+ for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
1381
1833
  const r = liveKeeper.after($app)
1834
+ hydrateAttThumbs(); barWidths($app)
1382
1835
  if (!r) return
1383
1836
  const ol = r.thread.querySelector('.live-log')
1384
1837
  const empty = r.thread.querySelector('.live-empty')
@@ -1386,8 +1839,16 @@ function liveAfterRender(sid) {
1386
1839
  liveJumpButton(r.thread, liveKeeper.unseen)
1387
1840
  if (ol && !ol.dataset.wired) {
1388
1841
  ol.dataset.wired = '1'
1389
- ol.addEventListener('scroll', () => { if (atBottom(ol)) { liveKeeper.seen(); liveJumpButton(r.thread, 0) } }, { passive: true })
1842
+ ol._stick = atBottom(ol)
1843
+ // the button follows the scroll: there whenever you're away from the bottom, gone once you're back
1844
+ ol.addEventListener('scroll', () => { ol._stick = atBottom(ol); liveJumpButton(r.thread, liveKeeper.unseen) }, { passive: true })
1845
+ // at the bottom, the thread stays there while an entry grows (a streamed reply, a code block, late fonts)
1846
+ if (typeof ResizeObserver === 'function') {
1847
+ ol._ro = new ResizeObserver(() => { if (ol._stick && !atBottom(ol, 2)) ol.scrollTop = ol.scrollHeight; liveJumpButton(r.thread, liveKeeper.unseen) })
1848
+ }
1390
1849
  }
1850
+ if (ol && ol._ro) { ol._ro.disconnect(); ol._ro.observe(ol); for (const li of ol.children) ol._ro.observe(li) }
1851
+ if (ol && r.follow) ol._stick = true
1391
1852
  // #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
1392
1853
  const want = parseLiveRoute(location.hash)
1393
1854
  if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
@@ -1400,48 +1861,179 @@ function liveAfterRender(sid) {
1400
1861
  }
1401
1862
  liveTick()
1402
1863
  }
1864
+ // "Jump to latest ↓" shows whenever the thread is scrolled away from its bottom (with the
1865
+ // count of what came in since, when there is any), and hides once you're back at the bottom.
1403
1866
  function liveJumpButton(thread, n) {
1404
1867
  const b = thread && thread.querySelector('.live-jump')
1405
1868
  if (!b) return
1406
- b.hidden = !(n > 0)
1869
+ const ol = thread.querySelector('.live-log')
1870
+ const v = jumpView(ol, n)
1871
+ if (!v.show && n > 0) liveKeeper.seen()
1872
+ // on its way down after a tap: don't flash back up mid-scroll
1873
+ if (v.show && thread.dataset.jumping && Date.now() < Number(thread.dataset.jumping)) return
1874
+ b.hidden = !v.show
1407
1875
  const c = b.querySelector('.live-jump-n')
1408
- if (c) c.textContent = n > 0 ? ' · ' + n + ' new' : ''
1876
+ if (c) c.textContent = v.count
1877
+ b.setAttribute('aria-label', v.label)
1409
1878
  }
1410
1879
  function liveJumpClick(btn) {
1411
1880
  const thread = btn.closest('.live-thread')
1412
1881
  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)
1882
+ const smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
1883
+ if (thread) thread.dataset.jumping = String(Date.now() + (smooth ? 900 : 0))
1884
+ if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
1885
+ liveKeeper.seen()
1886
+ btn.hidden = true
1887
+ const c = btn.querySelector('.live-jump-n'); if (c) c.textContent = ''
1888
+ // land exactly at the bottom even if something grew mid-scroll, then the button follows the scroll again
1889
+ 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)
1890
+ }
1891
+ // "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
1892
+ async function liveEarlierClick(btn) {
1893
+ const box = btn.closest('.live-box')
1894
+ const sid = box && box.dataset.id
1895
+ if (!sid) return
1896
+ state.liveTurns = state.liveTurns || {}
1897
+ state.liveTurns[sid] = ((state.liveTurns[sid]) || 20) + 20
1898
+ btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading…'
1899
+ await render()
1415
1900
  }
1416
1901
  // The elapsed time beside "Claude is working…", every second while something is in flight.
1902
+ // 0.9.8: when what the page shows is older than STALE_MS (the stream died, the
1903
+ // phone slept), a running bubble says "Reconnecting…" instead of a timer.
1417
1904
  let liveTimer = null
1418
1905
  function liveTick() {
1419
1906
  const els = [...document.querySelectorAll('.lv-status[data-since]')]
1907
+ const box = document.querySelector('#live[data-id]')
1908
+ const stale = !!box && isStale(liveUi.okAt[box.dataset.id], Date.now())
1420
1909
  for (const el of els) {
1421
1910
  const since = Number(el.dataset.since) || 0
1422
1911
  const end = Number(el.dataset.final) || 0
1423
1912
  const t = el.querySelector('.lvs-time')
1424
1913
  if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
1914
+ if (!end) {
1915
+ el.classList.toggle('lv-reconnecting', stale)
1916
+ let hint = el.querySelector('.lvs-hint')
1917
+ if (stale && !hint) { hint = document.createElement('span'); hint.className = 'lvs-hint'; hint.textContent = 'Reconnecting…'; (el.querySelector('.lvs-line') || el).append(hint) }
1918
+ if (hint) hint.hidden = !stale
1919
+ }
1425
1920
  }
1426
1921
  const running = els.some((el) => !el.dataset.final)
1427
1922
  if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
1428
1923
  }
1924
+
1925
+ // 0.9.8: Live keeps up after the phone sleeps (ui/live-wake.js). Every Live
1926
+ // fetch notes when it answered and whether a message is still going; while
1927
+ // one is, on a visible page, the thread is fetched again every POLL_MS
1928
+ // whatever the event stream does.
1929
+ const liveUi = { okAt: {}, sig: {}, pending: {}, polling: false }
1930
+ function noteLive(id, v) {
1931
+ if (!v || v.error) return
1932
+ liveUi.okAt[id] = Date.now()
1933
+ liveUi.pending[id] = anyPending(v.messages)
1934
+ }
1935
+ 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 }
1936
+ // Fetch the thread and diff it into the page, even while you type (busy()
1937
+ // holds full redraws back, never the thread). A turn that ended also redraws
1938
+ // the rest of the page (the pill stops pulsing) once you're not busy.
1939
+ async function refreshLive(reason) {
1940
+ const id = liveOnScreen()
1941
+ if (!id || liveUi.polling) return false
1942
+ liveUi.polling = true; liveUi.triedAt = Date.now()
1943
+ let v
1944
+ try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
1945
+ if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
1946
+ const s = state.sessions.find((x) => x.id === id)
1947
+ const sig = liveSignature(v.messages)
1948
+ const before = liveUi.sig[id]
1949
+ liveUi.sig[id] = sig
1950
+ if (s && before !== sig) {
1951
+ const box = document.querySelector('#live')
1952
+ const cur = box && box.querySelector('.live-thread')
1953
+ if (cur) {
1954
+ const tmp = document.createElement('div')
1955
+ tmp.innerHTML = liveBoxHtml(s, v)
1956
+ const fresh = tmp.querySelector('.live-thread')
1957
+ if (fresh) {
1958
+ const next = cur.nextSibling
1959
+ liveKeeper.before(box)
1960
+ box.insertBefore(fresh, next)
1961
+ const r = liveKeeper.after(box)
1962
+ hydrateAttThumbs()
1963
+ if (r) liveJumpButton(r.thread, liveKeeper.unseen)
1964
+ }
1965
+ }
1966
+ const c = sessionCache.get(id); if (c) c.live = v
1967
+ if (!liveUi.pending[id] && !busy()) soon() // settled: the pill, the tab, the card
1968
+ }
1969
+ liveTick()
1970
+ return true
1971
+ }
1972
+ // The safety net, and the stream's watchdog, on one slow timer.
1973
+ const streamDog = createWatchdog()
1974
+ const wakeGate = createWake()
1975
+ setInterval(() => {
1976
+ if (document.visibilityState === 'hidden') return
1977
+ const id = liveOnScreen()
1978
+ 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')
1979
+ if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
1980
+ }, 1000)
1981
+ // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
1982
+ // bfcache restore, the network returns): fetch what's on screen at once, and
1983
+ // open a new stream if the old one missed its pings.
1984
+ function refreshView(reason) {
1985
+ if (liveOnScreen()) refreshLive(reason)
1986
+ if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
1987
+ refreshHealth()
1988
+ }
1989
+ let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
1990
+ function onWake(ev) {
1991
+ if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
1992
+ const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
1993
+ if (!why) return
1994
+ liveTick() // a stale timer turns into "Reconnecting…" before anything else
1995
+ const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
1996
+ const away = awayAt ? Date.now() - awayAt : 0
1997
+ awayAt = 0
1998
+ // 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
1999
+ if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
2000
+ if (!wakeGate.due()) return
2001
+ if (why === 'pageshow-bfcache' || dead) connect()
2002
+ refreshView(why)
2003
+ }
2004
+ for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
2005
+ document.addEventListener('visibilitychange', onWake)
1429
2006
  async function liveSendClick(btn) {
1430
2007
  const box = btn.closest('.live-box')
1431
2008
  const ta = box && box.querySelector('.live-text')
2009
+ const sid = btn.dataset.id
1432
2010
  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…'
2011
+ if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
2012
+ if (btn.disabled || btn.classList.contains('sending')) return
2013
+ const hadFocus = !!(ta && document.activeElement === ta)
2014
+ btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
1436
2015
  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 = ''
2016
+ // 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
2017
+ await uploadsSettled(sid)
2018
+ const atts = attachList(sid)
2019
+ const failed = atts.filter((a) => a.status === 'failed').length
2020
+ 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 }
2021
+ const ids = atts.filter((a) => a.status === 'done' && a.id).map((a) => a.id)
2022
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live', { text, ...(ids.length ? { attachments: ids } : {}) })
2023
+ // what you typed while it went stays; what was sent goes
2024
+ if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
2025
+ if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
1440
2026
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
1441
2027
  const ol = box && box.querySelector('.live-log')
1442
2028
  if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
1443
2029
  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' } }
2030
+ } catch (e) { toast(e.message, 4200) } finally {
2031
+ await render()
2032
+ const nb = document.querySelector('.live-box [data-action="live-send"]')
2033
+ if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
2034
+ const nt = document.querySelector('.live-box .composer-input')
2035
+ if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
2036
+ }
1445
2037
  }
1446
2038
 
1447
2039
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
@@ -1604,6 +2196,11 @@ async function togglePr(url, { retry = false } = {}) {
1604
2196
  const was = prsUi.open.has(url)
1605
2197
  if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
1606
2198
  const repaint = () => {
2199
+ if (state.route && state.route.name === 's') { // 0.9.8: the session page's PRs open in place too
2200
+ const s = state.sessions.find((x) => x.id === state.route.arg), sec = document.getElementById('prs')
2201
+ if (s && sec) sec.innerHTML = sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details })
2202
+ return
2203
+ }
1607
2204
  const d = prsUi.data[prsUi.view]
1608
2205
  const r = prRows(d).find((x) => x.pr.url === url)
1609
2206
  const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
@@ -1617,7 +2214,7 @@ async function togglePr(url, { retry = false } = {}) {
1617
2214
  try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
1618
2215
  catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
1619
2216
  prsUi.details[url] = { ...res, at: Date.now() }
1620
- if (state.route && state.route.name === 'prs') repaint()
2217
+ if (state.route && (state.route.name === 'prs' || state.route.name === 's')) repaint()
1621
2218
  }
1622
2219
  function setPrsFilter(f) {
1623
2220
  const h = prsHash(prsUi.view, f)
@@ -1678,29 +2275,67 @@ async function viewTimeline() {
1678
2275
  $app.innerHTML = html
1679
2276
  }
1680
2277
 
1681
- function renderMd(md, { spaces = [] } = {}) {
2278
+ // 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
2279
+ // links there and Start here gets that session's Go / Resume.
2280
+ const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
2281
+ function briefAction(id, sessions, viewer) {
2282
+ const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
2283
+ const s = sessions && sessions.get(id)
2284
+ if (!s) return open
2285
+ let b = ''
2286
+ // 0.9.8: closed: Resume opens it in a new tab where it can, else (on the Mac) copies the resume command
2287
+ 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>')
2288
+ else { const g = goButton(s); b = g && !/disabled/.test(g) ? g : '' }
2289
+ return b || open
2290
+ }
2291
+
2292
+ function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
1682
2293
  const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
1683
2294
  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
2295
  const out = []
1685
- let inList = false, inSec = false
2296
+ let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
2297
+ const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
2298
+ const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
2299
+ const href = (id) => '#/s/' + encodeURIComponent(id)
1686
2300
  // v0.5.8: each "## <space>" opens a section with its own Copy for standup;
1687
2301
  // other "## " headings (Lists, Top of mind) are whole-folder sections.
2302
+ // 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
1688
2303
  const openSec = (h) => {
1689
2304
  if (inSec) out.push('</section>')
1690
2305
  inSec = true
1691
- if (spaces.includes(h)) {
2306
+ if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
2307
+ else if (spaces.includes(h)) {
1692
2308
  out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
1693
2309
  '<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
2310
  } else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
1695
2311
  }
1696
- for (const line of body.split('\n')) {
2312
+ for (const raw of body.split('\n')) {
2313
+ const mid = raw.match(MD_SID)
2314
+ const id = mid ? mid[1] : null
2315
+ const line = mid ? raw.slice(0, mid.index) : raw
2316
+ // 0.9.8: Start here: "1. <step> — **Title** (repo)"
2317
+ const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
2318
+ if (st) {
2319
+ openList('ol', 'start')
2320
+ const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
2321
+ 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>' +
2322
+ (id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
2323
+ continue
2324
+ }
1697
2325
  if (/^- /.test(line)) {
1698
- if (!inList) { out.push('<ul>'); inList = true }
2326
+ openList('ul')
1699
2327
  // Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
1700
2328
  const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
2329
+ const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
2330
+ // 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
2331
+ // session: the title links to it, the sentence under it, the tags quiet.
2332
+ const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
2333
+ // 0.9.8: an Unfinished one is closed, so it has its Resume beside it
2334
+ 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 }
2335
+ 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
2336
  // v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
1702
2337
  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
2338
+ out.push('<li' + attrs + '>' + li); continue
1704
2339
  }
1705
2340
  if (/^\s{2,}\S/.test(line) && inList) {
1706
2341
  // "Stopped at: … Next: …" is two lines, each with its label.
@@ -1709,13 +2344,17 @@ function renderMd(md, { spaces = [] } = {}) {
1709
2344
  else out.push('<span class="sub">' + inline(t) + '</span>')
1710
2345
  continue
1711
2346
  }
1712
- if (inList) { out.push('</ul>'); inList = false }
2347
+ closeList()
2348
+ if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
1713
2349
  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>') }
2350
+ 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>') } }
2351
+ // 0.9.8: Finished folds away (older digests' "Finished or closed" too)
2352
+ 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
2353
  else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
1716
- else if (line.trim()) out.push('<p>' + inline(line) + '</p>')
2354
+ // 0.9.8: the Yesterday / Today opener, above the first section
2355
+ else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
1717
2356
  }
1718
- if (inList) out.push('</ul>')
2357
+ closeList()
1719
2358
  if (inSec) out.push('</section>')
1720
2359
  return out.join('\n')
1721
2360
  }
@@ -1726,6 +2365,81 @@ function shortDay(iso) {
1726
2365
  return isNaN(d) ? iso : d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '')
1727
2366
  }
1728
2367
 
2368
+ // The past-digests calendar (0.9.8): one month at a time, Sunday first. A day
2369
+ // with a digest is a link (the shared one, else a Mac's own from before one
2370
+ // digest a day); the rest are plain numbers. Opens on the picked digest's
2371
+ // month; next stops at this month, prev at the first month with a digest.
2372
+ const monthOf = (iso) => String(iso).slice(0, 7)
2373
+ const localDay = (t = new Date()) => t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0')
2374
+ function shiftMonth(ym, by) {
2375
+ const [y, m] = ym.split('-').map(Number)
2376
+ const d = new Date(y, m - 1 + by, 1)
2377
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
2378
+ }
2379
+ function digestCalendar() {
2380
+ const { days = [], pick = null } = digestUi.cal || {}
2381
+ const today = localDay()
2382
+ const max = monthOf(today)
2383
+ const min = days.length ? days.map((x) => monthOf(x.day)).sort()[0] : max
2384
+ let ym = digestUi.month || max
2385
+ if (ym > max) ym = max
2386
+ if (ym < min) ym = min
2387
+ digestUi.month = ym
2388
+ const [y, m] = ym.split('-').map(Number)
2389
+ const first = new Date(y, m - 1, 1)
2390
+ const label = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
2391
+ const by = new Map(days.map((x) => [x.day, x.d]))
2392
+ const prev = shiftMonth(ym, -1), next = shiftMonth(ym, 1)
2393
+ const name = (x) => new Date(x + '-01T12:00:00').toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
2394
+ 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>'
2395
+ // phones fold it to one week (the picked day's) until you open the month
2396
+ const open = !!digestUi.calOpen
2397
+ let h = '<div class="cal-head">' + navBtn(prev, prev >= min, -1) + '<span class="cal-month" aria-live="polite">' + esc(label) + '</span>' +
2398
+ '<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>'
2399
+ 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('')
2400
+ const n = new Date(y, m, 0).getDate()
2401
+ const lead0 = first.getDay()
2402
+ const week = (dn) => Math.floor((lead0 + dn - 1) / 7)
2403
+ const inMonth = (x) => x && x.slice(0, 7) === ym
2404
+ const firstHas = days.map((x) => x.day).filter(inMonth).sort()[0]
2405
+ const wk = week(inMonth(pick) ? Number(pick.slice(8)) : inMonth(today) ? Number(today.slice(8)) : firstHas ? Number(firstHas.slice(8)) : 1)
2406
+ for (let i = 0; i < lead0; i++) h += '<span class="cal-day pad' + (wk === 0 ? ' wk' : '') + '" aria-hidden="true"></span>'
2407
+ let count = 0
2408
+ for (let dn = 1; dn <= n; dn++) {
2409
+ const day = ym + '-' + String(dn).padStart(2, '0')
2410
+ const d = by.get(day)
2411
+ const cls = ['cal-day', d ? 'has' : 'none', day === pick ? 'on' : '', day === today ? 'today' : '', week(dn) === wk ? 'wk' : ''].filter(Boolean).join(' ')
2412
+ const long = shortDay(day)
2413
+ if (d) {
2414
+ count++
2415
+ const whose = d.shared ? '' : ', ' + d.machine + '’s own digest'
2416
+ 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>'
2417
+ } else h += '<span class="' + cls + '" data-day="' + day + '"><span class="n">' + dn + '</span></span>'
2418
+ }
2419
+ h += '</div>'
2420
+ if (!count) h += '<div class="cal-none">' + (days.length ? 'No digests in ' + esc(first.toLocaleDateString('en-US', { month: 'long' })) + '.' : 'None yet') + '</div>'
2421
+ return h
2422
+ }
2423
+
2424
+ // Month or fold changed: patch the calendar in place, so the arrows (and the
2425
+ // focus on them, with its keyboard ring) stay put.
2426
+ function repaintCalendar() {
2427
+ const nav = document.querySelector('nav.digest-cal')
2428
+ if (!nav) return null
2429
+ const t = document.createElement('div')
2430
+ t.innerHTML = digestCalendar()
2431
+ const now = [...nav.querySelectorAll('.cal-head > *')], fresh = [...t.querySelectorAll('.cal-head > *')]
2432
+ now.forEach((el, i) => {
2433
+ const f = fresh[i]
2434
+ for (const a of ['data-to', 'aria-label', 'aria-expanded']) if (f.hasAttribute(a)) el.setAttribute(a, f.getAttribute(a))
2435
+ el.disabled = !!f.disabled
2436
+ if (!el.classList.contains('cal-nav')) el.innerHTML = f.innerHTML
2437
+ })
2438
+ for (const el of nav.querySelectorAll('.cal-grid, .cal-none')) el.remove()
2439
+ nav.append(...t.querySelectorAll('.cal-grid, .cal-none'))
2440
+ return nav
2441
+ }
2442
+
1729
2443
  // Digest (v0.5.8): one digest a day for the whole folder. The strip has one
1730
2444
  // pill per day: the shared digest, else (days from before 0.5.8) a Mac's own,
1731
2445
  // labelled with that Mac. Space tabs (All / Work / Personal …) sit above the
@@ -1750,16 +2464,16 @@ async function viewDigest(date, machine) {
1750
2464
  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
2465
  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
2466
  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>'
2467
+ // 0.9.8: a month calendar, not a long list: days with a digest are links
2468
+ if (digestUi.date !== pick || !digestUi.month) { digestUi.month = monthOf(pick || localDay()); digestUi.calOpen = false }
2469
+ digestUi.cal = { days: days.map((day) => ({ day, d: lead(day) })), pick }
2470
+ html += '<div class="digest-wrap"><nav class="digest-cal" aria-label="Past digests">' + digestCalendar() + '</nav>'
1759
2471
  let spaces = []
1760
2472
  if (pick) {
1761
2473
  try {
1762
- const d = await api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : ''))
2474
+ // 0.9.8: the sessions too, for Start here's Go / Resume
2475
+ 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)])
2476
+ if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
1763
2477
  const fm = d.meta || {}
1764
2478
  const madeAt = fm.generated_at
1765
2479
  spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
@@ -1769,10 +2483,14 @@ async function viewDigest(date, machine) {
1769
2483
  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
2484
  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
2485
  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>'
2486
+ '<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
2487
  } catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
1774
2488
  } 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
2489
  $app.innerHTML = html + '</div>'
2490
+ // 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
2491
+ const art = $app.querySelector('article.md')
2492
+ const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
2493
+ if (after) after.after(...['.digest-hint', '#digestSpaces', '#digestRepos'].map((q) => art.querySelector(q)).filter(Boolean))
1776
2494
  const params = (state.route && state.route.params) || new URLSearchParams()
1777
2495
  state.digestRepos = readFilter(params, window.localStorage).repos
1778
2496
  state.digestSpaces = spaces
@@ -1794,7 +2512,7 @@ function paintDigestFilter() {
1794
2512
  const spaces = state.digestSpaces || []
1795
2513
  const sp = state.digestSpace
1796
2514
  const secs = [...document.querySelectorAll('.md section')]
1797
- for (const sec of secs) sec.hidden = !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
2515
+ for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
1798
2516
  const tabsBox = document.getElementById('digestSpaces')
1799
2517
  if (tabsBox) {
1800
2518
  const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
@@ -1812,8 +2530,19 @@ function paintDigestFilter() {
1812
2530
  const any = [...ul.children].some((li) => !li.hidden)
1813
2531
  ul.hidden = !any
1814
2532
  if (h && (h.tagName === 'H2' || h.tagName === 'H3')) h.hidden = !any
2533
+ const fin = ul.closest('details.fin')
2534
+ if (fin) fin.hidden = !any
1815
2535
  }
1816
2536
  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
2537
+ // 0.9.8: a Start here step follows its session's line: hidden with it (space or repo)
2538
+ const startSec = document.querySelector('.md section.start-sec')
2539
+ if (startSec) {
2540
+ for (const li of startSec.querySelectorAll('li[data-sid]')) {
2541
+ const twin = [...document.querySelectorAll('.md li[data-repo][data-sid]')].find((x) => x.dataset.sid === li.dataset.sid)
2542
+ li.hidden = !!twin && (twin.hidden || !!(twin.closest('section') && twin.closest('section').hidden))
2543
+ }
2544
+ startSec.hidden = ![...startSec.querySelectorAll('ol.start > li')].some((li) => !li.hidden)
2545
+ }
1817
2546
  if (counts.size < 2) { box.innerHTML = ''; return }
1818
2547
  const on = (r) => sel.some((tok) => repoMatches({ repoKey: r, repoLabel: r }, tok))
1819
2548
  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 +2847,13 @@ async function tryLength() {
2118
2847
  const s = list[0]
2119
2848
  if (!s) { toast('No session yet to try it on'); return }
2120
2849
  box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
2850
+ // the page can be redrawn while the summarizer writes (Save re-renders Settings): put the result in the box on screen then
2851
+ const now = () => document.getElementById('f-length-example') || box
2121
2852
  try {
2122
2853
  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>' +
2854
+ 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
2855
  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) }
2856
+ } catch (e) { now().innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
2126
2857
  }
2127
2858
 
2128
2859
  // Am I the iPhone home-screen app (or another installed app window)?
@@ -2553,6 +3284,7 @@ async function viewSettings() {
2553
3284
  general: '<div class="section">Needs you</div>' +
2554
3285
  '<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
3286
  '<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
3287
+ '<div class="full push-slot" data-push-slot hidden></div>' +
2556
3288
  '<div class="section">Appearance</div>' +
2557
3289
  '<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
3290
  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 +3317,7 @@ async function viewSettings() {
2585
3317
  updates: '<div class="section">About</div>' +
2586
3318
  '<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
3319
  '<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>' +
3320
+ '<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a> · <a href="#/tour">Take the tour</a></div>' +
2589
3321
  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
3322
  '<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
2591
3323
  '<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
@@ -3284,7 +4016,7 @@ function paintListsCount(lists) {
3284
4016
  // The session page's Lists section: saved lists, the hint, and "Find lists in this session".
3285
4017
  function sessionListsSection(s) {
3286
4018
  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>'
4019
+ 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
4020
  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
4021
  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
4022
  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 +4047,7 @@ function parseRoute() {
3315
4047
  async function render() {
3316
4048
  const r = (state.route = parseRoute())
3317
4049
  paintNav(r)
4050
+ tabsOnRoute(r)
3318
4051
  try {
3319
4052
  if (r.name === 's' && r.arg) await viewSession(r.arg)
3320
4053
  else if (r.name === 'waiting') await viewWaiting()
@@ -3328,7 +4061,9 @@ async function render() {
3328
4061
  else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
3329
4062
  else if (r.name === 'search') await viewSearch(r.q || '')
3330
4063
  else if (r.name === 'setup') await viewSetup()
4064
+ else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
3331
4065
  else await viewBoard()
4066
+ tabsAfterRender()
3332
4067
  } catch (e) {
3333
4068
  if (e.code === 'login_required') return showLogin()
3334
4069
  $app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
@@ -3339,12 +4074,20 @@ async function render() {
3339
4074
  // it keeps its own step and fetches its own state.
3340
4075
  async function viewSetup() {
3341
4076
  if ($app.querySelector('.wz')) return
3342
- await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
4077
+ await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/'; autoTour() } })
3343
4078
  }
3344
4079
  // Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
4080
+ // Otherwise the product tour gets its turn.
3345
4081
  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 {}
4082
+ if (!location.hash.replace(/^#\/?/, '')) {
4083
+ try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) return location.replace('#/setup') } catch {}
4084
+ }
4085
+ autoTour()
4086
+ }
4087
+ // The product tour (0.9.8, ui/tour.js): by itself once per Mac, after setup, on
4088
+ // the board; #/tour (Settings → Updates & about, the palette) replays it.
4089
+ async function autoTour() {
4090
+ try { const t = await api('GET', '/v1/tour'); if (shouldAutoTour(t, parseRoute().name)) startTour({ api, auto: true }) } catch {}
3348
4091
  }
3349
4092
 
3350
4093
  // A remote browser (Tailscale) that hasn't signed in yet: the iPhone
@@ -3403,7 +4146,9 @@ async function refreshHealth() {
3403
4146
  if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
3404
4147
  paintMoreDots()
3405
4148
  paintSettingsDots()
4149
+ if (updateBar) updateBar.check(h)
3406
4150
  } catch (e) {
4151
+ if (updateBar) updateBar.check(null) // a failed check changes nothing
3407
4152
  el.className = 'health down'
3408
4153
  el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
3409
4154
  el.title = el.querySelector('.label').textContent
@@ -3436,12 +4181,12 @@ function paintTopDot() {
3436
4181
  }
3437
4182
 
3438
4183
  // ------------------------------------------------------------ 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
4184
+ // From 700px up the sections sit in a rail on the left; under it, in a bottom
3440
4185
  // bar with the rest under More. Hover the Needs you item (or long-press it on a
3441
4186
  // phone, or press ↓/→ on it) for Quick look: the count and the five longest
3442
4187
  // waits, with Allow / Deny on permission prompts (the Needs you page's own
3443
4188
  // buttons, so the same checks apply and nothing is answered without a click).
3444
- const railMode = () => window.matchMedia('(min-width: 820px)').matches
4189
+ const railMode = () => window.matchMedia('(min-width: 700px)').matches
3445
4190
  function paintNav(r = state.route || parseRoute()) {
3446
4191
  const key = navKey(r.name)
3447
4192
  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 +4215,7 @@ function openMore() {
3470
4215
  if (!m) return
3471
4216
  closeQuickLook(); toggleStatus(false)
3472
4217
  paintMoreDots()
4218
+ paintPush()
3473
4219
  m.hidden = false
3474
4220
  if (b) b.setAttribute('aria-expanded', 'true')
3475
4221
  const first = m.querySelector('a.on') || m.querySelector('a')
@@ -3598,6 +4344,214 @@ function closeQuickLook(focusTab = false) {
3598
4344
  window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
3599
4345
  }
3600
4346
 
4347
+ // ------------------------------------------------------------ tabs for sessions (0.9.8, ui/tabs.js)
4348
+ // The board is the first tab; a session opens as the one preview tab (italic)
4349
+ // that the next click replaces, and stays open once you send a Live message,
4350
+ // type there, choose Keep open or double-click its tab (⌘/Ctrl-click or a
4351
+ // middle-click opens it permanent straight away). Kept per device in
4352
+ // localStorage. By width: under 700px the Open row on the board and ‹ N of M ›
4353
+ // (or a swipe) on a session page; from 700px the strip. A width change (fold,
4354
+ // Split View, rotation) only changes the CSS: the open set, the session and
4355
+ // where you were on the page stay.
4356
+ 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: {} }
4357
+ window.leftoffTabs = { model: () => JSON.parse(serialize(tabsUi.m)) } // read by test:tabs-ui
4358
+ function setTabs(m) {
4359
+ if (m === tabsUi.m) return
4360
+ tabsUi.m = m
4361
+ try { localStorage.setItem(TABS_KEY, serialize(m)) } catch {}
4362
+ }
4363
+ const tabSession = (id) => state.sessions.find((s) => s.id === id)
4364
+ const tabViews = () => tabsUi.m.tabs.map((t) => tabView(t, tabSession(t.id), { title: sessionTitle, working: (s) => liveWorking(s) }))
4365
+ function tabsOnRoute(r) {
4366
+ const id = r.name === 's' && r.arg ? String(r.arg) : null
4367
+ if (tabsUi.active && tabsUi.active !== id) tabsUi.scrolls[tabsUi.active] = window.scrollY || 0
4368
+ if (id) {
4369
+ const permanent = tabsUi.permanent === id
4370
+ setTabs(openTab(tabsUi.m, id, { permanent, hash: keepHash(location.hash), title: (tabSession(id) && sessionTitle(tabSession(id))) || '' }))
4371
+ }
4372
+ tabsUi.permanent = null
4373
+ const changed = tabsUi.active !== id || tabsUi.route !== r.name
4374
+ tabsUi.active = id; tabsUi.route = r.name
4375
+ document.body.dataset.route = r.name
4376
+ paintTabs()
4377
+ if (changed) scrollActiveTab()
4378
+ }
4379
+ function tabsAfterRender() {
4380
+ let m = tabsUi.m
4381
+ for (const t of m.tabs) { const s = tabSession(t.id); if (s) m = remember(m, t.id, { title: sessionTitle(s) }) }
4382
+ setTabs(m)
4383
+ paintTabs()
4384
+ const back = tabsUi.active && tabsUi.restoreScroll === tabsUi.active ? tabsUi.scrolls[tabsUi.active] : null
4385
+ tabsUi.restoreScroll = null
4386
+ if (back != null) window.scrollTo(0, back)
4387
+ noteAnchor()
4388
+ // the other open tabs' pulse and dot: read the sessions again now and then while you're off the board
4389
+ if (tabsUi.route !== 'board' && tabsUi.m.tabs.some((t) => t.id !== tabsUi.active) && Date.now() - tabsUi.fetchedAt > 15000) refreshTabSessions()
4390
+ }
4391
+ async function refreshTabSessions() {
4392
+ tabsUi.fetchedAt = Date.now()
4393
+ try {
4394
+ const { sessions = [], parked = [] } = await api('GET', '/v1/sessions?limit=200')
4395
+ const fresh = new Map(sessions.concat(parked).map((s) => [s.id, s]))
4396
+ 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)))
4397
+ paintTabs()
4398
+ } catch {}
4399
+ }
4400
+ function paintTabs() {
4401
+ const views = tabViews()
4402
+ const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
4403
+ if (!strip) return
4404
+ const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
4405
+ if (sh !== tabsUi.html.strip) {
4406
+ const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
4407
+ const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
4408
+ strip.innerHTML = sh; tabsUi.html.strip = sh
4409
+ const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
4410
+ 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 }) }
4411
+ }
4412
+ strip.hidden = false
4413
+ const rh = openRowHtml(views, esc)
4414
+ 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 }
4415
+ row.hidden = !rh
4416
+ const ph = tabsUi.active ? pagerHtml(tabsUi.m, tabsUi.active, views, esc) : ''
4417
+ if (ph !== tabsUi.html.pager) { pager.innerHTML = ph; tabsUi.html.pager = ph }
4418
+ pager.hidden = !ph
4419
+ }
4420
+ function scrollActiveTab() {
4421
+ const el = document.querySelector('#tabStrip .tab-cell.on'), sc = document.querySelector('#tabStrip .tab-scroll')
4422
+ if (!el || !sc) return
4423
+ const r = el.getBoundingClientRect(), b = sc.getBoundingClientRect()
4424
+ if (r.left < b.left) sc.scrollLeft -= b.left - r.left + 8
4425
+ else if (r.right > b.right) sc.scrollLeft += r.right - b.right + 8
4426
+ }
4427
+ function keepTab(id) { setTabs(promote(tabsUi.m, id)); paintTabs() }
4428
+ function closeTabNow(id) {
4429
+ const { model, next } = closeTab(tabsUi.m, id, tabsUi.active)
4430
+ setTabs(model)
4431
+ delete tabsUi.scrolls[id]
4432
+ if (tabsUi.active === String(id)) {
4433
+ const t = next && model.tabs.find((x) => x.id === next)
4434
+ if (t) tabsUi.restoreScroll = t.id
4435
+ location.hash = t ? tabView(t).href : '#/'
4436
+ } else paintTabs()
4437
+ }
4438
+ function goTab(href) {
4439
+ const id = sessionOfHash(href)
4440
+ if (id) tabsUi.restoreScroll = id
4441
+ if (location.hash === href) render(); else location.hash = href
4442
+ }
4443
+ // ⌘/Ctrl-click or a middle-click on any session link: open it as a permanent tab (here, not a new browser tab)
4444
+ function sessionLinkId(a) { return a && a.getAttribute && sessionOfHash(a.getAttribute('href') || '') }
4445
+ document.addEventListener('click', (e) => {
4446
+ const a = e.target.closest && e.target.closest('a[href^="#/s/"]')
4447
+ if (!a || !(e.metaKey || e.ctrlKey) || e.button !== 0) return
4448
+ const id = sessionLinkId(a); if (!id) return
4449
+ e.preventDefault(); e.stopPropagation()
4450
+ if (indexOf(tabsUi.m, id) >= 0) setTabs(promote(tabsUi.m, id)); else tabsUi.permanent = id
4451
+ if (location.hash === a.getAttribute('href')) render(); else location.hash = a.getAttribute('href')
4452
+ }, true)
4453
+ document.addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest && e.target.closest('a[href^="#/s/"], #tabStrip .tab-cell')) e.preventDefault() }) // no autoscroll
4454
+ document.addEventListener('auxclick', (e) => {
4455
+ if (e.button !== 1 || !e.target.closest) return
4456
+ const cell = e.target.closest('#tabStrip .tab-cell')
4457
+ if (cell) { e.preventDefault(); closeTabNow(cell.dataset.tabId); return } // middle-click a tab: close it
4458
+ const a = e.target.closest('a[href^="#/s/"]')
4459
+ const id = sessionLinkId(a); if (!id) return
4460
+ e.preventDefault()
4461
+ if (indexOf(tabsUi.m, id) >= 0) { setTabs(promote(tabsUi.m, id)); paintTabs() }
4462
+ else { setTabs(openTab(tabsUi.m, id, { permanent: true })); paintTabs() } // opened in the background, the way a middle-click does
4463
+ })
4464
+ document.addEventListener('click', (e) => {
4465
+ if (!e.target.closest) return
4466
+ const close = e.target.closest('[data-tab-close]')
4467
+ if (close) { e.preventDefault(); return closeTabNow(close.dataset.tabClose) }
4468
+ const keep = e.target.closest('[data-tab-keep]')
4469
+ if (keep) { e.preventDefault(); return keepTab(keep.dataset.tabKeep) }
4470
+ const go = e.target.closest('[data-tab-go]')
4471
+ if (go) { e.preventDefault(); return goTab(go.dataset.tabGo) }
4472
+ const all = e.target.closest('#tabAll')
4473
+ if (all) { e.preventDefault(); return openActionMenu(all, allTabsItems(tabViews(), tabsUi.active), { title: 'Open sessions' }) }
4474
+ const t = e.target.closest('#tabStrip a[data-tab], #openRow a[data-tab], #tabPager a[data-tab]')
4475
+ if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
4476
+ })
4477
+ document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
4478
+ // Working in a session keeps its tab: a Live message, typing there, answering Claude there
4479
+ const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
4480
+ for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
4481
+ 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)
4482
+ // Drag a tab to reorder
4483
+ {
4484
+ let dragId = null
4485
+ 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 {} })
4486
+ 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') })
4487
+ 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() } })
4488
+ document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('#tabStrip .dragging, #tabStrip .drop-before').forEach((x) => x.classList.remove('dragging', 'drop-before')) })
4489
+ }
4490
+ // Phones: swipe left / right on a session page for the next / previous open session
4491
+ {
4492
+ let sw = null
4493
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
4494
+ 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 }
4495
+ document.addEventListener('touchstart', (e) => {
4496
+ sw = null
4497
+ if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
4498
+ const t = e.target
4499
+ if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .moremenu') || scrollsSideways(t)) return
4500
+ sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
4501
+ }, { passive: true })
4502
+ document.addEventListener('touchend', (e) => {
4503
+ if (!sw || !e.changedTouches.length) return
4504
+ const p = e.changedTouches[0]
4505
+ const dir = swipeDir(p.clientX - sw.x, p.clientY - sw.y, Date.now() - sw.at)
4506
+ sw = null
4507
+ if (!dir || (window.getSelection && String(window.getSelection()))) return
4508
+ const n = neighbours(tabsUi.m, tabsUi.active)
4509
+ const to = dir === 'next' ? n.next : n.prev
4510
+ if (!to) return
4511
+ const t = tabsUi.m.tabs.find((x) => x.id === to)
4512
+ document.body.classList.remove('swipe-next', 'swipe-prev'); void document.body.offsetWidth
4513
+ document.body.classList.add('swipe-' + dir); setTimeout(() => document.body.classList.remove('swipe-' + dir), 260)
4514
+ goTab(tabView(t).href)
4515
+ }, { passive: true })
4516
+ }
4517
+ // A width change keeps your place: the element at the top of the page stays at
4518
+ // the top, and a Live chat that was at its newest stays there.
4519
+ function anchorY() {
4520
+ let y = 0
4521
+ for (const el of [document.querySelector('.top'), document.getElementById('tabStrip'), document.getElementById('tabPager')]) {
4522
+ if (!el) continue
4523
+ const cs = getComputedStyle(el); if (cs.display === 'none' || !/(fixed|sticky)/.test(cs.position)) continue
4524
+ const r = el.getBoundingClientRect(); if (r.top <= 1 && r.bottom < innerHeight / 2) y = Math.max(y, r.bottom)
4525
+ }
4526
+ return y
4527
+ }
4528
+ function noteAnchor() {
4529
+ if (Date.now() < tabsUi.quietUntil) return
4530
+ const y = anchorY() + 6, m = $app.getBoundingClientRect()
4531
+ if (m.bottom < y) { tabsUi.anchor = null; return }
4532
+ let el = document.elementFromPoint(Math.max(m.left + 24, Math.min(m.left + m.width / 2, innerWidth - 24)), y)
4533
+ 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 }
4534
+ tabsUi.anchor = el ? { el, top: el.getBoundingClientRect().top, scrollY: window.scrollY } : null
4535
+ }
4536
+ function keepPlace() {
4537
+ const a = tabsUi.anchor
4538
+ tabsUi.quietUntil = Date.now() + 250
4539
+ if (a && a.el.isConnected && a.scrollY > 0) window.scrollBy(0, a.el.getBoundingClientRect().top - a.top)
4540
+ document.querySelectorAll('.live-log').forEach((log) => { const st = tabsUi.logs.get(log); if (st && st.bottom) log.scrollTop = log.scrollHeight })
4541
+ }
4542
+ {
4543
+ let raf = 0
4544
+ window.addEventListener('scroll', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; noteAnchor() }) }, { passive: true })
4545
+ 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)
4546
+ let lay = layoutFor(innerWidth)
4547
+ window.addEventListener('resize', () => {
4548
+ keepPlace()
4549
+ const now = layoutFor(innerWidth)
4550
+ if (now !== lay) { lay = now; document.body.dataset.layout = now; scrollActiveTab() }
4551
+ })
4552
+ document.body.dataset.layout = lay
4553
+ }
4554
+
3601
4555
  // Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
3602
4556
  // past the line; the page reads its data again, the same as a live update (no
3603
4557
  // full reload unless that fails). Never from a field, the Live box or a menu.
@@ -3606,8 +4560,8 @@ function closeQuickLook(focusTab = false) {
3606
4560
  ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
3607
4561
  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
4562
  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')
4563
+ const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
4564
+ 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
4565
  let pull = null, refreshing = false
3612
4566
  const paint = (step) => {
3613
4567
  ptr.hidden = !step || (!step.pulling && !refreshing)
@@ -3657,6 +4611,43 @@ function closeQuickLook(focusTab = false) {
3657
4611
  document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
3658
4612
  }
3659
4613
 
4614
+ // Hide the nav on scroll (phones, 0.9.8): under 700px the top row and the bottom
4615
+ // bar slide away while you scroll down and come back when you scroll up, reach
4616
+ // the top or the very bottom (ui/navhide.js). Only your own scrolling hides
4617
+ // them: a route change, the ‹ › pager or a swipe shows them and holds them until
4618
+ // you next touch, wheel or key; typing, a menu or a sheet keeps them shown. The
4619
+ // page never reflows: the top row stays in the flow and the bar is an overlay
4620
+ // over padding that stays.
4621
+ {
4622
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
4623
+ const top = document.querySelector('.top')
4624
+ let st = { hidden: false, anchor: 0 }, raf = 0, held = true
4625
+ const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
4626
+ 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])')
4627
+ const apply = (hidden) => {
4628
+ if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
4629
+ document.body.classList.toggle('nav-hidden', hidden)
4630
+ }
4631
+ const read = () => {
4632
+ raf = 0
4633
+ const se = document.scrollingElement || document.documentElement
4634
+ const y = window.scrollY || se.scrollTop || 0
4635
+ const next = navHideStep(st, { y, max: se.scrollHeight - window.innerHeight, blocked: held || !narrowQ.matches || typing() || overlay() })
4636
+ if (next.hidden !== st.hidden) apply(next.hidden)
4637
+ st = next
4638
+ }
4639
+ const soon = () => { if (!raf) raf = requestAnimationFrame(read) }
4640
+ // no layout read here: this runs on every route change, ahead of the page's own render
4641
+ const showNav = (hold = true) => { if (hold) held = true; const was = st.hidden; st = { hidden: false, anchor: NaN }; if (was) apply(false) }
4642
+ window.addEventListener('scroll', soon, { passive: true })
4643
+ for (const ev of ['touchstart', 'wheel', 'keydown']) window.addEventListener(ev, () => { held = false }, { passive: true, capture: true })
4644
+ window.addEventListener('hashchange', () => showNav(true))
4645
+ document.addEventListener('focusin', (e) => { if (typing() || (top && top.contains(e.target))) showNav(false) })
4646
+ document.addEventListener('click', soon, { passive: true, capture: true }) // a menu or sheet just opened: back they come
4647
+ narrowQ.addEventListener('change', () => showNav(false))
4648
+ window.leftoffNav = { show: () => showNav(true), hidden: () => document.body.classList.contains('nav-hidden') }
4649
+ }
4650
+
3660
4651
  // Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
3661
4652
  const statusOpen = () => { const p = document.getElementById('statusPop'); return p && !p.hidden }
3662
4653
  function syncLine() {
@@ -3790,6 +4781,7 @@ function paletteActions() {
3790
4781
  }
3791
4782
  if (projectsUi.mayEdit) out.push({ action: 'project-new', title: 'New project…', sub: 'a named group of repos', icon: 'plus' })
3792
4783
  out.push({ action: 'gen-digest-go', title: 'Today’s digest', sub: 'open it', icon: 'newspaper', href: '#/digest' })
4784
+ out.push({ action: 'tour', title: 'Take the tour', sub: 'a quick look around the board', icon: 'sparkles', href: '#/tour' })
3793
4785
  return out
3794
4786
  }
3795
4787
  function searchGroups(q, r) {
@@ -3939,11 +4931,27 @@ document.addEventListener('keydown', (e) => {
3939
4931
 
3940
4932
  // A card opens its session's page from anywhere on it, except its buttons,
3941
4933
  // links, fields and selected text (0.4.3: the timeline was too hidden).
4934
+ // 0.9.8: the tap shows at once (a pressed card with a spinner); the page's
4935
+ // shell follows in the same frame (viewSession).
4936
+ function cardOpening(c) {
4937
+ for (const x of document.querySelectorAll('.card.opening')) if (x !== c) x.classList.remove('opening')
4938
+ c.classList.add('opening')
4939
+ setTimeout(() => { if (c.isConnected) c.classList.remove('opening') }, 4000)
4940
+ }
4941
+ document.addEventListener('pointerdown', (ev) => {
4942
+ const c = ev.target.closest && ev.target.closest('.card[data-href]')
4943
+ if (!c || ev.button > 0 || ev.target.closest('button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
4944
+ const a = ev.target.closest('a')
4945
+ if (a && !/^#\/s\//.test(a.getAttribute('href') || '')) return // a repo chip, a PR link: not opening the session
4946
+ cardOpening(c)
4947
+ }, { passive: true })
4948
+ document.addEventListener('pointercancel', () => { for (const x of document.querySelectorAll('.card.opening')) x.classList.remove('opening') }, true)
3942
4949
  document.addEventListener('click', (ev) => {
3943
4950
  const c = ev.target.closest('.card[data-href]')
3944
4951
  if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
3945
4952
  if (String(window.getSelection && window.getSelection()).trim()) return
3946
4953
  if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
4954
+ cardOpening(c)
3947
4955
  location.hash = c.dataset.href
3948
4956
  })
3949
4957
 
@@ -3990,7 +4998,14 @@ document.addEventListener('click', async (ev) => {
3990
4998
  const id = btn.dataset.id || (card && card.dataset.id)
3991
4999
  const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
3992
5000
  try {
3993
- if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
5001
+ if (action === 'copy-cmd') {
5002
+ const ok = await copy(btn.dataset.cmd)
5003
+ if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
5004
+ clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
5005
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy Live resume'); btn.dataset.tip = 'Copy' }, 1500)
5006
+ }
5007
+ return
5008
+ }
3994
5009
  // PRs (0.6.9)
3995
5010
  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
5011
  // 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
@@ -4093,6 +5108,7 @@ document.addEventListener('click', async (ev) => {
4093
5108
  if (action === 'list-showall') { listUi.showAll = !listUi.showAll; return render() }
4094
5109
  if (action === 'list-help') { listUi.help = !listUi.help; return render() }
4095
5110
  if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
5111
+ if (action === 'list-help-inline') { listUi.sHelp = !listUi.sHelp; render(); return }
4096
5112
  if (action === 'remote-link' || action === 'remote-code') {
4097
5113
  const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
4098
5114
  if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
@@ -4280,6 +5296,19 @@ document.addEventListener('click', async (ev) => {
4280
5296
  saveSpace(state.digestSpace)
4281
5297
  return paintDigestFilter()
4282
5298
  }
5299
+ if (action === 'digest-cal-fold') {
5300
+ digestUi.calOpen = !digestUi.calOpen
5301
+ repaintCalendar()
5302
+ return
5303
+ }
5304
+ if (action === 'digest-month') {
5305
+ const back = btn.dataset.to < (digestUi.month || '')
5306
+ digestUi.month = btn.dataset.to
5307
+ digestUi.calOpen = true // another month: show all of it
5308
+ const nav = repaintCalendar()
5309
+ 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
5310
+ return
5311
+ }
4283
5312
  if (action === 'standup-copy') return standupCopy(btn)
4284
5313
  if (action === 'standup-close') { const b = btn.closest('.standup-out'); if (b) b.remove(); digestUi.open = null; return }
4285
5314
  if (action === 'space-quick' || action === 'space-repo-add' || action === 'space-unset') return spacesAction(action, btn)
@@ -4305,7 +5334,13 @@ document.addEventListener('click', async (ev) => {
4305
5334
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
4306
5335
  if (action === 'reply-send') return replyClick(btn)
4307
5336
  if (action === 'live-send') return liveSendClick(btn)
5337
+ if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
5338
+ if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
5339
+ if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
5340
+ if (action === 'att-open') return openAttachment(btn)
5341
+ if (action === 'att-chip-open') return openChip(btn.dataset.lid)
4308
5342
  if (action === 'live-jump') return liveJumpClick(btn)
5343
+ if (action === 'live-earlier') return liveEarlierClick(btn)
4309
5344
  if (action === 'quick-reply') return quickReplyClick(btn)
4310
5345
  if (action === 'need-more') return needMoreClick(btn)
4311
5346
  if (action === 'need-dismiss') return needDismissClick(btn)
@@ -4791,16 +5826,21 @@ let stream = null
4791
5826
  function connect() {
4792
5827
  if (stream) { try { stream.close() } catch {} }
4793
5828
  const es = stream = new EventSource('/v1/stream')
5829
+ const again = connect.opened // a reconnect: events may have been missed while it was down
5830
+ streamDog.opened()
5831
+ // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
5832
+ es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
4794
5833
  es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
4795
5834
  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)
5835
+ 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
5836
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
4798
5837
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
4799
5838
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
4800
5839
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
4801
5840
  // port; if the browser gave up on the stream, open a new one.
4802
5841
  es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
4803
- es.onopen = () => refreshHealth()
5842
+ let opens = 0
5843
+ es.onopen = () => { if (stream !== es) return; streamDog.seen(); refreshHealth(); if (again || opens++) refreshView('reconnect'); connect.opened = true }
4804
5844
  }
4805
5845
 
4806
5846
  document.addEventListener('change', async (ev) => {
@@ -4903,9 +5943,51 @@ document.addEventListener('pointerdown', (ev) => {
4903
5943
  if (m && !m.contains(ev.target)) m.open = false
4904
5944
  })
4905
5945
  api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
5946
+ // 0.9.8: "Leftoff updated · Refresh" when the Mac serving this board has newer
5947
+ // files than the ones this page runs. It never reloads by itself; Refresh
5948
+ // carries your drafts over and says first what wouldn't come along.
5949
+ updateBar = mountUpdateBar({
5950
+ icon,
5951
+ health: () => refreshHealth(),
5952
+ risks: () => {
5953
+ let attachments = 0, uploading = 0
5954
+ for (const list of attachUi.by.values()) for (const a of list) { attachments++; if (a.status === 'prep' || a.status === 'up') uploading++ }
5955
+ const f = document.activeElement
5956
+ 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())
5957
+ let unsavedSettings = false
5958
+ try { unsavedSettings = settingsChanged().length > 0 } catch {}
5959
+ return { attachments, uploading, unsavedSettings, typing }
5960
+ },
5961
+ carry: () => {
5962
+ const drafts = { ...state.drafts }
5963
+ for (const t of document.querySelectorAll('textarea[data-draft]')) if (t.value) drafts[t.dataset.draft] = t.value
5964
+ const f = document.activeElement
5965
+ const focus = f && f.dataset && f.dataset.draft ? { key: f.dataset.draft, a: f.selectionStart || 0, b: f.selectionEnd || 0 } : lastDraftFocus
5966
+ return { drafts, focus }
5967
+ },
5968
+ })
5969
+ // The box you were typing in, if you tabbed from it to the bar.
5970
+ let lastDraftFocus = null
5971
+ document.addEventListener('focusin', (ev) => { if (!(ev.target.closest && ev.target.closest('#updateBar'))) lastDraftFocus = null })
5972
+ 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 } })
5973
+ // Back from Refresh: the box you were typing in has its text, and (with a
5974
+ // keyboard and mouse) the cursor where it was.
5975
+ function restoreCarriedFocus(tries = 50) {
5976
+ const f = carried && carried.focus
5977
+ if (!f || isTouchUi(window.matchMedia)) return
5978
+ if (document.activeElement && document.activeElement !== document.body) return // you've moved on
5979
+ const t = [...document.querySelectorAll('textarea[data-draft]')].find((x) => x.dataset.draft === f.key)
5980
+ if (!t || t.disabled) { if (tries > 0) setTimeout(() => restoreCarriedFocus(tries - 1), 100); return } // the session page draws after its data
5981
+ t.focus({ preventScroll: true })
5982
+ try { const n = t.value.length; t.setSelectionRange(Math.min(f.a, n), Math.min(f.b, n)) } catch {}
5983
+ }
4906
5984
  refreshHealth().then(render).then(firstRunSetup)
5985
+ restoreCarriedFocus()
4907
5986
  connect()
4908
5987
 
4909
5988
  // The home-screen app's service worker (v0.4.6): it only serves an offline page
4910
5989
  // when the Mac can't be reached; it caches no API data and no board pages.
4911
5990
  if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
5991
+ // 0.9.8: phone notifications. The one per-device button (More on a phone,
5992
+ // Settings → General everywhere); the service worker shows the pushes.
5993
+ mountPush({ api })