@leftoff/cli 0.9.8-beta.1 → 0.9.8-beta.10

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
@@ -9,6 +9,10 @@ import { changedKeys } from './settings-dirty.js'
9
9
  import { looksMd, stripMd, plainCp } from './cp-text.js'
10
10
  import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
11
11
  import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel } from './repos.js'
12
+ import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
13
+ import { canStartPull, pullStep, pullRelease } from './pull.js'
14
+ import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
15
+ import { wireTips } from './tips.js'
12
16
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
13
17
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
14
18
  import { mdInline, renderSection } from './changelog-md.js'
@@ -16,8 +20,15 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
16
20
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
17
21
  import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
18
22
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
23
+ 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
24
+ import { statusView, elapsedText } from './live-status.js'
25
+ 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
26
+ import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
27
+ 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
19
28
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
20
29
  const $app = document.getElementById('app')
30
+ // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
31
+ if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
21
32
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
22
33
  const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
23
34
  const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
@@ -108,12 +119,16 @@ async function copy(text) {
108
119
  // 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
109
120
  function copiedMark(btn) {
110
121
  clearTimeout(btn._copied)
122
+ const tip = btn._tip || (btn._tip = btn.title || 'Copy resume command')
111
123
  btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.title = 'Copied'
112
- btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = 'Copy resume command' }, 1500)
124
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
113
125
  }
114
126
  const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
115
127
  // 0.9.8 Live link: the session's channel is listening (send it prompts from here)
116
- const liveBadge = (s) => (s && s.liveLink && s.liveLink.on ? ' <span class="pill live" title="Live link: send this session messages from the board or your phone">Live</span>' : '')
128
+ // It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
129
+ const liveBadge = (s) => liveBadgeHtml(s)
130
+ // The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
131
+ const statusPill = (s) => { const st = cardPills(s).status; return st ? pill(st) : '' }
117
132
 
118
133
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
119
134
 
@@ -177,11 +192,11 @@ function wtChips(s) {
177
192
  return h
178
193
  }
179
194
 
180
- function where(s) {
195
+ function where(s, { repo = true } = {}) {
181
196
  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)
182
197
  const branch = s.repo && s.repo.branch
183
198
  const label = s.repoLabel || s.repoName || 'no repo'
184
- 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) +
199
+ 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) +
185
200
  (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)
186
201
  }
187
202
 
@@ -380,9 +395,13 @@ function liveNote(s) {
380
395
  if (s.live === 'open' && s.liveVia === 'heartbeat' && !s.isLocal) return 'open (heartbeat)'
381
396
  return s.live === 'open' ? 'open' : ''
382
397
  }
383
- function goButton(s) {
398
+ // 0.9.8: { page: true } is the session page's header, where a Go that can't run
399
+ // is left out (its tooltip was the only place the reason showed, and the grey
400
+ // box read as broken); a card keeps the dimmed Go and its short why.
401
+ function goButton(s, { page = false } = {}) {
384
402
  if (s.live === 'closed') return ''
385
403
  const j = s.jump || {}
404
+ if (page && (state.viewerLocal === false || !j.supported)) return ''
386
405
  // Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
387
406
  if (!s.isLocal) {
388
407
  if (!j.peer) return ''
@@ -432,8 +451,12 @@ function agentCard(s) {
432
451
  // closed), a small copy icon for the resume command, and "⋯" for the rest. The
433
452
  // ⋯ menu's items ride in the card as an inert <template> (real buttons with the
434
453
  // same data-action as before), so the one click handler below runs them unchanged.
454
+ // What Resume runs: codex resume, claude --resume, or for a Live session (0.9.8)
455
+ // leftoff live -- --resume, so it comes back Live.
456
+ function resumeVerb(s) { return s.codingAgent === 'codex' ? 'codex resume' : s.resumeLive ? 'leftoff live -- --resume' : 'claude --resume' }
457
+ function resumeTip(s, lead) { return lead + ': ' + resumeVerb(s) + (s.resumeLive ? ' (it comes back Live)' : '') }
435
458
  function copyResumeIcon(s) {
436
- 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>'
459
+ 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>'
437
460
  }
438
461
  // items: [{ action, label, icon, title, attrs }]
439
462
  // 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
@@ -550,7 +573,7 @@ function checkpointSplit(s) {
550
573
  '<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
551
574
  }
552
575
  return '<span class="split" role="group" aria-label="Checkpoint now">' +
553
- '<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>' +
576
+ '<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>' +
554
577
  '<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>'
555
578
  }
556
579
  function openLengthMenu(btn, id) {
@@ -574,15 +597,15 @@ function card(s) {
574
597
  const nb = s.needsYou ? cardBadge(s.needsYou) : ''
575
598
  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)) + '">' +
576
599
  '<div class="row1"><div class="title">' + liveDot(s) +
577
- (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' +
600
+ (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
578
601
  '<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
579
- '</div>' + pill(s.status) + liveBadge(s) + '</div>' +
580
- '<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' ? ' · no prompt yet' : '') + '</span>' +
602
+ '</div>' + statusPill(s) + liveBadge(s) + '</div>' +
603
+ '<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>' +
581
604
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
582
605
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
583
606
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
584
607
  '<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
585
- copyResumeIcon(s) +
608
+ chatButton(s) + copyResumeIcon(s) +
586
609
  (s.isLocal ? '' : remoteNote(s)) + moreMenu(s, sessionMenuItems(s)) +
587
610
  '</div></article>'
588
611
  }
@@ -753,7 +776,7 @@ async function viewBoard() {
753
776
  // not a resumed conversation) fold into one "N empty sessions" row. One
754
777
  // appears on the board as itself after its first prompt. Pinned ones, and
755
778
  // ones that need you, always show.
756
- const isFolded = (s) => s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking)
779
+ const isFolded = (s) => foldsAsEmpty(s) // 0.9.8: never a live session
757
780
  const shown = recent.filter((s) => !isFolded(s))
758
781
  const empties = recent.filter(isFolded)
759
782
  const rank = Object.fromEntries(ORDER.map(([k], i) => [k, i]))
@@ -801,6 +824,7 @@ async function viewBoard() {
801
824
  html += projectRow(spaced, pj, 'board-project')
802
825
  if (filter === 'closed') {
803
826
  html += await closedSection(f, sp)
827
+ if (parseRoute().name === 's') return // 0.9.8: you opened a session while the board loaded
804
828
  $app.innerHTML = banners() + html
805
829
  return closedWire()
806
830
  }
@@ -842,6 +866,7 @@ function boardSearch(q, closedHits, n) {
842
866
  // A repaint from a live update (or the 30 s refresh) while you type in the
843
867
  // search box keeps the box focused, with what you typed and the caret.
844
868
  function paintBoardKeepingSearch(h) {
869
+ if (parseRoute().name === 's') return // 0.9.8: a slow board answer never paints over the session you opened
845
870
  const old = document.getElementById('boardQ')
846
871
  const keep = old && document.activeElement === old ? { v: old.value, pos: old.selectionStart } : null
847
872
  $app.innerHTML = h
@@ -921,10 +946,10 @@ function resumeButton(s, viewer = {}) {
921
946
  const busy = closedUi.busy.has(s.id)
922
947
  if (s.isLocal) {
923
948
  if (!viewer.ghostty) return ''
924
- 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>'
949
+ 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>'
925
950
  }
926
951
  if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
927
- 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>'
952
+ 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>'
928
953
  }
929
954
  function closedCard(s, viewer) {
930
955
  const href = '#/s/' + encodeURIComponent(s.id)
@@ -1168,9 +1193,11 @@ async function viewWaiting() {
1168
1193
  // The nav's count, the page title and the favicon dot: visible from a background tab.
1169
1194
  function paintNeedsCount(n) {
1170
1195
  const c = document.getElementById('needsCount')
1171
- if (c) { c.hidden = !n; c.textContent = n > 99 ? '99+' : String(n) }
1196
+ if (c) { c.hidden = !n; c.textContent = badgeText(n) }
1172
1197
  const tab = document.getElementById('needsTab')
1173
1198
  if (tab) { tab.classList.toggle('has-needs', !!n); tab.title = n ? n + ' waiting on you' : 'Nothing waiting on you' }
1199
+ if (quickLookOpen() && paintNeedsCount.count !== n) loadQuickLook()
1200
+ paintNeedsCount.count = n
1174
1201
  document.title = (n ? '(' + n + ') ' : '') + 'Leftoff'
1175
1202
  // The tab icon gets a red dot while something waits (leftoff-favicon-badge,
1176
1203
  // v0.4.5); the PNG is for browsers without SVG favicons.
@@ -1239,8 +1266,8 @@ async function viewAgentSession(s) {
1239
1266
  const name = esc(a.display || a.name)
1240
1267
  const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
1241
1268
  let html = '<a class="back" href="#/">← All sessions</a>'
1242
- 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>' +
1243
- '<div class="actions">' + agentReplyButton(a) + '</div></div>'
1269
+ 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>' +
1270
+ '<div class="actions s-actions">' + agentReplyButton(a) + '</div></div>'
1244
1271
  // v0.5.6: agent.url only links the name; the reply affordance and its
1245
1272
  // words come from the feed (agent.reply, agent.replyHint), or nothing.
1246
1273
  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>'
@@ -1261,25 +1288,94 @@ async function viewAgentSession(s) {
1261
1288
  $app.innerHTML = banners() + html
1262
1289
  }
1263
1290
 
1291
+ // 0.9.8: another Mac's session whose Mac is offline says so plainly, once, near the top
1292
+ function ownerOffline(s) {
1293
+ if (!s || s.isLocal !== false || s.agent || !s.machine) return false
1294
+ const st = window.leftoffRelay && state.relayStatus
1295
+ const x = st && (st.macs || []).find((y) => y.machine === s.machine)
1296
+ if (x) return !x.online
1297
+ return !!(s.peer && s.peer.code === 'unreachable')
1298
+ }
1299
+ function ownerOfflineNote(s) {
1300
+ if (!ownerOffline(s)) return ''
1301
+ const m = esc(s.machine), sn = staleNote(s.machine)
1302
+ 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>'
1303
+ }
1304
+ // 0.9.8: opening a session is instant. The tap paints the page's shell at
1305
+ // once (title and pills from the card we already have, or the whole page from
1306
+ // the last visit), then every fetch runs in parallel (the session, its Live
1307
+ // thread, spaces, projects, lists) instead of one after another; on a phone
1308
+ // through the relay each was a full round trip. A newer navigation wins: a
1309
+ // slow answer for a page you already left never paints over the one you're on.
1310
+ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few sessions you opened
1311
+ const SESSION_CACHE_MAX = 8
1312
+ function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
1313
+ const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
1314
+ function sessionShellHtml(s) {
1315
+ const head = s
1316
+ ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + statusPill(s) + liveBadge(s) + '</div>'
1317
+ : '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
1318
+ 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>' +
1319
+ '<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>' +
1320
+ '<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>'
1321
+ }
1322
+ function paintSession(s, data) {
1323
+ if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
1324
+ liveKeeper.before($app)
1325
+ $app.innerHTML = sessionPageHtml(s, data)
1326
+ liveAfterRender(s.id)
1327
+ aroundWire(s.id)
1328
+ }
1264
1329
  async function viewSession(id) {
1265
1330
  if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
1266
- const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
1331
+ const showing = $app.querySelector('.s-head[data-sid="' + CSS.escape(id) + '"]') // a re-render of the page you're on: no shell
1332
+ const cached = sessionCache.get(id)
1333
+ const known = (cached && cached.s) || state.sessions.find((x) => x.id === id) || null
1334
+ if (!showing) {
1335
+ if (cached) paintSession(cached.s, cached) // the last visit, at once; fresh data replaces it below
1336
+ else { liveKeeper.before($app); $app.innerHTML = sessionShellHtml(known) }
1337
+ }
1338
+ // on leftoff.dev, another Mac's session loads its data from that Mac: say so before the first call
1339
+ const owner = (x) => { if (x && window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(x.id, x.isLocal === false && !x.agent ? x.machine : null) }
1340
+ owner(known)
1341
+ const pS = api('GET', '/v1/sessions/' + encodeURIComponent(id))
1342
+ const pLive = !known || liveWanted(known) ? fetchLive(id) : null // the thread doesn't wait for the session
1343
+ const pLists = known && known.lists && known.lists.length ? fetchLists(id) : null
1344
+ const pRest = Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1345
+ let s
1346
+ try { s = await pS } catch (e) { if (!onSession(id)) return; throw e }
1267
1347
  state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
1348
+ owner(s)
1349
+ const status = s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null
1350
+ const lists = s.lists && s.lists.length ? (pLists || fetchLists(id)) : null
1351
+ const live = liveWanted(s) ? (pLive || fetchLive(id)) : !isLive(s) && !s.endedAt && s.live !== 'closed' ? liveHow().then(() => null) : null
1352
+ let [[spv], v, sl] = await Promise.all([pRest, live, lists, status])
1353
+ if (!onSession(id)) return // you moved on while this loaded
1354
+ // a Live fetch that failed keeps the thread you had (its bubbles say "Reconnecting…"), not an empty one
1355
+ const prev = sessionCache.get(id)
1356
+ if (v && v.error && prev && prev.live && !prev.live.error) v = { ...prev.live, error: null }
1357
+ const data = { spv, live: v, lists: sl }
1358
+ if (v && !v.error) cacheSession(id, { s, ...data, live: v })
1359
+ paintSession(s, data)
1360
+ }
1361
+ const fetchLists = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists').catch(() => null)
1362
+ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {}) {
1268
1363
  const branch = s.repo && s.repo.branch
1269
- const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1270
1364
  let html = '<a class="back" href="#/">← All sessions</a>'
1271
1365
  // 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)
1272
1366
  const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
1273
- html += '<div class="pagehead"><div>' + (canRename
1274
- ? '<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(s.title || 'Untitled session') + '</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>'
1275
- : '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + 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 + ' title' : '') + '</div></div>' +
1276
- '<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>'
1367
+ html += '<div class="pagehead s-head" data-sid="' + esc(s.id) + '"><div class="s-head-main">' + (canRename
1368
+ ? '<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>'
1369
+ : '<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>' +
1370
+ // 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
1371
+ '<div class="actions s-actions">' + goButton(s, { page: true }) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + 'Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
1372
+ html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
1277
1373
  html += '<div class="facts">' +
1278
1374
  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)) +
1279
1375
  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) +
1280
1376
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1281
1377
  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>'
1282
- if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + await liveSection(s)
1378
+ if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
1283
1379
  html += parkRow(s)
1284
1380
  html += spacePicker(s, spv, 'session', s.id)
1285
1381
  html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
@@ -1288,15 +1384,14 @@ async function viewSession(id) {
1288
1384
  html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
1289
1385
  // 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
1290
1386
  if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
1291
- if (!isLive(s)) html += await liveSection(s)
1387
+ if (!isLive(s)) html += liveBoxHtml(s, liveV)
1292
1388
  if (s.statusChanges && s.statusChanges.length) {
1293
1389
  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>'
1294
1390
  }
1295
1391
  // Lists (v0.5.1): its saved lists in full, the hint, and Find lists in this session
1296
1392
  if (s.lists && s.lists.length) {
1297
- const sl = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists').catch(() => null)
1298
1393
  state.listFull = {}
1299
- for (const l of (sl && sl.lists) || []) state.listFull[l.ref] = l
1394
+ for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l
1300
1395
  }
1301
1396
  state.listsMayEdit = state.mayAnswer !== false
1302
1397
  html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
@@ -1314,45 +1409,239 @@ async function viewSession(id) {
1314
1409
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
1315
1410
  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>'
1316
1411
  html += answeredList(s)
1317
- $app.innerHTML = html
1318
- aroundWire(s.id)
1412
+ return html
1319
1413
  }
1320
1414
  // ------------------------------------------------------------ Live link (0.9.8 beta)
1321
1415
 
1322
1416
  const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
1323
1417
  function sessionNeedsStrip(s) { return '<section class="needs-strip one" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>' }
1324
- const LIVE_STATE = { sent: 'Sent, waiting for the session', received: 'Received by the session', failed: 'Didn’t arrive' }
1325
- function liveEntry(m) {
1326
- const who = m.from === 'claude' ? 'Claude' : m.from === 'event' ? 'Event' + (m.source ? ' · ' + m.source : '') : 'You' + (m.where && !/^on this machine/.test(m.where) ? ' · ' + m.where : '')
1327
- const st = m.from === 'claude' ? '' : '<span class="lv-state ' + esc(m.state || '') + '">' + esc(m.state === 'failed' ? (m.errorText || LIVE_STATE.failed) : LIVE_STATE[m.state] || '') + '</span>'
1328
- return '<li class="lv lv-' + esc(m.from) + '"><div class="lv-who">' + esc(who) + ' <span class="faint">' + esc(ago(m.at)) + '</span>' + st + '</div><div class="lv-text">' + esc(m.text) + '</div></li>'
1418
+ // 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
1419
+ // bottom); Claude's answer is the session's own (markdown, like the terminal).
1420
+ const liveKeeper = createLiveKeeper()
1421
+ const liveSeenKey = (id) => 'lo-live-seen:' + id
1422
+ function liveSeen(id) { try { return localStorage.getItem(liveSeenKey(id)) } catch { return null } }
1423
+ function markLiveSeen(id, at) { try { localStorage.setItem(liveSeenKey(id), at || new Date().toISOString()) } catch {} }
1424
+ // "Chat" on a live card: straight to the Live box (#/s/<id>/live), with a dot when Claude replied since you last looked.
1425
+ function chatButton(s) {
1426
+ if (!cardPills(s).live || s.agent) return ''
1427
+ const unread = hasUnread(s.liveLink && s.liveLink.lastReplyAt, liveSeen(s.id))
1428
+ return '<a class="btn chat-btn" href="' + esc(liveHref(s.id)) + '" aria-label="' + esc('Chat with this session' + (unread ? ' (new reply)' : '')) + '">Chat' + (unread ? '<span class="unread-dot" aria-hidden="true"></span>' : '') + '</a>'
1329
1429
  }
1330
1430
  // The how-to for a session that isn't live (fetched once: the claude line has this Mac's path).
1331
1431
  async function liveHow() {
1332
1432
  if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
1333
1433
  return state.liveHow
1334
1434
  }
1335
- async function liveSection(s) {
1435
+ // Does this session's page show a Live thread (and so fetch one)?
1436
+ const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
1437
+ // GET the Live thread; what it says is the newest the page knows (0.9.8: the
1438
+ // time of the last good answer decides "Reconnecting…" on a pending bubble).
1439
+ async function fetchLive(id, { timeoutMs } = {}) {
1440
+ const turns = (state.liveTurns && state.liveTurns[id]) || 20 // "Show earlier" adds 20 at a time
1441
+ try {
1442
+ const v = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/live' + (turns > 20 ? '?turns=' + turns : ''), null, timeoutMs ? { timeoutMs } : {})
1443
+ noteLive(id, v)
1444
+ return v
1445
+ } catch (e) { return { messages: [], error: e.message } }
1446
+ }
1447
+ function liveBoxHtml(s, v) {
1336
1448
  if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
1337
1449
  if (!isLive(s)) {
1338
1450
  if (s.endedAt || s.live === 'closed') return ''
1339
- const how = await liveHow()
1451
+ const how = state.liveHow
1340
1452
  return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
1341
1453
  '<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>' +
1342
1454
  (how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p></section>'
1343
1455
  }
1344
- let v = null
1345
- try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
1346
1456
  const msgs = (v && v.messages) || []
1347
1457
  const may = state.mayAnswer !== false
1348
1458
  const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
1459
+ const now = Date.now()
1460
+ const lastClaude = [...msgs].reverse().find((m) => m.from === 'claude')
1461
+ if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1349
1462
  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>' +
1350
- (msgs.length ? '<ol class="live-log" aria-live="polite">' + msgs.map(liveEntry).join('') + '</ol>' : '<p class="faint live-empty">Nothing sent yet.</p>') +
1463
+ '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1464
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now }) + '</ol>' +
1465
+ '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
1466
+ '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1351
1467
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1352
1468
  '<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>' +
1353
1469
  '<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>' +
1354
1470
  (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>'
1355
1471
  }
1472
+ // After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
1473
+ function liveAfterRender(sid) {
1474
+ const r = liveKeeper.after($app)
1475
+ if (!r) return
1476
+ const ol = r.thread.querySelector('.live-log')
1477
+ const empty = r.thread.querySelector('.live-empty')
1478
+ if (empty && ol) empty.hidden = ol.children.length > 0
1479
+ liveJumpButton(r.thread, liveKeeper.unseen)
1480
+ if (ol && !ol.dataset.wired) {
1481
+ ol.dataset.wired = '1'
1482
+ ol._stick = atBottom(ol)
1483
+ // the button follows the scroll: there whenever you're away from the bottom, gone once you're back
1484
+ ol.addEventListener('scroll', () => { ol._stick = atBottom(ol); liveJumpButton(r.thread, liveKeeper.unseen) }, { passive: true })
1485
+ // at the bottom, the thread stays there while an entry grows (a streamed reply, a code block, late fonts)
1486
+ if (typeof ResizeObserver === 'function') {
1487
+ ol._ro = new ResizeObserver(() => { if (ol._stick && !atBottom(ol, 2)) ol.scrollTop = ol.scrollHeight; liveJumpButton(r.thread, liveKeeper.unseen) })
1488
+ }
1489
+ }
1490
+ if (ol && ol._ro) { ol._ro.disconnect(); ol._ro.observe(ol); for (const li of ol.children) ol._ro.observe(li) }
1491
+ if (ol && r.follow) ol._stick = true
1492
+ // #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
1493
+ const want = parseLiveRoute(location.hash)
1494
+ if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
1495
+ state.liveFocused = location.hash + '|' + sid
1496
+ const box = document.getElementById('live')
1497
+ if (box) box.scrollIntoView({ block: 'start', behavior: 'auto' })
1498
+ if (ol) ol.scrollTop = ol.scrollHeight
1499
+ const ta = box && box.querySelector('.live-text')
1500
+ if (ta && !ta.disabled && !(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)) ta.focus({ preventScroll: true })
1501
+ }
1502
+ liveTick()
1503
+ }
1504
+ // "Jump to latest ↓" shows whenever the thread is scrolled away from its bottom (with the
1505
+ // count of what came in since, when there is any), and hides once you're back at the bottom.
1506
+ function liveJumpButton(thread, n) {
1507
+ const b = thread && thread.querySelector('.live-jump')
1508
+ if (!b) return
1509
+ const ol = thread.querySelector('.live-log')
1510
+ const v = jumpView(ol, n)
1511
+ if (!v.show && n > 0) liveKeeper.seen()
1512
+ // on its way down after a tap: don't flash back up mid-scroll
1513
+ if (v.show && thread.dataset.jumping && Date.now() < Number(thread.dataset.jumping)) return
1514
+ b.hidden = !v.show
1515
+ const c = b.querySelector('.live-jump-n')
1516
+ if (c) c.textContent = v.count
1517
+ b.setAttribute('aria-label', v.label)
1518
+ }
1519
+ function liveJumpClick(btn) {
1520
+ const thread = btn.closest('.live-thread')
1521
+ const ol = thread && thread.querySelector('.live-log')
1522
+ const smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
1523
+ if (thread) thread.dataset.jumping = String(Date.now() + (smooth ? 900 : 0))
1524
+ if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
1525
+ liveKeeper.seen()
1526
+ btn.hidden = true
1527
+ const c = btn.querySelector('.live-jump-n'); if (c) c.textContent = ''
1528
+ // land exactly at the bottom even if something grew mid-scroll, then the button follows the scroll again
1529
+ 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)
1530
+ }
1531
+ // "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
1532
+ async function liveEarlierClick(btn) {
1533
+ const box = btn.closest('.live-box')
1534
+ const sid = box && box.dataset.id
1535
+ if (!sid) return
1536
+ state.liveTurns = state.liveTurns || {}
1537
+ state.liveTurns[sid] = ((state.liveTurns[sid]) || 20) + 20
1538
+ btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading…'
1539
+ await render()
1540
+ }
1541
+ // The elapsed time beside "Claude is working…", every second while something is in flight.
1542
+ // 0.9.8: when what the page shows is older than STALE_MS (the stream died, the
1543
+ // phone slept), a running bubble says "Reconnecting…" instead of a timer.
1544
+ let liveTimer = null
1545
+ function liveTick() {
1546
+ const els = [...document.querySelectorAll('.lv-status[data-since]')]
1547
+ const box = document.querySelector('#live[data-id]')
1548
+ const stale = !!box && isStale(liveUi.okAt[box.dataset.id], Date.now())
1549
+ for (const el of els) {
1550
+ const since = Number(el.dataset.since) || 0
1551
+ const end = Number(el.dataset.final) || 0
1552
+ const t = el.querySelector('.lvs-time')
1553
+ if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
1554
+ if (!end) {
1555
+ el.classList.toggle('lv-reconnecting', stale)
1556
+ let hint = el.querySelector('.lvs-hint')
1557
+ if (stale && !hint) { hint = document.createElement('span'); hint.className = 'lvs-hint'; hint.textContent = 'Reconnecting…'; (el.querySelector('.lvs-line') || el).append(hint) }
1558
+ if (hint) hint.hidden = !stale
1559
+ }
1560
+ }
1561
+ const running = els.some((el) => !el.dataset.final)
1562
+ if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
1563
+ }
1564
+
1565
+ // 0.9.8: Live keeps up after the phone sleeps (ui/live-wake.js). Every Live
1566
+ // fetch notes when it answered and whether a message is still going; while
1567
+ // one is, on a visible page, the thread is fetched again every POLL_MS
1568
+ // whatever the event stream does.
1569
+ const liveUi = { okAt: {}, sig: {}, pending: {}, polling: false }
1570
+ function noteLive(id, v) {
1571
+ if (!v || v.error) return
1572
+ liveUi.okAt[id] = Date.now()
1573
+ liveUi.pending[id] = anyPending(v.messages)
1574
+ }
1575
+ 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 }
1576
+ // Fetch the thread and diff it into the page, even while you type (busy()
1577
+ // holds full redraws back, never the thread). A turn that ended also redraws
1578
+ // the rest of the page (the pill stops pulsing) once you're not busy.
1579
+ async function refreshLive(reason) {
1580
+ const id = liveOnScreen()
1581
+ if (!id || liveUi.polling) return false
1582
+ liveUi.polling = true; liveUi.triedAt = Date.now()
1583
+ let v
1584
+ try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
1585
+ if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
1586
+ const s = state.sessions.find((x) => x.id === id)
1587
+ const sig = liveSignature(v.messages)
1588
+ const before = liveUi.sig[id]
1589
+ liveUi.sig[id] = sig
1590
+ if (s && before !== sig) {
1591
+ const box = document.querySelector('#live')
1592
+ const cur = box && box.querySelector('.live-thread')
1593
+ if (cur) {
1594
+ const tmp = document.createElement('div')
1595
+ tmp.innerHTML = liveBoxHtml(s, v)
1596
+ const fresh = tmp.querySelector('.live-thread')
1597
+ if (fresh) {
1598
+ const next = cur.nextSibling
1599
+ liveKeeper.before(box)
1600
+ box.insertBefore(fresh, next)
1601
+ const r = liveKeeper.after(box)
1602
+ if (r) liveJumpButton(r.thread, liveKeeper.unseen)
1603
+ }
1604
+ }
1605
+ const c = sessionCache.get(id); if (c) c.live = v
1606
+ if (!liveUi.pending[id] && !busy()) soon() // settled: the pill, the tab, the card
1607
+ }
1608
+ liveTick()
1609
+ return true
1610
+ }
1611
+ // The safety net, and the stream's watchdog, on one slow timer.
1612
+ const streamDog = createWatchdog()
1613
+ const wakeGate = createWake()
1614
+ setInterval(() => {
1615
+ if (document.visibilityState === 'hidden') return
1616
+ const id = liveOnScreen()
1617
+ 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')
1618
+ if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
1619
+ }, 1000)
1620
+ // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
1621
+ // bfcache restore, the network returns): fetch what's on screen at once, and
1622
+ // open a new stream if the old one missed its pings.
1623
+ function refreshView(reason) {
1624
+ if (liveOnScreen()) refreshLive(reason)
1625
+ if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
1626
+ refreshHealth()
1627
+ }
1628
+ let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
1629
+ function onWake(ev) {
1630
+ if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
1631
+ const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
1632
+ if (!why) return
1633
+ liveTick() // a stale timer turns into "Reconnecting…" before anything else
1634
+ const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
1635
+ const away = awayAt ? Date.now() - awayAt : 0
1636
+ awayAt = 0
1637
+ // 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
1638
+ if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
1639
+ if (!wakeGate.due()) return
1640
+ if (why === 'pageshow-bfcache' || dead) connect()
1641
+ refreshView(why)
1642
+ }
1643
+ for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
1644
+ document.addEventListener('visibilitychange', onWake)
1356
1645
  async function liveSendClick(btn) {
1357
1646
  const box = btn.closest('.live-box')
1358
1647
  const ta = box && box.querySelector('.live-text')
@@ -1365,8 +1654,10 @@ async function liveSendClick(btn) {
1365
1654
  if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
1366
1655
  if (ta) ta.value = ''
1367
1656
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
1657
+ const ol = box && box.querySelector('.live-log')
1658
+ if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
1368
1659
  setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
1369
- } catch (e) { toast(e.message, 4200) } finally { render() }
1660
+ } 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' } }
1370
1661
  }
1371
1662
 
1372
1663
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
@@ -2478,6 +2769,7 @@ async function viewSettings() {
2478
2769
  general: '<div class="section">Needs you</div>' +
2479
2770
  '<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>' +
2480
2771
  '<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
2772
+ '<div class="full push-slot" data-push-slot hidden></div>' +
2481
2773
  '<div class="section">Appearance</div>' +
2482
2774
  '<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>' +
2483
2775
  advanced('general', '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
@@ -3239,7 +3531,8 @@ function parseRoute() {
3239
3531
 
3240
3532
  async function render() {
3241
3533
  const r = (state.route = parseRoute())
3242
- document.querySelectorAll('.tabs a, .topright a[data-route]').forEach((a) => { const on = a.dataset.route === (r.name === 's' ? 'board' : r.name === 'catchup' ? 'digest' : r.name); a.classList.toggle('on', on); if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current') })
3534
+ paintNav(r)
3535
+ tabsOnRoute(r)
3243
3536
  try {
3244
3537
  if (r.name === 's' && r.arg) await viewSession(r.arg)
3245
3538
  else if (r.name === 'waiting') await viewWaiting()
@@ -3254,6 +3547,7 @@ async function render() {
3254
3547
  else if (r.name === 'search') await viewSearch(r.q || '')
3255
3548
  else if (r.name === 'setup') await viewSetup()
3256
3549
  else await viewBoard()
3550
+ tabsAfterRender()
3257
3551
  } catch (e) {
3258
3552
  if (e.code === 'login_required') return showLogin()
3259
3553
  $app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
@@ -3326,6 +3620,7 @@ async function refreshHealth() {
3326
3620
  if (h.answering) state.mayAnswer = h.answering.allowedHere !== false
3327
3621
  const dot = document.querySelector('#wnTab .newdot')
3328
3622
  if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
3623
+ paintMoreDots()
3329
3624
  paintSettingsDots()
3330
3625
  } catch (e) {
3331
3626
  el.className = 'health down'
@@ -3356,6 +3651,475 @@ function paintTopDot() {
3356
3651
  el.className = 'topdot' + (warn ? ' warn' : '')
3357
3652
  const why = all.flatMap((x) => x.why)
3358
3653
  document.getElementById('setTab').title = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
3654
+ paintMoreDots()
3655
+ }
3656
+
3657
+ // ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
3658
+ // From 700px up the sections sit in a rail on the left; under it, in a bottom
3659
+ // bar with the rest under More. Hover the Needs you item (or long-press it on a
3660
+ // phone, or press ↓/→ on it) for Quick look: the count and the five longest
3661
+ // waits, with Allow / Deny on permission prompts (the Needs you page's own
3662
+ // buttons, so the same checks apply and nothing is answered without a click).
3663
+ const railMode = () => window.matchMedia('(min-width: 700px)').matches
3664
+ function paintNav(r = state.route || parseRoute()) {
3665
+ const key = navKey(r.name)
3666
+ 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') })
3667
+ const more = document.getElementById('moreBtn')
3668
+ if (more) more.classList.toggle('on', moreActive(key))
3669
+ }
3670
+ function paintMoreDots() {
3671
+ const wn = document.querySelector('#wnTab .newdot'), st = document.querySelector('#setTab .topdot')
3672
+ const mwn = document.querySelector('#moreMenu .newdot'), mst = document.querySelector('#moreMenu .topdot')
3673
+ if (mwn && wn) mwn.hidden = wn.hidden
3674
+ if (mst && st) { mst.hidden = st.hidden; mst.className = st.className }
3675
+ const md = document.querySelector('#moreBtn .moredot')
3676
+ if (md) { md.hidden = !((wn && !wn.hidden) || (st && !st.hidden)); md.className = 'moredot' + (st && !st.hidden && st.classList.contains('warn') ? ' warn' : '') }
3677
+ }
3678
+
3679
+ // More (phones)
3680
+ const moreOpen = () => { const m = document.getElementById('moreMenu'); return !!m && !m.hidden }
3681
+ function closeMore(focusBtn = false) {
3682
+ const m = document.getElementById('moreMenu'), b = document.getElementById('moreBtn')
3683
+ if (!m || m.hidden) return
3684
+ m.hidden = true
3685
+ if (b) { b.setAttribute('aria-expanded', 'false'); if (focusBtn) b.focus() }
3686
+ }
3687
+ function openMore() {
3688
+ const m = document.getElementById('moreMenu'), b = document.getElementById('moreBtn')
3689
+ if (!m) return
3690
+ closeQuickLook(); toggleStatus(false)
3691
+ paintMoreDots()
3692
+ paintPush()
3693
+ m.hidden = false
3694
+ if (b) b.setAttribute('aria-expanded', 'true')
3695
+ const first = m.querySelector('a.on') || m.querySelector('a')
3696
+ if (first) first.focus()
3697
+ }
3698
+ document.getElementById('moreBtn').addEventListener('click', () => (moreOpen() ? closeMore() : openMore()))
3699
+ document.getElementById('moreMenu').addEventListener('click', (e) => { if (e.target.closest('a')) closeMore() })
3700
+ document.getElementById('moreMenu').addEventListener('keydown', (e) => {
3701
+ const items = [...document.querySelectorAll('#moreMenu a')]
3702
+ const i = items.indexOf(document.activeElement)
3703
+ if (e.key === 'Escape') { e.preventDefault(); closeMore(true) }
3704
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const n = items[(i + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]; if (n) n.focus() }
3705
+ else if (e.key === 'Tab') closeMore()
3706
+ })
3707
+
3708
+ // Quick look
3709
+ const qlUi = { open: false, how: null, openT: 0, closeT: 0, suppressClick: false, model: null, seq: 0 }
3710
+ const quickLookOpen = () => qlUi.open
3711
+ function quickLookFocusables() { const p = document.getElementById('quickLook'); return p ? [...p.querySelectorAll('a[href], button:not([disabled])')] : [] }
3712
+ function paintQuickLook() {
3713
+ const pop = document.getElementById('quickLook')
3714
+ if (!pop || !qlUi.open) return
3715
+ const had = pop.contains(document.activeElement) ? document.activeElement : null
3716
+ const keep = had && (had.closest('.ql-item') ? { key: had.closest('.ql-item').dataset.key, choice: had.dataset.choice || '', main: had.classList.contains('ql-main') } : { foot: had.classList.contains('ql-foot') })
3717
+ const model = qlUi.model || { total: Number((state.health || {}).needsYou) || 0, items: [], more: 0 }
3718
+ pop.innerHTML = quickLookHtml(model, { mayAnswer: state.mayAnswer, pending: state.pendingAsk })
3719
+ pop.setAttribute('aria-busy', qlUi.model ? 'false' : 'true')
3720
+ placeQuickLook()
3721
+ if (keep) {
3722
+ let el = null
3723
+ if (keep.key) { const row = [...pop.querySelectorAll('.ql-item')].find((x) => x.dataset.key === keep.key); if (row) el = keep.main ? row.querySelector('.ql-main') : row.querySelector('[data-choice="' + keep.choice + '"]:not([disabled])') }
3724
+ else if (keep.foot) el = pop.querySelector('.ql-foot')
3725
+ ;(el || quickLookFocusables()[0] || pop).focus()
3726
+ }
3727
+ }
3728
+ function placeQuickLook() {
3729
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
3730
+ if (!pop || pop.hidden || !tab) return
3731
+ if (!railMode()) { pop.style.left = ''; pop.style.top = ''; return } // the CSS puts it above the bottom bar
3732
+ const r = tab.getBoundingClientRect(), rail = document.querySelector('.top').getBoundingClientRect()
3733
+ pop.style.left = (Math.max(r.right, rail.right) + 8) + 'px'
3734
+ pop.style.top = Math.max(8, Math.min(r.top - 6, window.innerHeight - 8 - pop.offsetHeight)) + 'px'
3735
+ }
3736
+ // The sessions behind the popover: what the page already has, then a fresh read.
3737
+ async function loadQuickLook() {
3738
+ const seq = ++qlUi.seq
3739
+ if (state.sessions && state.sessions.length && !qlUi.model) { qlUi.model = quickLookItems(state.sessions); paintQuickLook() }
3740
+ try {
3741
+ const { sessions, viewer } = await api('GET', '/v1/sessions?limit=200', undefined, { timeoutMs: 8000 })
3742
+ if (seq !== qlUi.seq) return
3743
+ if (viewer) state.mayAnswer = viewer.mayAnswer !== false
3744
+ qlUi.model = quickLookItems(sessions || [])
3745
+ } catch { if (!qlUi.model) qlUi.model = { total: 0, items: [], more: 0, failed: true } }
3746
+ paintQuickLook()
3747
+ }
3748
+ function openQuickLook(how = 'hover') {
3749
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
3750
+ if (!pop || !tab) return
3751
+ clearTimeout(qlUi.openT); clearTimeout(qlUi.closeT)
3752
+ if (qlUi.open) { if (how !== 'hover') { qlUi.how = how; const f = quickLookFocusables()[0]; if (f) f.focus() } return }
3753
+ closeMore(); toggleStatus(false)
3754
+ qlUi.open = true; qlUi.how = how; qlUi.model = null
3755
+ pop.hidden = false
3756
+ tab.setAttribute('aria-expanded', 'true')
3757
+ // Hover never takes focus; the keyboard and a long-press move into the card (and Tab stays inside it).
3758
+ pop.setAttribute('aria-modal', how === 'hover' ? 'false' : 'true')
3759
+ paintQuickLook()
3760
+ if (how !== 'hover') { const f = quickLookFocusables()[0]; (f || pop).focus() }
3761
+ loadQuickLook()
3762
+ }
3763
+ function closeQuickLook(focusTab = false) {
3764
+ clearTimeout(qlUi.openT); clearTimeout(qlUi.closeT)
3765
+ if (!qlUi.open) return
3766
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
3767
+ const inside = pop && pop.contains(document.activeElement)
3768
+ qlUi.open = false; qlUi.seq++
3769
+ if (pop) { pop.hidden = true; pop.innerHTML = '' }
3770
+ if (tab) { tab.setAttribute('aria-expanded', 'false'); if (focusTab || inside) tab.focus() }
3771
+ }
3772
+ {
3773
+ const tab = document.getElementById('needsTab'), pop = document.getElementById('quickLook')
3774
+ const canHover = () => window.matchMedia('(hover: hover) and (pointer: fine)').matches
3775
+ const later = (fn, ms) => { clearTimeout(qlUi.closeT); clearTimeout(qlUi.openT); return setTimeout(fn, ms) }
3776
+ tab.addEventListener('mouseenter', () => { if (!canHover()) return; clearTimeout(qlUi.closeT); if (!qlUi.open) qlUi.openT = later(() => openQuickLook('hover'), 280) })
3777
+ tab.addEventListener('mouseleave', () => { clearTimeout(qlUi.openT); if (qlUi.open && qlUi.how === 'hover') qlUi.closeT = setTimeout(() => closeQuickLook(), 320) })
3778
+ pop.addEventListener('mouseenter', () => clearTimeout(qlUi.closeT))
3779
+ pop.addEventListener('mouseleave', () => { if (qlUi.open && qlUi.how === 'hover' && !pop.contains(document.activeElement)) qlUi.closeT = setTimeout(() => closeQuickLook(), 320) })
3780
+ // Long-press on a touch screen; the tap that ends it doesn't navigate.
3781
+ let lp = null
3782
+ tab.addEventListener('pointerdown', (e) => {
3783
+ if (e.pointerType === 'mouse') return
3784
+ const x = e.clientX, y = e.clientY
3785
+ clearTimeout(lp && lp.t)
3786
+ lp = { x, y, t: setTimeout(() => { qlUi.suppressClick = true; openQuickLook('touch'); if (navigator.vibrate) try { navigator.vibrate(10) } catch {} }, 450) }
3787
+ })
3788
+ const cancel = () => { if (lp) { clearTimeout(lp.t); lp = null } }
3789
+ tab.addEventListener('pointerup', cancel)
3790
+ tab.addEventListener('pointercancel', cancel)
3791
+ tab.addEventListener('pointermove', (e) => { if (lp && Math.hypot(e.clientX - lp.x, e.clientY - lp.y) > 10) cancel() })
3792
+ tab.addEventListener('contextmenu', (e) => { if (qlUi.open || lp) e.preventDefault() })
3793
+ tab.addEventListener('click', (e) => {
3794
+ if (qlUi.suppressClick) { qlUi.suppressClick = false; e.preventDefault(); return }
3795
+ // A second tap while it's open goes to the page, as the footer does.
3796
+ closeQuickLook()
3797
+ })
3798
+ tab.addEventListener('keydown', (e) => {
3799
+ if (['ArrowDown', 'ArrowRight', 'ArrowUp'].includes(e.key) && !e.altKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); openQuickLook('keyboard') }
3800
+ })
3801
+ pop.addEventListener('keydown', (e) => {
3802
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeQuickLook(true); return }
3803
+ if (e.key !== 'Tab') return
3804
+ const f = quickLookFocusables()
3805
+ if (!f.length) { e.preventDefault(); return }
3806
+ const i = f.indexOf(document.activeElement)
3807
+ if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus() }
3808
+ else if (!e.shiftKey && (i === -1 || i === f.length - 1)) { e.preventDefault(); f[0].focus() }
3809
+ })
3810
+ pop.addEventListener('focusin', () => { clearTimeout(qlUi.closeT); pop.setAttribute('aria-modal', 'true') })
3811
+ pop.addEventListener('click', (e) => { const a = e.target.closest('a[href]'); if (a) closeQuickLook() })
3812
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && qlUi.open) closeQuickLook(true) })
3813
+ document.addEventListener('pointerdown', (e) => {
3814
+ if (qlUi.open && !e.target.closest('#quickLook, #needsTab')) closeQuickLook()
3815
+ if (moreOpen() && !e.target.closest('#moreMenu, #moreBtn')) closeMore()
3816
+ }, true)
3817
+ window.addEventListener('hashchange', () => { closeQuickLook(); closeMore() })
3818
+ window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
3819
+ }
3820
+
3821
+ // ------------------------------------------------------------ tabs for sessions (0.9.8, ui/tabs.js)
3822
+ // The board is the first tab; a session opens as the one preview tab (italic)
3823
+ // that the next click replaces, and stays open once you send a Live message,
3824
+ // type there, choose Keep open or double-click its tab (⌘/Ctrl-click or a
3825
+ // middle-click opens it permanent straight away). Kept per device in
3826
+ // localStorage. By width: under 700px the Open row on the board and ‹ N of M ›
3827
+ // (or a swipe) on a session page; from 700px the strip. A width change (fold,
3828
+ // Split View, rotation) only changes the CSS: the open set, the session and
3829
+ // where you were on the page stay.
3830
+ 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: {} }
3831
+ window.leftoffTabs = { model: () => JSON.parse(serialize(tabsUi.m)) } // read by test:tabs-ui
3832
+ function setTabs(m) {
3833
+ if (m === tabsUi.m) return
3834
+ tabsUi.m = m
3835
+ try { localStorage.setItem(TABS_KEY, serialize(m)) } catch {}
3836
+ }
3837
+ const tabSession = (id) => state.sessions.find((s) => s.id === id)
3838
+ const tabViews = () => tabsUi.m.tabs.map((t) => tabView(t, tabSession(t.id), { title: sessionTitle, working: (s) => liveWorking(s) }))
3839
+ function tabsOnRoute(r) {
3840
+ const id = r.name === 's' && r.arg ? String(r.arg) : null
3841
+ if (tabsUi.active && tabsUi.active !== id) tabsUi.scrolls[tabsUi.active] = window.scrollY || 0
3842
+ if (id) {
3843
+ const permanent = tabsUi.permanent === id
3844
+ setTabs(openTab(tabsUi.m, id, { permanent, hash: keepHash(location.hash), title: (tabSession(id) && sessionTitle(tabSession(id))) || '' }))
3845
+ }
3846
+ tabsUi.permanent = null
3847
+ const changed = tabsUi.active !== id || tabsUi.route !== r.name
3848
+ tabsUi.active = id; tabsUi.route = r.name
3849
+ document.body.dataset.route = r.name
3850
+ paintTabs()
3851
+ if (changed) scrollActiveTab()
3852
+ }
3853
+ function tabsAfterRender() {
3854
+ let m = tabsUi.m
3855
+ for (const t of m.tabs) { const s = tabSession(t.id); if (s) m = remember(m, t.id, { title: sessionTitle(s) }) }
3856
+ setTabs(m)
3857
+ paintTabs()
3858
+ const back = tabsUi.active && tabsUi.restoreScroll === tabsUi.active ? tabsUi.scrolls[tabsUi.active] : null
3859
+ tabsUi.restoreScroll = null
3860
+ if (back != null) window.scrollTo(0, back)
3861
+ noteAnchor()
3862
+ // the other open tabs' pulse and dot: read the sessions again now and then while you're off the board
3863
+ if (tabsUi.route !== 'board' && tabsUi.m.tabs.some((t) => t.id !== tabsUi.active) && Date.now() - tabsUi.fetchedAt > 15000) refreshTabSessions()
3864
+ }
3865
+ async function refreshTabSessions() {
3866
+ tabsUi.fetchedAt = Date.now()
3867
+ try {
3868
+ const { sessions = [], parked = [] } = await api('GET', '/v1/sessions?limit=200')
3869
+ const fresh = new Map(sessions.concat(parked).map((s) => [s.id, s]))
3870
+ 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)))
3871
+ paintTabs()
3872
+ } catch {}
3873
+ }
3874
+ function paintTabs() {
3875
+ const views = tabViews()
3876
+ const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
3877
+ if (!strip) return
3878
+ const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
3879
+ if (sh !== tabsUi.html.strip) {
3880
+ const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
3881
+ const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
3882
+ strip.innerHTML = sh; tabsUi.html.strip = sh
3883
+ const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
3884
+ 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 }) }
3885
+ }
3886
+ strip.hidden = false
3887
+ const rh = openRowHtml(views, esc)
3888
+ 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 }
3889
+ row.hidden = !rh
3890
+ const ph = tabsUi.active ? pagerHtml(tabsUi.m, tabsUi.active, views, esc) : ''
3891
+ if (ph !== tabsUi.html.pager) { pager.innerHTML = ph; tabsUi.html.pager = ph }
3892
+ pager.hidden = !ph
3893
+ }
3894
+ function scrollActiveTab() {
3895
+ const el = document.querySelector('#tabStrip .tab-cell.on'), sc = document.querySelector('#tabStrip .tab-scroll')
3896
+ if (!el || !sc) return
3897
+ const r = el.getBoundingClientRect(), b = sc.getBoundingClientRect()
3898
+ if (r.left < b.left) sc.scrollLeft -= b.left - r.left + 8
3899
+ else if (r.right > b.right) sc.scrollLeft += r.right - b.right + 8
3900
+ }
3901
+ function keepTab(id) { setTabs(promote(tabsUi.m, id)); paintTabs() }
3902
+ function closeTabNow(id) {
3903
+ const { model, next } = closeTab(tabsUi.m, id, tabsUi.active)
3904
+ setTabs(model)
3905
+ delete tabsUi.scrolls[id]
3906
+ if (tabsUi.active === String(id)) {
3907
+ const t = next && model.tabs.find((x) => x.id === next)
3908
+ if (t) tabsUi.restoreScroll = t.id
3909
+ location.hash = t ? tabView(t).href : '#/'
3910
+ } else paintTabs()
3911
+ }
3912
+ function goTab(href) {
3913
+ const id = sessionOfHash(href)
3914
+ if (id) tabsUi.restoreScroll = id
3915
+ if (location.hash === href) render(); else location.hash = href
3916
+ }
3917
+ // ⌘/Ctrl-click or a middle-click on any session link: open it as a permanent tab (here, not a new browser tab)
3918
+ function sessionLinkId(a) { return a && a.getAttribute && sessionOfHash(a.getAttribute('href') || '') }
3919
+ document.addEventListener('click', (e) => {
3920
+ const a = e.target.closest && e.target.closest('a[href^="#/s/"]')
3921
+ if (!a || !(e.metaKey || e.ctrlKey) || e.button !== 0) return
3922
+ const id = sessionLinkId(a); if (!id) return
3923
+ e.preventDefault(); e.stopPropagation()
3924
+ if (indexOf(tabsUi.m, id) >= 0) setTabs(promote(tabsUi.m, id)); else tabsUi.permanent = id
3925
+ if (location.hash === a.getAttribute('href')) render(); else location.hash = a.getAttribute('href')
3926
+ }, true)
3927
+ document.addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest && e.target.closest('a[href^="#/s/"], #tabStrip .tab-cell')) e.preventDefault() }) // no autoscroll
3928
+ document.addEventListener('auxclick', (e) => {
3929
+ if (e.button !== 1 || !e.target.closest) return
3930
+ const cell = e.target.closest('#tabStrip .tab-cell')
3931
+ if (cell) { e.preventDefault(); closeTabNow(cell.dataset.tabId); return } // middle-click a tab: close it
3932
+ const a = e.target.closest('a[href^="#/s/"]')
3933
+ const id = sessionLinkId(a); if (!id) return
3934
+ e.preventDefault()
3935
+ if (indexOf(tabsUi.m, id) >= 0) { setTabs(promote(tabsUi.m, id)); paintTabs() }
3936
+ else { setTabs(openTab(tabsUi.m, id, { permanent: true })); paintTabs() } // opened in the background, the way a middle-click does
3937
+ })
3938
+ document.addEventListener('click', (e) => {
3939
+ if (!e.target.closest) return
3940
+ const close = e.target.closest('[data-tab-close]')
3941
+ if (close) { e.preventDefault(); return closeTabNow(close.dataset.tabClose) }
3942
+ const keep = e.target.closest('[data-tab-keep]')
3943
+ if (keep) { e.preventDefault(); return keepTab(keep.dataset.tabKeep) }
3944
+ const go = e.target.closest('[data-tab-go]')
3945
+ if (go) { e.preventDefault(); return goTab(go.dataset.tabGo) }
3946
+ const all = e.target.closest('#tabAll')
3947
+ if (all) { e.preventDefault(); return openActionMenu(all, allTabsItems(tabViews(), tabsUi.active), { title: 'Open sessions' }) }
3948
+ const t = e.target.closest('#tabStrip a[data-tab], #openRow a[data-tab], #tabPager a[data-tab]')
3949
+ if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
3950
+ })
3951
+ document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
3952
+ // Working in a session keeps its tab: a Live message, typing there, answering Claude there
3953
+ const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
3954
+ for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
3955
+ 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)
3956
+ // Drag a tab to reorder
3957
+ {
3958
+ let dragId = null
3959
+ 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 {} })
3960
+ 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') })
3961
+ 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() } })
3962
+ document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('#tabStrip .dragging, #tabStrip .drop-before').forEach((x) => x.classList.remove('dragging', 'drop-before')) })
3963
+ }
3964
+ // Phones: swipe left / right on a session page for the next / previous open session
3965
+ {
3966
+ let sw = null
3967
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
3968
+ 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 }
3969
+ document.addEventListener('touchstart', (e) => {
3970
+ sw = null
3971
+ if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
3972
+ const t = e.target
3973
+ if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .tabs, .moremenu') || scrollsSideways(t)) return
3974
+ sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
3975
+ }, { passive: true })
3976
+ document.addEventListener('touchend', (e) => {
3977
+ if (!sw || !e.changedTouches.length) return
3978
+ const p = e.changedTouches[0]
3979
+ const dir = swipeDir(p.clientX - sw.x, p.clientY - sw.y, Date.now() - sw.at)
3980
+ sw = null
3981
+ if (!dir || (window.getSelection && String(window.getSelection()))) return
3982
+ const n = neighbours(tabsUi.m, tabsUi.active)
3983
+ const to = dir === 'next' ? n.next : n.prev
3984
+ if (!to) return
3985
+ const t = tabsUi.m.tabs.find((x) => x.id === to)
3986
+ document.body.classList.remove('swipe-next', 'swipe-prev'); void document.body.offsetWidth
3987
+ document.body.classList.add('swipe-' + dir); setTimeout(() => document.body.classList.remove('swipe-' + dir), 260)
3988
+ goTab(tabView(t).href)
3989
+ }, { passive: true })
3990
+ }
3991
+ // A width change keeps your place: the element at the top of the page stays at
3992
+ // the top, and a Live chat that was at its newest stays there.
3993
+ function anchorY() {
3994
+ let y = 0
3995
+ for (const el of [document.querySelector('.top'), document.getElementById('tabStrip'), document.getElementById('tabPager')]) {
3996
+ if (!el) continue
3997
+ const cs = getComputedStyle(el); if (cs.display === 'none' || !/(fixed|sticky)/.test(cs.position)) continue
3998
+ const r = el.getBoundingClientRect(); if (r.top <= 1 && r.bottom < innerHeight / 2) y = Math.max(y, r.bottom)
3999
+ }
4000
+ return y
4001
+ }
4002
+ function noteAnchor() {
4003
+ if (Date.now() < tabsUi.quietUntil) return
4004
+ const y = anchorY() + 6, m = $app.getBoundingClientRect()
4005
+ if (m.bottom < y) { tabsUi.anchor = null; return }
4006
+ let el = document.elementFromPoint(Math.max(m.left + 24, Math.min(m.left + m.width / 2, innerWidth - 24)), y)
4007
+ 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 }
4008
+ tabsUi.anchor = el ? { el, top: el.getBoundingClientRect().top, scrollY: window.scrollY } : null
4009
+ }
4010
+ function keepPlace() {
4011
+ const a = tabsUi.anchor
4012
+ tabsUi.quietUntil = Date.now() + 250
4013
+ if (a && a.el.isConnected && a.scrollY > 0) window.scrollBy(0, a.el.getBoundingClientRect().top - a.top)
4014
+ document.querySelectorAll('.live-log').forEach((log) => { const st = tabsUi.logs.get(log); if (st && st.bottom) log.scrollTop = log.scrollHeight })
4015
+ }
4016
+ {
4017
+ let raf = 0
4018
+ window.addEventListener('scroll', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; noteAnchor() }) }, { passive: true })
4019
+ 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)
4020
+ let lay = layoutFor(innerWidth)
4021
+ window.addEventListener('resize', () => {
4022
+ keepPlace()
4023
+ const now = layoutFor(innerWidth)
4024
+ if (now !== lay) { lay = now; document.body.dataset.layout = now; scrollActiveTab() }
4025
+ })
4026
+ document.body.dataset.layout = lay
4027
+ }
4028
+
4029
+ // Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
4030
+ // past the line; the page reads its data again, the same as a live update (no
4031
+ // full reload unless that fails). Never from a field, the Live box or a menu.
4032
+ {
4033
+ const ptr = document.createElement('div')
4034
+ ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
4035
+ 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>'
4036
+ document.body.appendChild(ptr)
4037
+ const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
4038
+ const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
4039
+ let pull = null, refreshing = false
4040
+ const paint = (step) => {
4041
+ ptr.hidden = !step || (!step.pulling && !refreshing)
4042
+ if (ptr.hidden) return
4043
+ ptr.style.transform = 'translate(-50%, ' + (refreshing ? 56 : step.dist) + 'px)'
4044
+ ptr.style.setProperty('--ptr-turn', String(Math.round((step.progress || 0) * 270)) + 'deg')
4045
+ ptr.style.opacity = refreshing ? '1' : String(Math.min(1, 0.25 + (step.progress || 0)))
4046
+ ptr.classList.toggle('armed', !!step.armed)
4047
+ ptr.classList.toggle('busy', refreshing)
4048
+ }
4049
+ async function pullRefresh() {
4050
+ refreshing = true; paint({ pulling: true, dist: 56, progress: 1, armed: true })
4051
+ const started = Date.now()
4052
+ try {
4053
+ prsUi.at = {}; projectsUi.at = 0
4054
+ const r = state.route || parseRoute()
4055
+ const editing = busy() || ['settings', 'setup'].includes(r.name)
4056
+ await Promise.all([editing ? null : render(), refreshHealth()])
4057
+ } catch { location.reload(); return }
4058
+ await new Promise((res) => setTimeout(res, Math.max(0, 450 - (Date.now() - started)))) // long enough to see it turn
4059
+ refreshing = false; ptr.classList.remove('busy', 'armed'); ptr.hidden = true
4060
+ }
4061
+ window.leftoffPullRefresh = pullRefresh
4062
+ document.addEventListener('touchstart', (e) => {
4063
+ if (refreshing || e.touches.length !== 1) { pull = null; return }
4064
+ const t = e.touches[0]
4065
+ pull = canStartPull({ coarse: coarse.matches, narrow: narrow.matches, scrollY: window.scrollY || document.scrollingElement.scrollTop, target: e.target, overlayOpen: overlayOpen() }) ? { x: t.clientX, y: t.clientY, step: null } : null
4066
+ }, { passive: true })
4067
+ document.addEventListener('touchmove', (e) => {
4068
+ if (!pull || e.touches.length !== 1) return
4069
+ const t = e.touches[0]
4070
+ const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
4071
+ if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
4072
+ if ((window.scrollY || 0) > 0) { pull = null; paint(null); return }
4073
+ if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
4074
+ pull.step = step
4075
+ paint(step)
4076
+ }, { passive: false })
4077
+ const end = () => {
4078
+ if (!pull) return
4079
+ const what = pullRelease(pull.step)
4080
+ pull = null
4081
+ if (what === 'refresh') pullRefresh()
4082
+ else paint(null)
4083
+ }
4084
+ document.addEventListener('touchend', end, { passive: true })
4085
+ document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
4086
+ }
4087
+
4088
+ // Hide the nav on scroll (phones, 0.9.8): under 700px the top row and the bottom
4089
+ // bar slide away while you scroll down and come back when you scroll up, reach
4090
+ // the top or the very bottom (ui/navhide.js). Only your own scrolling hides
4091
+ // them: a route change, the ‹ › pager or a swipe shows them and holds them until
4092
+ // you next touch, wheel or key; typing, a menu or a sheet keeps them shown. The
4093
+ // page never reflows: the top row stays in the flow and the bar is an overlay
4094
+ // over padding that stays.
4095
+ {
4096
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
4097
+ const top = document.querySelector('.top')
4098
+ let st = { hidden: false, anchor: 0 }, raf = 0, held = true
4099
+ const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
4100
+ const overlay = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .lo-backdrop, .doc-viewer:not([hidden]), .emoji-sheet-wrap, .relay-screen:not([hidden])')
4101
+ const apply = (hidden) => {
4102
+ if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
4103
+ document.body.classList.toggle('nav-hidden', hidden)
4104
+ }
4105
+ const read = () => {
4106
+ raf = 0
4107
+ const se = document.scrollingElement || document.documentElement
4108
+ const y = window.scrollY || se.scrollTop || 0
4109
+ const next = navHideStep(st, { y, max: se.scrollHeight - window.innerHeight, blocked: held || !narrowQ.matches || typing() || overlay() })
4110
+ if (next.hidden !== st.hidden) apply(next.hidden)
4111
+ st = next
4112
+ }
4113
+ const soon = () => { if (!raf) raf = requestAnimationFrame(read) }
4114
+ // no layout read here: this runs on every route change, ahead of the page's own render
4115
+ const showNav = (hold = true) => { if (hold) held = true; const was = st.hidden; st = { hidden: false, anchor: NaN }; if (was) apply(false) }
4116
+ window.addEventListener('scroll', soon, { passive: true })
4117
+ for (const ev of ['touchstart', 'wheel', 'keydown']) window.addEventListener(ev, () => { held = false }, { passive: true, capture: true })
4118
+ window.addEventListener('hashchange', () => showNav(true))
4119
+ document.addEventListener('focusin', (e) => { if (typing() || (top && top.contains(e.target))) showNav(false) })
4120
+ document.addEventListener('click', soon, { passive: true, capture: true }) // a menu or sheet just opened: back they come
4121
+ narrowQ.addEventListener('change', () => showNav(false))
4122
+ window.leftoffNav = { show: () => showNav(true), hidden: () => document.body.classList.contains('nav-hidden') }
3359
4123
  }
3360
4124
 
3361
4125
  // Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
@@ -3412,6 +4176,7 @@ function placeStatus() {
3412
4176
  const pop = document.getElementById('statusPop'), b = document.getElementById('health')
3413
4177
  if (!pop || pop.hidden || !b) return
3414
4178
  const r = b.getBoundingClientRect(), vw = document.documentElement.clientWidth
4179
+ if (railMode()) { pop.style.right = 'auto'; pop.style.left = (document.querySelector('.top').getBoundingClientRect().right + 8) + 'px'; pop.style.top = Math.max(8, Math.min(r.bottom - pop.offsetHeight, window.innerHeight - 8 - pop.offsetHeight)) + 'px'; return } // 0.9.8: beside the rail
3415
4180
  if (vw < 600) { pop.style.left = '8px'; pop.style.right = '8px'; pop.style.top = (r.bottom + 8) + 'px'; return }
3416
4181
  pop.style.right = 'auto'
3417
4182
  pop.style.top = (r.bottom + 8) + 'px'
@@ -3639,11 +4404,27 @@ document.addEventListener('keydown', (e) => {
3639
4404
 
3640
4405
  // A card opens its session's page from anywhere on it, except its buttons,
3641
4406
  // links, fields and selected text (0.4.3: the timeline was too hidden).
4407
+ // 0.9.8: the tap shows at once (a pressed card with a spinner); the page's
4408
+ // shell follows in the same frame (viewSession).
4409
+ function cardOpening(c) {
4410
+ for (const x of document.querySelectorAll('.card.opening')) if (x !== c) x.classList.remove('opening')
4411
+ c.classList.add('opening')
4412
+ setTimeout(() => { if (c.isConnected) c.classList.remove('opening') }, 4000)
4413
+ }
4414
+ document.addEventListener('pointerdown', (ev) => {
4415
+ const c = ev.target.closest && ev.target.closest('.card[data-href]')
4416
+ if (!c || ev.button > 0 || ev.target.closest('button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
4417
+ const a = ev.target.closest('a')
4418
+ if (a && !/^#\/s\//.test(a.getAttribute('href') || '')) return // a repo chip, a PR link: not opening the session
4419
+ cardOpening(c)
4420
+ }, { passive: true })
4421
+ document.addEventListener('pointercancel', () => { for (const x of document.querySelectorAll('.card.opening')) x.classList.remove('opening') }, true)
3642
4422
  document.addEventListener('click', (ev) => {
3643
4423
  const c = ev.target.closest('.card[data-href]')
3644
4424
  if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
3645
4425
  if (String(window.getSelection && window.getSelection()).trim()) return
3646
4426
  if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
4427
+ cardOpening(c)
3647
4428
  location.hash = c.dataset.href
3648
4429
  })
3649
4430
 
@@ -3670,6 +4451,8 @@ document.addEventListener('input', (ev) => {
3670
4451
  document.addEventListener('keydown', (ev) => { if (ev.target.id === 'prs-q' && ev.key === 'Escape' && ev.target.value) { ev.preventDefault(); ev.target.value = ''; ev.target.dispatchEvent(new Event('input', { bubbles: true })) } })
3671
4452
 
3672
4453
  window.addEventListener('hashchange', () => closeActionMenu())
4454
+ const tipsUi = wireTips(document) // 0.9.8: the ⓘ tooltips (Always allow here)
4455
+ window.addEventListener('hashchange', () => tipsUi.hide())
3673
4456
  // 0.8.9: a session page's title is a button too (Enter or Space renames it)
3674
4457
  document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"]')) { e.preventDefault(); t.click() } })
3675
4458
  // 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
@@ -4003,6 +4786,8 @@ document.addEventListener('click', async (ev) => {
4003
4786
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
4004
4787
  if (action === 'reply-send') return replyClick(btn)
4005
4788
  if (action === 'live-send') return liveSendClick(btn)
4789
+ if (action === 'live-jump') return liveJumpClick(btn)
4790
+ if (action === 'live-earlier') return liveEarlierClick(btn)
4006
4791
  if (action === 'quick-reply') return quickReplyClick(btn)
4007
4792
  if (action === 'need-more') return needMoreClick(btn)
4008
4793
  if (action === 'need-dismiss') return needDismissClick(btn)
@@ -4130,7 +4915,7 @@ document.addEventListener('click', async (ev) => {
4130
4915
  // Answer a permission prompt or a question from the board. The daemon hands it
4131
4916
  // to the waiting hook or mod; the terminal's own prompt may already have won.
4132
4917
  async function answerClick(btn, action) {
4133
- const need = btn.closest('.need')
4918
+ const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
4134
4919
  if (!need) return
4135
4920
  const askId = need.dataset.ask, key = need.dataset.key
4136
4921
  const s = state.sessions.find((x) => x.id === need.dataset.id)
@@ -4164,6 +4949,7 @@ async function answerClick(btn, action) {
4164
4949
  toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
4165
4950
  } finally {
4166
4951
  delete state.pendingAsk[key]
4952
+ if (need.classList.contains('ql-item')) { await loadQuickLook(); refreshHealth() }
4167
4953
  render()
4168
4954
  }
4169
4955
  }
@@ -4479,7 +5265,7 @@ document.addEventListener('submit', async (ev) => {
4479
5265
 
4480
5266
  // Live updates over SSE; re-render the current view, at most every 800 ms.
4481
5267
  let pending = null
4482
- const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value)
5268
+ const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value)
4483
5269
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
4484
5270
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
4485
5271
  function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name)) && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
@@ -4487,16 +5273,21 @@ let stream = null
4487
5273
  function connect() {
4488
5274
  if (stream) { try { stream.close() } catch {} }
4489
5275
  const es = stream = new EventSource('/v1/stream')
5276
+ const again = connect.opened // a reconnect: events may have been missed while it was down
5277
+ streamDog.opened()
5278
+ // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
5279
+ es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
4490
5280
  es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
4491
5281
  es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
4492
- 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)
5282
+ 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() })
4493
5283
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
4494
5284
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
4495
5285
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
4496
5286
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
4497
5287
  // port; if the browser gave up on the stream, open a new one.
4498
5288
  es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
4499
- es.onopen = () => refreshHealth()
5289
+ let opens = 0
5290
+ es.onopen = () => { if (stream !== es) return; streamDog.seen(); refreshHealth(); if (again || opens++) refreshView('reconnect'); connect.opened = true }
4500
5291
  }
4501
5292
 
4502
5293
  document.addEventListener('change', async (ev) => {
@@ -4605,3 +5396,6 @@ connect()
4605
5396
  // The home-screen app's service worker (v0.4.6): it only serves an offline page
4606
5397
  // when the Mac can't be reached; it caches no API data and no board pages.
4607
5398
  if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
5399
+ // 0.9.8: phone notifications. The one per-device button (More on a phone,
5400
+ // Settings → General everywhere); the service worker shows the pushes.
5401
+ mountPush({ api })