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

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,14 @@ 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 { openLightbox } from './lightbox.js' // 0.9.8: the image preview
16
+ import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
17
+ import { isBlank, composerKey, isTouchUi, composerHeight } from './composer.js' // 0.9.8 the Live composer
18
+ import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
19
+ import { wireTips } from './tips.js'
12
20
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
13
21
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
14
22
  import { mdInline, renderSection } from './changelog-md.js'
@@ -16,8 +24,15 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
16
24
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
17
25
  import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
18
26
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
27
+ 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
28
+ import { statusView, elapsedText } from './live-status.js'
29
+ 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
30
+ import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
31
+ 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
32
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
20
33
  const $app = document.getElementById('app')
34
+ // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
35
+ if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
21
36
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
22
37
  const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
23
38
  const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
@@ -108,12 +123,16 @@ async function copy(text) {
108
123
  // 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
109
124
  function copiedMark(btn) {
110
125
  clearTimeout(btn._copied)
126
+ const tip = btn._tip || (btn._tip = btn.title || 'Copy resume command')
111
127
  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)
128
+ btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
113
129
  }
114
130
  const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
115
131
  // 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>' : '')
132
+ // It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
133
+ const liveBadge = (s) => liveBadgeHtml(s)
134
+ // The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
135
+ const statusPill = (s) => { const st = cardPills(s).status; return st ? pill(st) : '' }
117
136
 
118
137
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
119
138
 
@@ -177,11 +196,11 @@ function wtChips(s) {
177
196
  return h
178
197
  }
179
198
 
180
- function where(s) {
199
+ function where(s, { repo = true } = {}) {
181
200
  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
201
  const branch = s.repo && s.repo.branch
183
202
  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) +
203
+ 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
204
  (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
205
  }
187
206
 
@@ -380,9 +399,13 @@ function liveNote(s) {
380
399
  if (s.live === 'open' && s.liveVia === 'heartbeat' && !s.isLocal) return 'open (heartbeat)'
381
400
  return s.live === 'open' ? 'open' : ''
382
401
  }
383
- function goButton(s) {
402
+ // 0.9.8: { page: true } is the session page's header, where a Go that can't run
403
+ // is left out (its tooltip was the only place the reason showed, and the grey
404
+ // box read as broken); a card keeps the dimmed Go and its short why.
405
+ function goButton(s, { page = false } = {}) {
384
406
  if (s.live === 'closed') return ''
385
407
  const j = s.jump || {}
408
+ if (page && (state.viewerLocal === false || !j.supported)) return ''
386
409
  // Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
387
410
  if (!s.isLocal) {
388
411
  if (!j.peer) return ''
@@ -432,8 +455,12 @@ function agentCard(s) {
432
455
  // closed), a small copy icon for the resume command, and "⋯" for the rest. The
433
456
  // ⋯ menu's items ride in the card as an inert <template> (real buttons with the
434
457
  // same data-action as before), so the one click handler below runs them unchanged.
458
+ // What Resume runs: codex resume, claude --resume, or for a Live session (0.9.8)
459
+ // leftoff live -- --resume, so it comes back Live.
460
+ function resumeVerb(s) { return s.codingAgent === 'codex' ? 'codex resume' : s.resumeLive ? 'leftoff live -- --resume' : 'claude --resume' }
461
+ function resumeTip(s, lead) { return lead + ': ' + resumeVerb(s) + (s.resumeLive ? ' (it comes back Live)' : '') }
435
462
  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>'
463
+ 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
464
  }
438
465
  // items: [{ action, label, icon, title, attrs }]
439
466
  // 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
@@ -550,7 +577,7 @@ function checkpointSplit(s) {
550
577
  '<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
551
578
  }
552
579
  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>' +
580
+ '<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
581
  '<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
582
  }
556
583
  function openLengthMenu(btn, id) {
@@ -574,15 +601,15 @@ function card(s) {
574
601
  const nb = s.needsYou ? cardBadge(s.needsYou) : ''
575
602
  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
603
  '<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>' +
604
+ (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
578
605
  '<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>' +
606
+ '</div>' + statusPill(s) + liveBadge(s) + '</div>' +
607
+ '<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
608
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
582
609
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
583
610
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
584
611
  '<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) +
612
+ chatButton(s) + copyResumeIcon(s) +
586
613
  (s.isLocal ? '' : remoteNote(s)) + moreMenu(s, sessionMenuItems(s)) +
587
614
  '</div></article>'
588
615
  }
@@ -753,7 +780,7 @@ async function viewBoard() {
753
780
  // not a resumed conversation) fold into one "N empty sessions" row. One
754
781
  // appears on the board as itself after its first prompt. Pinned ones, and
755
782
  // ones that need you, always show.
756
- const isFolded = (s) => s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking)
783
+ const isFolded = (s) => foldsAsEmpty(s) // 0.9.8: never a live session
757
784
  const shown = recent.filter((s) => !isFolded(s))
758
785
  const empties = recent.filter(isFolded)
759
786
  const rank = Object.fromEntries(ORDER.map(([k], i) => [k, i]))
@@ -801,6 +828,7 @@ async function viewBoard() {
801
828
  html += projectRow(spaced, pj, 'board-project')
802
829
  if (filter === 'closed') {
803
830
  html += await closedSection(f, sp)
831
+ if (parseRoute().name === 's') return // 0.9.8: you opened a session while the board loaded
804
832
  $app.innerHTML = banners() + html
805
833
  return closedWire()
806
834
  }
@@ -842,6 +870,7 @@ function boardSearch(q, closedHits, n) {
842
870
  // A repaint from a live update (or the 30 s refresh) while you type in the
843
871
  // search box keeps the box focused, with what you typed and the caret.
844
872
  function paintBoardKeepingSearch(h) {
873
+ if (parseRoute().name === 's') return // 0.9.8: a slow board answer never paints over the session you opened
845
874
  const old = document.getElementById('boardQ')
846
875
  const keep = old && document.activeElement === old ? { v: old.value, pos: old.selectionStart } : null
847
876
  $app.innerHTML = h
@@ -921,10 +950,10 @@ function resumeButton(s, viewer = {}) {
921
950
  const busy = closedUi.busy.has(s.id)
922
951
  if (s.isLocal) {
923
952
  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>'
953
+ 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
954
  }
926
955
  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>'
956
+ 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
957
  }
929
958
  function closedCard(s, viewer) {
930
959
  const href = '#/s/' + encodeURIComponent(s.id)
@@ -1168,9 +1197,11 @@ async function viewWaiting() {
1168
1197
  // The nav's count, the page title and the favicon dot: visible from a background tab.
1169
1198
  function paintNeedsCount(n) {
1170
1199
  const c = document.getElementById('needsCount')
1171
- if (c) { c.hidden = !n; c.textContent = n > 99 ? '99+' : String(n) }
1200
+ if (c) { c.hidden = !n; c.textContent = badgeText(n) }
1172
1201
  const tab = document.getElementById('needsTab')
1173
1202
  if (tab) { tab.classList.toggle('has-needs', !!n); tab.title = n ? n + ' waiting on you' : 'Nothing waiting on you' }
1203
+ if (quickLookOpen() && paintNeedsCount.count !== n) loadQuickLook()
1204
+ paintNeedsCount.count = n
1174
1205
  document.title = (n ? '(' + n + ') ' : '') + 'Leftoff'
1175
1206
  // The tab icon gets a red dot while something waits (leftoff-favicon-badge,
1176
1207
  // v0.4.5); the PNG is for browsers without SVG favicons.
@@ -1239,8 +1270,8 @@ async function viewAgentSession(s) {
1239
1270
  const name = esc(a.display || a.name)
1240
1271
  const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
1241
1272
  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>'
1273
+ 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>' +
1274
+ '<div class="actions s-actions">' + agentReplyButton(a) + '</div></div>'
1244
1275
  // v0.5.6: agent.url only links the name; the reply affordance and its
1245
1276
  // words come from the feed (agent.reply, agent.replyHint), or nothing.
1246
1277
  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 +1292,117 @@ async function viewAgentSession(s) {
1261
1292
  $app.innerHTML = banners() + html
1262
1293
  }
1263
1294
 
1295
+ // 0.9.8: another Mac's session whose Mac is offline says so plainly, once, near the top
1296
+ function ownerOffline(s) {
1297
+ if (!s || s.isLocal !== false || s.agent || !s.machine) return false
1298
+ const st = window.leftoffRelay && state.relayStatus
1299
+ const x = st && (st.macs || []).find((y) => y.machine === s.machine)
1300
+ if (x) return !x.online
1301
+ return !!(s.peer && s.peer.code === 'unreachable')
1302
+ }
1303
+ function ownerOfflineNote(s) {
1304
+ if (!ownerOffline(s)) return ''
1305
+ const m = esc(s.machine), sn = staleNote(s.machine)
1306
+ 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>'
1307
+ }
1308
+ // 0.9.8: opening a session is instant. The tap paints the page's shell at
1309
+ // once (title and pills from the card we already have, or the whole page from
1310
+ // the last visit), then every fetch runs in parallel (the session, its Live
1311
+ // thread, spaces, projects, lists) instead of one after another; on a phone
1312
+ // through the relay each was a full round trip. A newer navigation wins: a
1313
+ // slow answer for a page you already left never paints over the one you're on.
1314
+ const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few sessions you opened
1315
+ const SESSION_CACHE_MAX = 8
1316
+ function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
1317
+ const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
1318
+ function sessionShellHtml(s) {
1319
+ const head = s
1320
+ ? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + statusPill(s) + liveBadge(s) + '</div>'
1321
+ : '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
1322
+ 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>' +
1323
+ '<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>' +
1324
+ '<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>'
1325
+ }
1326
+ // A selector that finds the same control again after a redraw (null: it can't be told apart).
1327
+ function focusKey(el) {
1328
+ if (!el || !el.tagName) return null
1329
+ if (el.id) return '#' + CSS.escape(el.id)
1330
+ const d = el.dataset || {}
1331
+ const keys = ['action', 'lid', 'sid', 'att', 'id', 'tab', 'submitFor'].filter((k) => d[k] != null)
1332
+ if (!keys.length) return null
1333
+ const attr = (k) => '[data-' + k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + '="' + CSS.escape(d[k]) + '"]'
1334
+ return el.tagName.toLowerCase() + keys.map(attr).join('')
1335
+ }
1336
+ function paintSession(s, data) {
1337
+ if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
1338
+ // a redraw (the live poll, a settled reply) keeps the composer focused with its caret where it was
1339
+ const fa = document.activeElement
1340
+ const kf = fa && fa.classList && fa.classList.contains('composer-input') && $app.contains(fa) ? { d: fa.dataset.draft, a: fa.selectionStart, b: fa.selectionEnd } : null
1341
+ // …and any other control that had focus (a chip, a thumbnail, a button: a
1342
+ // preview closed with Esc puts focus back on one, then a redraw replaced it)
1343
+ const kb = !kf && fa && fa !== document.body && $app.contains(fa) ? focusKey(fa) : null
1344
+ liveKeeper.before($app)
1345
+ $app.innerHTML = sessionPageHtml(s, data)
1346
+ liveAfterRender(s.id)
1347
+ if (kf) {
1348
+ const nt = $app.querySelector('.composer-input')
1349
+ if (nt && nt.dataset.draft === kf.d && !nt.disabled) { nt.focus({ preventScroll: true }); try { const n = nt.value.length; nt.setSelectionRange(Math.min(kf.a, n), Math.min(kf.b, n)) } catch {} }
1350
+ } else if (kb && !$app.contains(document.activeElement)) {
1351
+ const hits = $app.querySelectorAll(kb)
1352
+ if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true })
1353
+ }
1354
+ aroundWire(s.id)
1355
+ }
1264
1356
  async function viewSession(id) {
1265
1357
  if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
1266
- const s = await api('GET', '/v1/sessions/' + encodeURIComponent(id))
1358
+ const showing = $app.querySelector('.s-head[data-sid="' + CSS.escape(id) + '"]') // a re-render of the page you're on: no shell
1359
+ const cached = sessionCache.get(id)
1360
+ const known = (cached && cached.s) || state.sessions.find((x) => x.id === id) || null
1361
+ if (!showing) {
1362
+ if (cached) paintSession(cached.s, cached) // the last visit, at once; fresh data replaces it below
1363
+ else { liveKeeper.before($app); $app.innerHTML = sessionShellHtml(known) }
1364
+ }
1365
+ // on leftoff.dev, another Mac's session loads its data from that Mac: say so before the first call
1366
+ const owner = (x) => { if (x && window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(x.id, x.isLocal === false && !x.agent ? x.machine : null) }
1367
+ owner(known)
1368
+ const pS = api('GET', '/v1/sessions/' + encodeURIComponent(id))
1369
+ const pLive = !known || liveWanted(known) ? fetchLive(id) : null // the thread doesn't wait for the session
1370
+ const pLists = known && known.lists && known.lists.length ? fetchLists(id) : null
1371
+ const pRest = Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1372
+ let s
1373
+ try { s = await pS } catch (e) { if (!onSession(id)) return; throw e }
1267
1374
  state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
1375
+ owner(s)
1376
+ const status = s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null
1377
+ const lists = s.lists && s.lists.length ? (pLists || fetchLists(id)) : null
1378
+ const live = liveWanted(s) ? (pLive || fetchLive(id)) : !isLive(s) && !s.endedAt && s.live !== 'closed' ? liveHow().then(() => null) : null
1379
+ let [[spv], v, sl] = await Promise.all([pRest, live, lists, status])
1380
+ if (!onSession(id)) return // you moved on while this loaded
1381
+ // a Live fetch that failed keeps the thread you had (its bubbles say "Reconnecting…"), not an empty one
1382
+ const prev = sessionCache.get(id)
1383
+ if (v && v.error && prev && prev.live && !prev.live.error) v = { ...prev.live, error: null }
1384
+ const data = { spv, live: v, lists: sl }
1385
+ if (v && !v.error) cacheSession(id, { s, ...data, live: v })
1386
+ paintSession(s, data)
1387
+ }
1388
+ const fetchLists = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists').catch(() => null)
1389
+ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {}) {
1268
1390
  const branch = s.repo && s.repo.branch
1269
- const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
1270
1391
  let html = '<a class="back" href="#/">← All sessions</a>'
1271
1392
  // 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
1393
  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>'
1394
+ html += '<div class="pagehead s-head" data-sid="' + esc(s.id) + '"><div class="s-head-main">' + (canRename
1395
+ ? '<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>'
1396
+ : '<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>' +
1397
+ // 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
1398
+ '<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>'
1399
+ html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
1277
1400
  html += '<div class="facts">' +
1278
1401
  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
1402
  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
1403
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1281
1404
  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)
1405
+ if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
1283
1406
  html += parkRow(s)
1284
1407
  html += spacePicker(s, spv, 'session', s.id)
1285
1408
  html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
@@ -1288,15 +1411,14 @@ async function viewSession(id) {
1288
1411
  html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
1289
1412
  // 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
1290
1413
  if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
1291
- if (!isLive(s)) html += await liveSection(s)
1414
+ if (!isLive(s)) html += liveBoxHtml(s, liveV)
1292
1415
  if (s.statusChanges && s.statusChanges.length) {
1293
1416
  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
1417
  }
1295
1418
  // Lists (v0.5.1): its saved lists in full, the hint, and Find lists in this session
1296
1419
  if (s.lists && s.lists.length) {
1297
- const sl = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists').catch(() => null)
1298
1420
  state.listFull = {}
1299
- for (const l of (sl && sl.lists) || []) state.listFull[l.ref] = l
1421
+ for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l
1300
1422
  }
1301
1423
  state.listsMayEdit = state.mayAnswer !== false
1302
1424
  html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
@@ -1314,59 +1436,597 @@ async function viewSession(id) {
1314
1436
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
1315
1437
  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
1438
  html += answeredList(s)
1317
- $app.innerHTML = html
1318
- aroundWire(s.id)
1439
+ return html
1319
1440
  }
1320
1441
  // ------------------------------------------------------------ Live link (0.9.8 beta)
1321
1442
 
1322
1443
  const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
1323
1444
  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>'
1445
+ // 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
1446
+ // bottom); Claude's answer is the session's own (markdown, like the terminal).
1447
+ const liveKeeper = createLiveKeeper()
1448
+ const liveSeenKey = (id) => 'lo-live-seen:' + id
1449
+ function liveSeen(id) { try { return localStorage.getItem(liveSeenKey(id)) } catch { return null } }
1450
+ function markLiveSeen(id, at) { try { localStorage.setItem(liveSeenKey(id), at || new Date().toISOString()) } catch {} }
1451
+ // "Chat" on a live card: straight to the Live box (#/s/<id>/live), with a dot when Claude replied since you last looked.
1452
+ function chatButton(s) {
1453
+ if (!cardPills(s).live || s.agent) return ''
1454
+ const unread = hasUnread(s.liveLink && s.liveLink.lastReplyAt, liveSeen(s.id))
1455
+ 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
1456
  }
1330
1457
  // The how-to for a session that isn't live (fetched once: the claude line has this Mac's path).
1331
1458
  async function liveHow() {
1332
1459
  if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
1333
1460
  return state.liveHow
1334
1461
  }
1335
- async function liveSection(s) {
1462
+ // Does this session's page show a Live thread (and so fetch one)?
1463
+ const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
1464
+ // GET the Live thread; what it says is the newest the page knows (0.9.8: the
1465
+ // time of the last good answer decides "Reconnecting…" on a pending bubble).
1466
+ async function fetchLive(id, { timeoutMs } = {}) {
1467
+ const turns = (state.liveTurns && state.liveTurns[id]) || 20 // "Show earlier" adds 20 at a time
1468
+ try {
1469
+ const v = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/live' + (turns > 20 ? '?turns=' + turns : ''), null, timeoutMs ? { timeoutMs } : {})
1470
+ noteLive(id, v)
1471
+ return v
1472
+ } catch (e) { return { messages: [], error: e.message } }
1473
+ }
1474
+ function liveBoxHtml(s, v) {
1336
1475
  if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
1337
1476
  if (!isLive(s)) {
1338
1477
  if (s.endedAt || s.live === 'closed') return ''
1339
- const how = await liveHow()
1478
+ const how = state.liveHow
1340
1479
  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
1480
  '<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
1481
  (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
1482
  }
1344
- let v = null
1345
- try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
1346
1483
  const msgs = (v && v.messages) || []
1347
1484
  const may = state.mayAnswer !== false
1348
1485
  const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
1486
+ const now = Date.now()
1487
+ const lastClaude = [...msgs].reverse().find((m) => m.from === 'claude')
1488
+ if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1349
1489
  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>') +
1490
+ '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1491
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
1492
+ '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
1493
+ '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1351
1494
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1352
- '<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
- '<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>' +
1495
+ composerHtml(s, draft, may) +
1354
1496
  (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
1497
  }
1498
+ // The Live composer: one rounded box, a textarea that starts at one line and
1499
+ // grows, and a round ↑ in its corner (dim until there's something to send).
1500
+ // No interrupt reaches Claude Code from the board, so while it works the
1501
+ // button still sends: the message waits for its next turn, as before.
1502
+ const SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
1503
+ const CLIP_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
1504
+ const FILE_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>'
1505
+ function composerHtml(s, draft, may) {
1506
+ const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
1507
+ const atts = attachList(s.id)
1508
+ const off = !may || (isBlank(draft) && !atts.length)
1509
+ return '<div class="composer live-row' + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '" data-sid="' + esc(s.id) + '">' +
1510
+ '<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
1511
+ '<textarea class="composer-input live-text" rows="1" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" autocomplete="off" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd) + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
1512
+ // the phone's own picker (Photo Library, Take Photo, Choose File): any file
1513
+ '<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
1514
+ '<button type="button" class="composer-attach" data-action="attach" data-id="' + esc(s.id) + '" aria-label="Attach" title="Attach photos or files"' + (may ? '' : ' disabled') + '><span class="composer-clip">' + CLIP_IC + '</span></button>' +
1515
+ '<button type="button" class="composer-send" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '" aria-label="Send" title="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
1516
+ }
1517
+
1518
+ // ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
1519
+ // Each file uploads to the Mac that runs the session as soon as you attach it;
1520
+ // the chips live here (not in the DOM), so a redraw of the page keeps them.
1521
+ const attachUi = { by: new Map(), urls: new Map(), blobs: new Map(), loading: new Map(), seq: 0, running: 0, queue: [] }
1522
+ const attachList = (sid) => attachUi.by.get(sid) || []
1523
+ function chipHtml(a) {
1524
+ const pct = a.total ? Math.min(100, Math.round((a.sent / a.total) * 100)) : 0
1525
+ const status = a.status === 'failed' ? 'Didn’t upload' : a.status === 'done' ? fmtSize(a.size) : a.status === 'prep' ? 'Preparing…' : fmtSize(a.size) + ' · ' + pct + '%'
1526
+ return '<div class="att-chip ' + esc(a.status) + '" data-lid="' + esc(a.lid) + '"' + (a.error ? ' title="' + esc(a.error) + '"' : '') + '>' +
1527
+ '<button type="button" class="att-open" data-action="att-chip-open" data-lid="' + esc(a.lid) + '" aria-label="Preview ' + esc(a.name) + '">' +
1528
+ '<span class="att-thumb">' + (a.preview ? '<img src="' + esc(a.preview) + '" alt="">' : FILE_IC) + '</span>' +
1529
+ '<span class="att-meta"><span class="att-name" title="' + esc(a.name) + '">' + esc(shortName(a.name)) + '</span><span class="att-size">' + esc(status) + '</span></span></button>' +
1530
+ (a.status === 'failed' ? '<button type="button" class="att-btn att-retry" data-action="att-retry" data-lid="' + esc(a.lid) + '" aria-label="Retry ' + esc(a.name) + '" title="Retry">↻</button>' : '') +
1531
+ '<button type="button" class="att-btn att-x" data-action="att-remove" data-lid="' + esc(a.lid) + '" aria-label="Remove ' + esc(a.name) + '" title="Remove">×</button>' +
1532
+ (a.status === 'up' || a.status === 'prep' ? '<span class="att-bar" aria-hidden="true"><span data-pct="' + pct + '"></span></span>' : '') + '</div>'
1533
+ }
1534
+ // The board's CSP takes no inline styles: a progress bar's width is set here.
1535
+ function barWidths(root) { for (const b of root.querySelectorAll('.att-bar > span[data-pct]')) b.style.width = b.dataset.pct + '%' }
1536
+ // Redraw one session's chips in place (and the ↑'s state).
1537
+ function paintChips(sid) {
1538
+ const box = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"]')
1539
+ if (!box) return
1540
+ const atts = attachList(sid)
1541
+ const wrap = box.querySelector('.att-chips')
1542
+ const fa = wrap.contains(document.activeElement) ? focusKey(document.activeElement) : null // a progress tick keeps the chip you're on focused
1543
+ wrap.innerHTML = atts.map(chipHtml).join('')
1544
+ if (fa) { const t = wrap.querySelector(fa); if (t) t.focus({ preventScroll: true }) }
1545
+ barWidths(wrap)
1546
+ wrap.hidden = !atts.length
1547
+ box.classList.toggle('has-atts', !!atts.length)
1548
+ const ta = box.querySelector('.composer-input')
1549
+ if (ta) syncComposer(ta)
1550
+ }
1551
+ function addFiles(sid, files, { pasted = false } = {}) {
1552
+ const list = Array.from(files || []).filter(Boolean)
1553
+ if (!sid || !list.length) return
1554
+ const have = attachList(sid)
1555
+ const plan = attachPlan(list, have.length)
1556
+ if (plan.skipped.length) toast('Not attached: ' + skippedText(plan.skipped), 4200)
1557
+ const add = plan.take.map((f) => {
1558
+ const file = pasted && /^image\.\w+$|^$/.test(f.name || '') ? new File([f], pastedName(f.type), { type: f.type, lastModified: Date.now() }) : f
1559
+ return { lid: 'a' + (++attachUi.seq), sid, file, name: file.name || 'file', size: file.size, type: file.type || '', kind: attachKind(file), status: 'prep', sent: 0, total: file.size, id: null, preview: null, thumb: null, error: null }
1560
+ })
1561
+ if (!add.length) return
1562
+ attachUi.by.set(sid, [...have, ...add])
1563
+ paintChips(sid)
1564
+ try { promoteActive() } catch {}
1565
+ for (const a of add) startUpload(a)
1566
+ }
1567
+ function startUpload(a) {
1568
+ a.status = 'prep'; a.error = null; a.sent = 0
1569
+ attachUi.queue.push(a); pumpUploads()
1570
+ }
1571
+ // Three at a time: a phone on a slow line shouldn't open ten uploads at once.
1572
+ function pumpUploads() {
1573
+ while (attachUi.running < 3 && attachUi.queue.length) {
1574
+ const a = attachUi.queue.shift()
1575
+ if (!attachList(a.sid).includes(a)) continue
1576
+ attachUi.running++
1577
+ runUpload(a).finally(() => { attachUi.running--; pumpUploads() })
1578
+ }
1579
+ }
1580
+ async function runUpload(a) {
1581
+ const gone = () => !attachList(a.sid).includes(a)
1582
+ try {
1583
+ if (!a.prepared) {
1584
+ const p = await prepareFile(a.file)
1585
+ a.prepared = p.file; a.thumb = p.thumb
1586
+ a.name = p.file.name || a.name; a.size = p.file.size; a.total = p.file.size; a.type = p.file.type || a.type
1587
+ if (attachKind(p.file) === 'image' && !a.preview && p.thumb) a.preview = 'data:image/jpeg;base64,' + p.thumb
1588
+ // a PDF's first page, for its chip and its bubble (a few seconds at most; without it, the file icon)
1589
+ if (attachKind(p.file) === 'pdf' && !a.thumb) {
1590
+ const t = await Promise.race([pdfThumb(p.file), new Promise((r) => setTimeout(() => r(null), 5000))]).catch(() => null)
1591
+ if (t) { a.thumb = t; a.preview = 'data:image/jpeg;base64,' + t }
1592
+ }
1593
+ if (p.note === 'heic') a.error = 'This browser can’t convert HEIC; it goes as is'
1594
+ }
1595
+ if (gone()) return
1596
+ a.status = 'up'; paintChips(a.sid)
1597
+ let last = 0
1598
+ const f = await uploadFile({ api, sessionId: a.sid, file: a.prepared, thumb: a.thumb, isCancelled: gone,
1599
+ onProgress: (sent, total) => { a.sent = sent; a.total = total; const t = Date.now(); if (t - last > 120 || sent >= total) { last = t; paintChips(a.sid) } } })
1600
+ a.id = f.id; a.size = f.size; a.kind = f.kind; a.status = 'done'
1601
+ if (a.preview) attachUi.urls.set(a.sid + '/' + f.id, a.preview) // the bubble shows it without asking the Mac
1602
+ } catch (e) {
1603
+ if (e.code === 'removed' || gone()) return
1604
+ a.status = 'failed'; a.error = e.message || 'Upload failed'
1605
+ }
1606
+ paintChips(a.sid)
1607
+ attachWaiters()
1608
+ }
1609
+ let attachWaitList = []
1610
+ function attachWaiters() { const w = attachWaitList; attachWaitList = []; for (const f of w) f() }
1611
+ // Wait until nothing of this session's is still uploading.
1612
+ async function uploadsSettled(sid) {
1613
+ while (attachList(sid).some((a) => a.status === 'prep' || a.status === 'up')) await new Promise((r) => { attachWaitList.push(r); setTimeout(r, 1000) })
1614
+ }
1615
+ function removeAttachment(lid) {
1616
+ for (const [sid, list] of attachUi.by) {
1617
+ const a = list.find((x) => x.lid === lid)
1618
+ if (!a) continue
1619
+ attachUi.by.set(sid, list.filter((x) => x !== a))
1620
+ paintChips(sid)
1621
+ const ta = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"] .composer-input')
1622
+ if (ta && !isTouchUi(window.matchMedia)) ta.focus({ preventScroll: true })
1623
+ return
1624
+ }
1625
+ }
1626
+ function retryAttachment(lid) {
1627
+ for (const list of attachUi.by.values()) { const a = list.find((x) => x.lid === lid); if (a) { startUpload(a); paintChips(a.sid); return } }
1628
+ }
1629
+ // The composer a drop or a paste is for: the session page's own (enabled).
1630
+ function composerFor(el) {
1631
+ const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
1632
+ return box && !box.classList.contains('off') ? box : null
1633
+ }
1634
+ document.addEventListener('change', (ev) => {
1635
+ const inp = ev.target
1636
+ if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
1637
+ addFiles(inp.dataset.sid, inp.files)
1638
+ inp.value = '' // the same file can be picked again
1639
+ })
1640
+ document.addEventListener('paste', (ev) => {
1641
+ const t = ev.target
1642
+ const inComposer = t && t.classList && t.classList.contains('composer-input')
1643
+ if (!inComposer && t && t.closest && t.closest('input, textarea, [contenteditable]')) return
1644
+ const cd = ev.clipboardData
1645
+ const files = cd ? Array.from(cd.files || []).length ? Array.from(cd.files) : Array.from(cd.items || []).filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter(Boolean) : []
1646
+ if (!files.length) return
1647
+ const box = composerFor(inComposer ? t : null)
1648
+ if (!box) return
1649
+ ev.preventDefault()
1650
+ addFiles(box.dataset.sid, files, { pasted: true })
1651
+ })
1652
+ {
1653
+ // Drag files onto the session page: the box lights up; drop attaches.
1654
+ const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
1655
+ let leaveT = 0
1656
+ const lit = (on) => { const b = document.querySelector('#app .composer[data-sid]'); if (b) b.classList.toggle('att-drop', on) }
1657
+ document.addEventListener('dragover', (e) => { if (!hasFiles(e) || !composerFor(null)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; lit(true); clearTimeout(leaveT); leaveT = setTimeout(() => lit(false), 300) })
1658
+ document.addEventListener('drop', (e) => {
1659
+ if (!hasFiles(e)) return
1660
+ const box = composerFor(null)
1661
+ e.preventDefault() // never let a stray drop navigate away from the board
1662
+ lit(false)
1663
+ if (box) addFiles(box.dataset.sid, e.dataTransfer.files)
1664
+ })
1665
+ }
1666
+ // A sent message's attachments in its bubble: thumbnails (tap for full size) or file chips.
1667
+ function attBubbleHtml(atts, sid) {
1668
+ return '<div class="lv-atts">' + atts.map((a) => {
1669
+ const s0 = a.sessionId || sid, key = s0 + '/' + a.id
1670
+ const label = esc(a.name) + ' · ' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '')
1671
+ const data = ' data-action="att-open" data-sid="' + esc(s0) + '" data-att="' + esc(a.id) + '" data-name="' + esc(a.name) + '" data-kind="' + esc(a.kind) + '" data-thumb="' + (a.thumb === false ? '0' : '1') + '" data-size="' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '') + '"'
1672
+ if (a.kind === 'image') { const u = attachUi.urls.get(key); return '<button type="button" class="lv-att lv-att-img"' + data + ' aria-label="Open ' + esc(a.name) + '" title="' + label + '"><img alt="' + esc(a.name) + '" data-att-thumb="' + esc(key) + '"' + (u ? ' src="' + esc(u) + '"' : '') + '></button>' }
1673
+ const firstPage = a.kind === 'pdf' && a.thumb !== false ? '<img alt="" data-att-thumb="' + esc(key) + '"' + (attachUi.urls.get(key) ? ' src="' + esc(attachUi.urls.get(key)) + '"' : '') + '>' : '' // its first page, when there is one
1674
+ return '<button type="button" class="lv-att lv-att-file"' + data + ' aria-label="Open ' + esc(a.name) + '" title="' + label + '"><span class="att-thumb' + (firstPage ? ' att-thumb-pdf' : '') + '">' + FILE_IC + firstPage + '</span><span class="att-meta"><span class="att-name">' + esc(a.name) + '</span><span class="att-size">' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '') + '</span></span></button>'
1675
+ }).join('') + '</div>'
1676
+ }
1677
+ // Thumbnails come from the Mac once, then from memory. data: URLs, because the
1678
+ // board's CSP allows images from itself and data: only (no blob:).
1679
+ const blobToDataUrl = (b) => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = () => reject(r.error || new Error('unreadable')); r.readAsDataURL(b) })
1680
+ function attData(sid, id, { thumb = false } = {}) {
1681
+ const key = sid + '/' + id + (thumb ? '' : '#full')
1682
+ if (attachUi.urls.has(key)) return Promise.resolve({ url: attachUi.urls.get(key), blob: attachUi.blobs.get(key) || null })
1683
+ if (attachUi.loading.has(key)) return attachUi.loading.get(key)
1684
+ const p = fetchAttachment({ api, sessionId: sid, id, thumb }).then(async (b) => { const url = await blobToDataUrl(b); attachUi.urls.set(key, url); attachUi.blobs.set(key, b); return { url, blob: b } }).finally(() => attachUi.loading.delete(key))
1685
+ attachUi.loading.set(key, p)
1686
+ return p
1687
+ }
1688
+ function hydrateAttThumbs() {
1689
+ for (const img of document.querySelectorAll('#app img[data-att-thumb]:not([src])')) {
1690
+ const key = img.dataset.attThumb
1691
+ if (attachUi.urls.has(key)) { img.src = attachUi.urls.get(key); continue }
1692
+ const btn = img.closest('.lv-att'); if (!btn || btn.dataset.loading) continue
1693
+ btn.dataset.loading = '1'
1694
+ const thumb = btn.dataset.thumb !== '0'
1695
+ attData(btn.dataset.sid, btn.dataset.att, { thumb }).then(({ url }) => { attachUi.urls.set(key, url); for (const i of document.querySelectorAll('#app img[data-att-thumb="' + CSS.escape(key) + '"]')) i.src = url })
1696
+ .catch((e) => {
1697
+ // a PDF sent without a first-page picture (before 0.9.8, or it couldn't be drawn) keeps its icon
1698
+ if (btn.dataset.kind === 'pdf' && e && e.code === 'no_thumb') { for (const i of btn.querySelectorAll('img[data-att-thumb]')) i.remove(); return }
1699
+ btn.classList.add('gone'); btn.title = 'Not on that Mac any more'
1700
+ })
1701
+ }
1702
+ }
1703
+ // An image opens full screen (ui/lightbox.js) with the other pictures of the
1704
+ // same message beside it; the thumbnail shows (blurred) while the full one
1705
+ // comes from the Mac. Text and other files keep the docs viewer.
1706
+ const attSel = (sid, id) => '.lv-att[data-sid="' + CSS.escape(sid) + '"][data-att="' + CSS.escape(id) + '"]'
1707
+ function openImageAttachment(btn) {
1708
+ const group = btn.closest('.lv-atts')
1709
+ const btns = group ? [...group.querySelectorAll('.lv-att[data-kind="image"]')] : [btn]
1710
+ const items = btns.map((b) => {
1711
+ const im = b.querySelector('img'), sid = b.dataset.sid, id = b.dataset.att
1712
+ const full = attachUi.urls.get(sid + '/' + id + '#full')
1713
+ return { name: b.dataset.name || 'image', sizeLabel: b.dataset.size || '', thumb: (im && im.getAttribute('src')) || null, url: full || null,
1714
+ w: im && im.naturalWidth, h: im && im.naturalHeight, load: () => attData(sid, id).then((r) => r.url) }
1715
+ })
1716
+ const sid = btn.dataset.sid, id = btn.dataset.att
1717
+ openLightbox({ items, index: Math.max(0, btns.indexOf(btn)), from: btn.querySelector('img') || btn, refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1718
+ }
1719
+ // A chip in the box before it's sent: the picture from the file you picked
1720
+ // (a data: URL; the CSP takes no blob:), the other pictures in the box beside it.
1721
+ // Any other file: its name and size in the docs viewer (nothing to download yet).
1722
+ function openChip(lid) {
1723
+ let a = null
1724
+ for (const list of attachUi.by.values()) { a = list.find((x) => x.lid === lid); if (a) break }
1725
+ if (!a) return
1726
+ const chip = document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1727
+ const isImg = (x) => x.kind === 'image' && (x.preview || /^image\/(png|jpeg|gif|webp|bmp|svg\+xml|avif)$/i.test((x.prepared || x.file || {}).type || ''))
1728
+ const refocus = () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
1729
+ if (a.kind === 'pdf') {
1730
+ const f = a.prepared || a.file
1731
+ openPdfView({ name: a.name, sizeLabel: fmtSize(a.size), download: false, from: chip, refocus, load: async () => ({ bytes: new Uint8Array(await f.arrayBuffer()) }) })
1732
+ return
1733
+ }
1734
+ if (isImg(a)) {
1735
+ const list = attachList(a.sid).filter(isImg)
1736
+ const items = list.map((x) => ({ name: x.name, sizeLabel: fmtSize(x.size), thumb: x.preview || null, download: false,
1737
+ load: () => { if (!x.fullUrl) x.fullUrl = blobToDataUrl(x.prepared || x.file); return x.fullUrl } }))
1738
+ openLightbox({ items, index: Math.max(0, list.indexOf(a)), from: (chip && chip.querySelector('img')) || chip, refocus: () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]') })
1739
+ return
1740
+ }
1741
+ const el = docViewerEl()
1742
+ el.querySelector('.dv-title').textContent = a.name
1743
+ el.querySelector('.dv-sub').textContent = ''
1744
+ el.querySelector('.dv-dl').hidden = true
1745
+ docUi.r = null; docUi.name = a.name
1746
+ el.querySelector('.dv-body').innerHTML = '<div class="empty att-chip-info"><b>' + esc(a.name) + '</b><br>' + esc(fmtSize(a.size)) + (a.status === 'done' ? ' · goes with your next message' : a.status === 'failed' ? ' · didn’t upload' : ' · uploading') + '</div>'
1747
+ el.hidden = false
1748
+ document.documentElement.classList.add('dv-open')
1749
+ el.querySelector('.dv-x') && el.querySelector('.dv-x').focus({ preventScroll: true })
1750
+ }
1751
+ // A PDF opens in its own full-screen preview (ui/pdfview.js): the pages drawn
1752
+ // from the file the Mac sends back (the same chunked read as a picture).
1753
+ function openPdfAttachment(btn) {
1754
+ const sid = btn.dataset.sid, id = btn.dataset.att
1755
+ openPdfView({ name: btn.dataset.name || 'file.pdf', sizeLabel: btn.dataset.size || '', from: btn,
1756
+ load: async () => { const r = await attData(sid, id); return { bytes: new Uint8Array(await r.blob.arrayBuffer()), url: r.url } },
1757
+ refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
1758
+ }
1759
+ async function openAttachment(btn) {
1760
+ const sid = btn.dataset.sid, id = btn.dataset.att, name = btn.dataset.name || 'file', kind = btn.dataset.kind
1761
+ if (kind === 'image') return openImageAttachment(btn)
1762
+ if (kind === 'pdf') return openPdfAttachment(btn)
1763
+ const el = docViewerEl()
1764
+ el.querySelector('.dv-title').textContent = name
1765
+ el.querySelector('.dv-sub').textContent = ''
1766
+ el.querySelector('.dv-dl').hidden = true
1767
+ const body = el.querySelector('.dv-body')
1768
+ body.innerHTML = '<div class="empty">Opening…</div>'
1769
+ docUi.r = null; docUi.name = name
1770
+ el.hidden = false
1771
+ document.documentElement.classList.add('dv-open')
1772
+ try {
1773
+ const { url: u, blob } = await attData(sid, id)
1774
+ if (el.hidden) return
1775
+ const dl = '<a class="btn" href="' + esc(u) + '" download="' + esc(name) + '">' + icon('download') + 'Download</a>'
1776
+ if (kind === 'image') body.innerHTML = '<div class="att-full"><img src="' + esc(u) + '" alt="' + esc(name) + '"></div><div class="att-full-actions">' + dl + '</div>'
1777
+ else if (kind === 'text') { const t = blob ? await blob.text() : ''; body.innerHTML = '<pre class="att-text">' + esc(t.slice(0, 200000)) + '</pre><div class="att-full-actions">' + dl + '</div>' }
1778
+ else body.innerHTML = '<div class="empty">' + esc(name) + '</div><div class="att-full-actions">' + dl + '</div>'
1779
+ } catch (e) { if (!el.hidden) body.innerHTML = '<div class="empty">Couldn’t open it: ' + esc(e.message) + '</div>' }
1780
+ }
1781
+ // Fit the box to its words (one line up to COMPOSER_MAX_LINES, then it scrolls) and light the ↑ when there's something to send.
1782
+ function fitComposer(ta) {
1783
+ if (!ta || !ta.isConnected) return
1784
+ const cs = getComputedStyle(ta)
1785
+ const lh = parseFloat(cs.lineHeight) || 22
1786
+ const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
1787
+ const keep = ta.scrollTop
1788
+ ta.style.height = 'auto'
1789
+ const r = composerHeight({ scrollHeight: ta.scrollHeight, lineHeight: lh, padY })
1790
+ ta.style.height = r.height + 'px'
1791
+ ta.style.overflowY = r.scroll ? 'auto' : 'hidden'
1792
+ if (r.scroll) ta.scrollTop = keep
1793
+ syncComposer(ta)
1794
+ }
1795
+ function syncComposer(ta) {
1796
+ const box = ta && ta.closest('.composer')
1797
+ const btn = box && box.querySelector('.composer-send')
1798
+ if (!btn || btn.classList.contains('sending')) return
1799
+ btn.disabled = ta.disabled || (isBlank(ta.value) && !attachList(box.dataset.sid).length)
1800
+ }
1801
+ document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
1802
+ // Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
1803
+ document.addEventListener('keydown', (ev) => {
1804
+ const ta = ev.target
1805
+ if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
1806
+ if (ev.metaKey || ev.ctrlKey) return // ⌘↵ / Ctrl+↵: the Needs-you handler presses the button
1807
+ if (composerKey(ev, { touch: isTouchUi(window.matchMedia) }) !== 'send') return
1808
+ ev.preventDefault()
1809
+ const cb = ta.closest('.composer')
1810
+ if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
1811
+ const box = ta.closest('.live-box')
1812
+ const btn = box && box.querySelector('[data-submit-for="live"]')
1813
+ if (!btn || btn.disabled) return
1814
+ const last = Number(box.dataset.kbdAt || 0)
1815
+ if (Date.now() - last < 1500) return
1816
+ box.dataset.kbdAt = String(Date.now())
1817
+ btn.click()
1818
+ })
1819
+ // A tap anywhere on the box (its padding) is a tap in the textarea.
1820
+ document.addEventListener('click', (ev) => { const b = ev.target && ev.target.classList && ev.target.classList.contains('composer') ? ev.target : null; const ta = b && b.querySelector('.composer-input'); if (ta && !ta.disabled) ta.focus() })
1821
+ // Pressing ↑ keeps the keyboard up (the textarea keeps focus) on a phone.
1822
+ document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.composer-send') && document.activeElement && document.activeElement.classList.contains('composer-input')) ev.preventDefault() })
1823
+ // After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
1824
+ function liveAfterRender(sid) {
1825
+ for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
1826
+ const r = liveKeeper.after($app)
1827
+ hydrateAttThumbs(); barWidths($app)
1828
+ if (!r) return
1829
+ const ol = r.thread.querySelector('.live-log')
1830
+ const empty = r.thread.querySelector('.live-empty')
1831
+ if (empty && ol) empty.hidden = ol.children.length > 0
1832
+ liveJumpButton(r.thread, liveKeeper.unseen)
1833
+ if (ol && !ol.dataset.wired) {
1834
+ ol.dataset.wired = '1'
1835
+ ol._stick = atBottom(ol)
1836
+ // the button follows the scroll: there whenever you're away from the bottom, gone once you're back
1837
+ ol.addEventListener('scroll', () => { ol._stick = atBottom(ol); liveJumpButton(r.thread, liveKeeper.unseen) }, { passive: true })
1838
+ // at the bottom, the thread stays there while an entry grows (a streamed reply, a code block, late fonts)
1839
+ if (typeof ResizeObserver === 'function') {
1840
+ ol._ro = new ResizeObserver(() => { if (ol._stick && !atBottom(ol, 2)) ol.scrollTop = ol.scrollHeight; liveJumpButton(r.thread, liveKeeper.unseen) })
1841
+ }
1842
+ }
1843
+ if (ol && ol._ro) { ol._ro.disconnect(); ol._ro.observe(ol); for (const li of ol.children) ol._ro.observe(li) }
1844
+ if (ol && r.follow) ol._stick = true
1845
+ // #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
1846
+ const want = parseLiveRoute(location.hash)
1847
+ if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
1848
+ state.liveFocused = location.hash + '|' + sid
1849
+ const box = document.getElementById('live')
1850
+ if (box) box.scrollIntoView({ block: 'start', behavior: 'auto' })
1851
+ if (ol) ol.scrollTop = ol.scrollHeight
1852
+ const ta = box && box.querySelector('.live-text')
1853
+ if (ta && !ta.disabled && !(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)) ta.focus({ preventScroll: true })
1854
+ }
1855
+ liveTick()
1856
+ }
1857
+ // "Jump to latest ↓" shows whenever the thread is scrolled away from its bottom (with the
1858
+ // count of what came in since, when there is any), and hides once you're back at the bottom.
1859
+ function liveJumpButton(thread, n) {
1860
+ const b = thread && thread.querySelector('.live-jump')
1861
+ if (!b) return
1862
+ const ol = thread.querySelector('.live-log')
1863
+ const v = jumpView(ol, n)
1864
+ if (!v.show && n > 0) liveKeeper.seen()
1865
+ // on its way down after a tap: don't flash back up mid-scroll
1866
+ if (v.show && thread.dataset.jumping && Date.now() < Number(thread.dataset.jumping)) return
1867
+ b.hidden = !v.show
1868
+ const c = b.querySelector('.live-jump-n')
1869
+ if (c) c.textContent = v.count
1870
+ b.setAttribute('aria-label', v.label)
1871
+ }
1872
+ function liveJumpClick(btn) {
1873
+ const thread = btn.closest('.live-thread')
1874
+ const ol = thread && thread.querySelector('.live-log')
1875
+ const smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
1876
+ if (thread) thread.dataset.jumping = String(Date.now() + (smooth ? 900 : 0))
1877
+ if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
1878
+ liveKeeper.seen()
1879
+ btn.hidden = true
1880
+ const c = btn.querySelector('.live-jump-n'); if (c) c.textContent = ''
1881
+ // land exactly at the bottom even if something grew mid-scroll, then the button follows the scroll again
1882
+ 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)
1883
+ }
1884
+ // "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
1885
+ async function liveEarlierClick(btn) {
1886
+ const box = btn.closest('.live-box')
1887
+ const sid = box && box.dataset.id
1888
+ if (!sid) return
1889
+ state.liveTurns = state.liveTurns || {}
1890
+ state.liveTurns[sid] = ((state.liveTurns[sid]) || 20) + 20
1891
+ btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading…'
1892
+ await render()
1893
+ }
1894
+ // The elapsed time beside "Claude is working…", every second while something is in flight.
1895
+ // 0.9.8: when what the page shows is older than STALE_MS (the stream died, the
1896
+ // phone slept), a running bubble says "Reconnecting…" instead of a timer.
1897
+ let liveTimer = null
1898
+ function liveTick() {
1899
+ const els = [...document.querySelectorAll('.lv-status[data-since]')]
1900
+ const box = document.querySelector('#live[data-id]')
1901
+ const stale = !!box && isStale(liveUi.okAt[box.dataset.id], Date.now())
1902
+ for (const el of els) {
1903
+ const since = Number(el.dataset.since) || 0
1904
+ const end = Number(el.dataset.final) || 0
1905
+ const t = el.querySelector('.lvs-time')
1906
+ if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
1907
+ if (!end) {
1908
+ el.classList.toggle('lv-reconnecting', stale)
1909
+ let hint = el.querySelector('.lvs-hint')
1910
+ if (stale && !hint) { hint = document.createElement('span'); hint.className = 'lvs-hint'; hint.textContent = 'Reconnecting…'; (el.querySelector('.lvs-line') || el).append(hint) }
1911
+ if (hint) hint.hidden = !stale
1912
+ }
1913
+ }
1914
+ const running = els.some((el) => !el.dataset.final)
1915
+ if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
1916
+ }
1917
+
1918
+ // 0.9.8: Live keeps up after the phone sleeps (ui/live-wake.js). Every Live
1919
+ // fetch notes when it answered and whether a message is still going; while
1920
+ // one is, on a visible page, the thread is fetched again every POLL_MS
1921
+ // whatever the event stream does.
1922
+ const liveUi = { okAt: {}, sig: {}, pending: {}, polling: false }
1923
+ function noteLive(id, v) {
1924
+ if (!v || v.error) return
1925
+ liveUi.okAt[id] = Date.now()
1926
+ liveUi.pending[id] = anyPending(v.messages)
1927
+ }
1928
+ 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 }
1929
+ // Fetch the thread and diff it into the page, even while you type (busy()
1930
+ // holds full redraws back, never the thread). A turn that ended also redraws
1931
+ // the rest of the page (the pill stops pulsing) once you're not busy.
1932
+ async function refreshLive(reason) {
1933
+ const id = liveOnScreen()
1934
+ if (!id || liveUi.polling) return false
1935
+ liveUi.polling = true; liveUi.triedAt = Date.now()
1936
+ let v
1937
+ try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
1938
+ if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
1939
+ const s = state.sessions.find((x) => x.id === id)
1940
+ const sig = liveSignature(v.messages)
1941
+ const before = liveUi.sig[id]
1942
+ liveUi.sig[id] = sig
1943
+ if (s && before !== sig) {
1944
+ const box = document.querySelector('#live')
1945
+ const cur = box && box.querySelector('.live-thread')
1946
+ if (cur) {
1947
+ const tmp = document.createElement('div')
1948
+ tmp.innerHTML = liveBoxHtml(s, v)
1949
+ const fresh = tmp.querySelector('.live-thread')
1950
+ if (fresh) {
1951
+ const next = cur.nextSibling
1952
+ liveKeeper.before(box)
1953
+ box.insertBefore(fresh, next)
1954
+ const r = liveKeeper.after(box)
1955
+ hydrateAttThumbs()
1956
+ if (r) liveJumpButton(r.thread, liveKeeper.unseen)
1957
+ }
1958
+ }
1959
+ const c = sessionCache.get(id); if (c) c.live = v
1960
+ if (!liveUi.pending[id] && !busy()) soon() // settled: the pill, the tab, the card
1961
+ }
1962
+ liveTick()
1963
+ return true
1964
+ }
1965
+ // The safety net, and the stream's watchdog, on one slow timer.
1966
+ const streamDog = createWatchdog()
1967
+ const wakeGate = createWake()
1968
+ setInterval(() => {
1969
+ if (document.visibilityState === 'hidden') return
1970
+ const id = liveOnScreen()
1971
+ 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')
1972
+ if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
1973
+ }, 1000)
1974
+ // Coming back (iOS: the Home Screen app returns, the screen unlocks, a
1975
+ // bfcache restore, the network returns): fetch what's on screen at once, and
1976
+ // open a new stream if the old one missed its pings.
1977
+ function refreshView(reason) {
1978
+ if (liveOnScreen()) refreshLive(reason)
1979
+ if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
1980
+ refreshHealth()
1981
+ }
1982
+ let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
1983
+ function onWake(ev) {
1984
+ if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
1985
+ const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
1986
+ if (!why) return
1987
+ liveTick() // a stale timer turns into "Reconnecting…" before anything else
1988
+ const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
1989
+ const away = awayAt ? Date.now() - awayAt : 0
1990
+ awayAt = 0
1991
+ // 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
1992
+ if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
1993
+ if (!wakeGate.due()) return
1994
+ if (why === 'pageshow-bfcache' || dead) connect()
1995
+ refreshView(why)
1996
+ }
1997
+ for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
1998
+ document.addEventListener('visibilitychange', onWake)
1356
1999
  async function liveSendClick(btn) {
1357
2000
  const box = btn.closest('.live-box')
1358
2001
  const ta = box && box.querySelector('.live-text')
2002
+ const sid = btn.dataset.id
1359
2003
  const text = ta ? ta.value.trim() : ''
1360
- if (!text) { toast('Type a message first'); return }
1361
- if (btn.getAttribute('aria-disabled') === 'true') return
1362
- btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Sending…'
2004
+ if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
2005
+ if (btn.disabled || btn.classList.contains('sending')) return
2006
+ const hadFocus = !!(ta && document.activeElement === ta)
2007
+ btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
1363
2008
  try {
1364
- const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/live', { text })
1365
- if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
1366
- if (ta) ta.value = ''
2009
+ // 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
2010
+ await uploadsSettled(sid)
2011
+ const atts = attachList(sid)
2012
+ const failed = atts.filter((a) => a.status === 'failed').length
2013
+ if (failed) { toast(failed === 1 ? 'A file didn’t upload: retry it or remove it.' : failed + ' files didn’t upload: retry or remove them.', 4200); return }
2014
+ const ids = atts.filter((a) => a.status === 'done' && a.id).map((a) => a.id)
2015
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live', { text, ...(ids.length ? { attachments: ids } : {}) })
2016
+ // what you typed while it went stays; what was sent goes
2017
+ if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
2018
+ if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
1367
2019
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
2020
+ const ol = box && box.querySelector('.live-log')
2021
+ if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
1368
2022
  setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
1369
- } catch (e) { toast(e.message, 4200) } finally { render() }
2023
+ } catch (e) { toast(e.message, 4200) } finally {
2024
+ await render()
2025
+ const nb = document.querySelector('.live-box [data-action="live-send"]')
2026
+ if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
2027
+ const nt = document.querySelector('.live-box .composer-input')
2028
+ if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
2029
+ }
1370
2030
  }
1371
2031
 
1372
2032
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
@@ -2478,6 +3138,7 @@ async function viewSettings() {
2478
3138
  general: '<div class="section">Needs you</div>' +
2479
3139
  '<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
3140
  '<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
3141
+ '<div class="full push-slot" data-push-slot hidden></div>' +
2481
3142
  '<div class="section">Appearance</div>' +
2482
3143
  '<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
3144
  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 +3900,8 @@ function parseRoute() {
3239
3900
 
3240
3901
  async function render() {
3241
3902
  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') })
3903
+ paintNav(r)
3904
+ tabsOnRoute(r)
3243
3905
  try {
3244
3906
  if (r.name === 's' && r.arg) await viewSession(r.arg)
3245
3907
  else if (r.name === 'waiting') await viewWaiting()
@@ -3254,6 +3916,7 @@ async function render() {
3254
3916
  else if (r.name === 'search') await viewSearch(r.q || '')
3255
3917
  else if (r.name === 'setup') await viewSetup()
3256
3918
  else await viewBoard()
3919
+ tabsAfterRender()
3257
3920
  } catch (e) {
3258
3921
  if (e.code === 'login_required') return showLogin()
3259
3922
  $app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
@@ -3326,6 +3989,7 @@ async function refreshHealth() {
3326
3989
  if (h.answering) state.mayAnswer = h.answering.allowedHere !== false
3327
3990
  const dot = document.querySelector('#wnTab .newdot')
3328
3991
  if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
3992
+ paintMoreDots()
3329
3993
  paintSettingsDots()
3330
3994
  } catch (e) {
3331
3995
  el.className = 'health down'
@@ -3356,6 +4020,475 @@ function paintTopDot() {
3356
4020
  el.className = 'topdot' + (warn ? ' warn' : '')
3357
4021
  const why = all.flatMap((x) => x.why)
3358
4022
  document.getElementById('setTab').title = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
4023
+ paintMoreDots()
4024
+ }
4025
+
4026
+ // ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
4027
+ // From 700px up the sections sit in a rail on the left; under it, in a bottom
4028
+ // bar with the rest under More. Hover the Needs you item (or long-press it on a
4029
+ // phone, or press ↓/→ on it) for Quick look: the count and the five longest
4030
+ // waits, with Allow / Deny on permission prompts (the Needs you page's own
4031
+ // buttons, so the same checks apply and nothing is answered without a click).
4032
+ const railMode = () => window.matchMedia('(min-width: 700px)').matches
4033
+ function paintNav(r = state.route || parseRoute()) {
4034
+ const key = navKey(r.name)
4035
+ 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') })
4036
+ const more = document.getElementById('moreBtn')
4037
+ if (more) more.classList.toggle('on', moreActive(key))
4038
+ }
4039
+ function paintMoreDots() {
4040
+ const wn = document.querySelector('#wnTab .newdot'), st = document.querySelector('#setTab .topdot')
4041
+ const mwn = document.querySelector('#moreMenu .newdot'), mst = document.querySelector('#moreMenu .topdot')
4042
+ if (mwn && wn) mwn.hidden = wn.hidden
4043
+ if (mst && st) { mst.hidden = st.hidden; mst.className = st.className }
4044
+ const md = document.querySelector('#moreBtn .moredot')
4045
+ if (md) { md.hidden = !((wn && !wn.hidden) || (st && !st.hidden)); md.className = 'moredot' + (st && !st.hidden && st.classList.contains('warn') ? ' warn' : '') }
4046
+ }
4047
+
4048
+ // More (phones)
4049
+ const moreOpen = () => { const m = document.getElementById('moreMenu'); return !!m && !m.hidden }
4050
+ function closeMore(focusBtn = false) {
4051
+ const m = document.getElementById('moreMenu'), b = document.getElementById('moreBtn')
4052
+ if (!m || m.hidden) return
4053
+ m.hidden = true
4054
+ if (b) { b.setAttribute('aria-expanded', 'false'); if (focusBtn) b.focus() }
4055
+ }
4056
+ function openMore() {
4057
+ const m = document.getElementById('moreMenu'), b = document.getElementById('moreBtn')
4058
+ if (!m) return
4059
+ closeQuickLook(); toggleStatus(false)
4060
+ paintMoreDots()
4061
+ paintPush()
4062
+ m.hidden = false
4063
+ if (b) b.setAttribute('aria-expanded', 'true')
4064
+ const first = m.querySelector('a.on') || m.querySelector('a')
4065
+ if (first) first.focus()
4066
+ }
4067
+ document.getElementById('moreBtn').addEventListener('click', () => (moreOpen() ? closeMore() : openMore()))
4068
+ document.getElementById('moreMenu').addEventListener('click', (e) => { if (e.target.closest('a')) closeMore() })
4069
+ document.getElementById('moreMenu').addEventListener('keydown', (e) => {
4070
+ const items = [...document.querySelectorAll('#moreMenu a')]
4071
+ const i = items.indexOf(document.activeElement)
4072
+ if (e.key === 'Escape') { e.preventDefault(); closeMore(true) }
4073
+ 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() }
4074
+ else if (e.key === 'Tab') closeMore()
4075
+ })
4076
+
4077
+ // Quick look
4078
+ const qlUi = { open: false, how: null, openT: 0, closeT: 0, suppressClick: false, model: null, seq: 0 }
4079
+ const quickLookOpen = () => qlUi.open
4080
+ function quickLookFocusables() { const p = document.getElementById('quickLook'); return p ? [...p.querySelectorAll('a[href], button:not([disabled])')] : [] }
4081
+ function paintQuickLook() {
4082
+ const pop = document.getElementById('quickLook')
4083
+ if (!pop || !qlUi.open) return
4084
+ const had = pop.contains(document.activeElement) ? document.activeElement : null
4085
+ 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') })
4086
+ const model = qlUi.model || { total: Number((state.health || {}).needsYou) || 0, items: [], more: 0 }
4087
+ pop.innerHTML = quickLookHtml(model, { mayAnswer: state.mayAnswer, pending: state.pendingAsk })
4088
+ pop.setAttribute('aria-busy', qlUi.model ? 'false' : 'true')
4089
+ placeQuickLook()
4090
+ if (keep) {
4091
+ let el = null
4092
+ 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])') }
4093
+ else if (keep.foot) el = pop.querySelector('.ql-foot')
4094
+ ;(el || quickLookFocusables()[0] || pop).focus()
4095
+ }
4096
+ }
4097
+ function placeQuickLook() {
4098
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
4099
+ if (!pop || pop.hidden || !tab) return
4100
+ if (!railMode()) { pop.style.left = ''; pop.style.top = ''; return } // the CSS puts it above the bottom bar
4101
+ const r = tab.getBoundingClientRect(), rail = document.querySelector('.top').getBoundingClientRect()
4102
+ pop.style.left = (Math.max(r.right, rail.right) + 8) + 'px'
4103
+ pop.style.top = Math.max(8, Math.min(r.top - 6, window.innerHeight - 8 - pop.offsetHeight)) + 'px'
4104
+ }
4105
+ // The sessions behind the popover: what the page already has, then a fresh read.
4106
+ async function loadQuickLook() {
4107
+ const seq = ++qlUi.seq
4108
+ if (state.sessions && state.sessions.length && !qlUi.model) { qlUi.model = quickLookItems(state.sessions); paintQuickLook() }
4109
+ try {
4110
+ const { sessions, viewer } = await api('GET', '/v1/sessions?limit=200', undefined, { timeoutMs: 8000 })
4111
+ if (seq !== qlUi.seq) return
4112
+ if (viewer) state.mayAnswer = viewer.mayAnswer !== false
4113
+ qlUi.model = quickLookItems(sessions || [])
4114
+ } catch { if (!qlUi.model) qlUi.model = { total: 0, items: [], more: 0, failed: true } }
4115
+ paintQuickLook()
4116
+ }
4117
+ function openQuickLook(how = 'hover') {
4118
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
4119
+ if (!pop || !tab) return
4120
+ clearTimeout(qlUi.openT); clearTimeout(qlUi.closeT)
4121
+ if (qlUi.open) { if (how !== 'hover') { qlUi.how = how; const f = quickLookFocusables()[0]; if (f) f.focus() } return }
4122
+ closeMore(); toggleStatus(false)
4123
+ qlUi.open = true; qlUi.how = how; qlUi.model = null
4124
+ pop.hidden = false
4125
+ tab.setAttribute('aria-expanded', 'true')
4126
+ // Hover never takes focus; the keyboard and a long-press move into the card (and Tab stays inside it).
4127
+ pop.setAttribute('aria-modal', how === 'hover' ? 'false' : 'true')
4128
+ paintQuickLook()
4129
+ if (how !== 'hover') { const f = quickLookFocusables()[0]; (f || pop).focus() }
4130
+ loadQuickLook()
4131
+ }
4132
+ function closeQuickLook(focusTab = false) {
4133
+ clearTimeout(qlUi.openT); clearTimeout(qlUi.closeT)
4134
+ if (!qlUi.open) return
4135
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
4136
+ const inside = pop && pop.contains(document.activeElement)
4137
+ qlUi.open = false; qlUi.seq++
4138
+ if (pop) { pop.hidden = true; pop.innerHTML = '' }
4139
+ if (tab) { tab.setAttribute('aria-expanded', 'false'); if (focusTab || inside) tab.focus() }
4140
+ }
4141
+ {
4142
+ const tab = document.getElementById('needsTab'), pop = document.getElementById('quickLook')
4143
+ const canHover = () => window.matchMedia('(hover: hover) and (pointer: fine)').matches
4144
+ const later = (fn, ms) => { clearTimeout(qlUi.closeT); clearTimeout(qlUi.openT); return setTimeout(fn, ms) }
4145
+ tab.addEventListener('mouseenter', () => { if (!canHover()) return; clearTimeout(qlUi.closeT); if (!qlUi.open) qlUi.openT = later(() => openQuickLook('hover'), 280) })
4146
+ tab.addEventListener('mouseleave', () => { clearTimeout(qlUi.openT); if (qlUi.open && qlUi.how === 'hover') qlUi.closeT = setTimeout(() => closeQuickLook(), 320) })
4147
+ pop.addEventListener('mouseenter', () => clearTimeout(qlUi.closeT))
4148
+ pop.addEventListener('mouseleave', () => { if (qlUi.open && qlUi.how === 'hover' && !pop.contains(document.activeElement)) qlUi.closeT = setTimeout(() => closeQuickLook(), 320) })
4149
+ // Long-press on a touch screen; the tap that ends it doesn't navigate.
4150
+ let lp = null
4151
+ tab.addEventListener('pointerdown', (e) => {
4152
+ if (e.pointerType === 'mouse') return
4153
+ const x = e.clientX, y = e.clientY
4154
+ clearTimeout(lp && lp.t)
4155
+ lp = { x, y, t: setTimeout(() => { qlUi.suppressClick = true; openQuickLook('touch'); if (navigator.vibrate) try { navigator.vibrate(10) } catch {} }, 450) }
4156
+ })
4157
+ const cancel = () => { if (lp) { clearTimeout(lp.t); lp = null } }
4158
+ tab.addEventListener('pointerup', cancel)
4159
+ tab.addEventListener('pointercancel', cancel)
4160
+ tab.addEventListener('pointermove', (e) => { if (lp && Math.hypot(e.clientX - lp.x, e.clientY - lp.y) > 10) cancel() })
4161
+ tab.addEventListener('contextmenu', (e) => { if (qlUi.open || lp) e.preventDefault() })
4162
+ tab.addEventListener('click', (e) => {
4163
+ if (qlUi.suppressClick) { qlUi.suppressClick = false; e.preventDefault(); return }
4164
+ // A second tap while it's open goes to the page, as the footer does.
4165
+ closeQuickLook()
4166
+ })
4167
+ tab.addEventListener('keydown', (e) => {
4168
+ if (['ArrowDown', 'ArrowRight', 'ArrowUp'].includes(e.key) && !e.altKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); openQuickLook('keyboard') }
4169
+ })
4170
+ pop.addEventListener('keydown', (e) => {
4171
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeQuickLook(true); return }
4172
+ if (e.key !== 'Tab') return
4173
+ const f = quickLookFocusables()
4174
+ if (!f.length) { e.preventDefault(); return }
4175
+ const i = f.indexOf(document.activeElement)
4176
+ if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus() }
4177
+ else if (!e.shiftKey && (i === -1 || i === f.length - 1)) { e.preventDefault(); f[0].focus() }
4178
+ })
4179
+ pop.addEventListener('focusin', () => { clearTimeout(qlUi.closeT); pop.setAttribute('aria-modal', 'true') })
4180
+ pop.addEventListener('click', (e) => { const a = e.target.closest('a[href]'); if (a) closeQuickLook() })
4181
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && qlUi.open) closeQuickLook(true) })
4182
+ document.addEventListener('pointerdown', (e) => {
4183
+ if (qlUi.open && !e.target.closest('#quickLook, #needsTab')) closeQuickLook()
4184
+ if (moreOpen() && !e.target.closest('#moreMenu, #moreBtn')) closeMore()
4185
+ }, true)
4186
+ window.addEventListener('hashchange', () => { closeQuickLook(); closeMore() })
4187
+ window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
4188
+ }
4189
+
4190
+ // ------------------------------------------------------------ tabs for sessions (0.9.8, ui/tabs.js)
4191
+ // The board is the first tab; a session opens as the one preview tab (italic)
4192
+ // that the next click replaces, and stays open once you send a Live message,
4193
+ // type there, choose Keep open or double-click its tab (⌘/Ctrl-click or a
4194
+ // middle-click opens it permanent straight away). Kept per device in
4195
+ // localStorage. By width: under 700px the Open row on the board and ‹ N of M ›
4196
+ // (or a swipe) on a session page; from 700px the strip. A width change (fold,
4197
+ // Split View, rotation) only changes the CSS: the open set, the session and
4198
+ // where you were on the page stay.
4199
+ 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: {} }
4200
+ window.leftoffTabs = { model: () => JSON.parse(serialize(tabsUi.m)) } // read by test:tabs-ui
4201
+ function setTabs(m) {
4202
+ if (m === tabsUi.m) return
4203
+ tabsUi.m = m
4204
+ try { localStorage.setItem(TABS_KEY, serialize(m)) } catch {}
4205
+ }
4206
+ const tabSession = (id) => state.sessions.find((s) => s.id === id)
4207
+ const tabViews = () => tabsUi.m.tabs.map((t) => tabView(t, tabSession(t.id), { title: sessionTitle, working: (s) => liveWorking(s) }))
4208
+ function tabsOnRoute(r) {
4209
+ const id = r.name === 's' && r.arg ? String(r.arg) : null
4210
+ if (tabsUi.active && tabsUi.active !== id) tabsUi.scrolls[tabsUi.active] = window.scrollY || 0
4211
+ if (id) {
4212
+ const permanent = tabsUi.permanent === id
4213
+ setTabs(openTab(tabsUi.m, id, { permanent, hash: keepHash(location.hash), title: (tabSession(id) && sessionTitle(tabSession(id))) || '' }))
4214
+ }
4215
+ tabsUi.permanent = null
4216
+ const changed = tabsUi.active !== id || tabsUi.route !== r.name
4217
+ tabsUi.active = id; tabsUi.route = r.name
4218
+ document.body.dataset.route = r.name
4219
+ paintTabs()
4220
+ if (changed) scrollActiveTab()
4221
+ }
4222
+ function tabsAfterRender() {
4223
+ let m = tabsUi.m
4224
+ for (const t of m.tabs) { const s = tabSession(t.id); if (s) m = remember(m, t.id, { title: sessionTitle(s) }) }
4225
+ setTabs(m)
4226
+ paintTabs()
4227
+ const back = tabsUi.active && tabsUi.restoreScroll === tabsUi.active ? tabsUi.scrolls[tabsUi.active] : null
4228
+ tabsUi.restoreScroll = null
4229
+ if (back != null) window.scrollTo(0, back)
4230
+ noteAnchor()
4231
+ // the other open tabs' pulse and dot: read the sessions again now and then while you're off the board
4232
+ if (tabsUi.route !== 'board' && tabsUi.m.tabs.some((t) => t.id !== tabsUi.active) && Date.now() - tabsUi.fetchedAt > 15000) refreshTabSessions()
4233
+ }
4234
+ async function refreshTabSessions() {
4235
+ tabsUi.fetchedAt = Date.now()
4236
+ try {
4237
+ const { sessions = [], parked = [] } = await api('GET', '/v1/sessions?limit=200')
4238
+ const fresh = new Map(sessions.concat(parked).map((s) => [s.id, s]))
4239
+ 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)))
4240
+ paintTabs()
4241
+ } catch {}
4242
+ }
4243
+ function paintTabs() {
4244
+ const views = tabViews()
4245
+ const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
4246
+ if (!strip) return
4247
+ const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
4248
+ if (sh !== tabsUi.html.strip) {
4249
+ const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
4250
+ const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
4251
+ strip.innerHTML = sh; tabsUi.html.strip = sh
4252
+ const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
4253
+ 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 }) }
4254
+ }
4255
+ strip.hidden = false
4256
+ const rh = openRowHtml(views, esc)
4257
+ 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 }
4258
+ row.hidden = !rh
4259
+ const ph = tabsUi.active ? pagerHtml(tabsUi.m, tabsUi.active, views, esc) : ''
4260
+ if (ph !== tabsUi.html.pager) { pager.innerHTML = ph; tabsUi.html.pager = ph }
4261
+ pager.hidden = !ph
4262
+ }
4263
+ function scrollActiveTab() {
4264
+ const el = document.querySelector('#tabStrip .tab-cell.on'), sc = document.querySelector('#tabStrip .tab-scroll')
4265
+ if (!el || !sc) return
4266
+ const r = el.getBoundingClientRect(), b = sc.getBoundingClientRect()
4267
+ if (r.left < b.left) sc.scrollLeft -= b.left - r.left + 8
4268
+ else if (r.right > b.right) sc.scrollLeft += r.right - b.right + 8
4269
+ }
4270
+ function keepTab(id) { setTabs(promote(tabsUi.m, id)); paintTabs() }
4271
+ function closeTabNow(id) {
4272
+ const { model, next } = closeTab(tabsUi.m, id, tabsUi.active)
4273
+ setTabs(model)
4274
+ delete tabsUi.scrolls[id]
4275
+ if (tabsUi.active === String(id)) {
4276
+ const t = next && model.tabs.find((x) => x.id === next)
4277
+ if (t) tabsUi.restoreScroll = t.id
4278
+ location.hash = t ? tabView(t).href : '#/'
4279
+ } else paintTabs()
4280
+ }
4281
+ function goTab(href) {
4282
+ const id = sessionOfHash(href)
4283
+ if (id) tabsUi.restoreScroll = id
4284
+ if (location.hash === href) render(); else location.hash = href
4285
+ }
4286
+ // ⌘/Ctrl-click or a middle-click on any session link: open it as a permanent tab (here, not a new browser tab)
4287
+ function sessionLinkId(a) { return a && a.getAttribute && sessionOfHash(a.getAttribute('href') || '') }
4288
+ document.addEventListener('click', (e) => {
4289
+ const a = e.target.closest && e.target.closest('a[href^="#/s/"]')
4290
+ if (!a || !(e.metaKey || e.ctrlKey) || e.button !== 0) return
4291
+ const id = sessionLinkId(a); if (!id) return
4292
+ e.preventDefault(); e.stopPropagation()
4293
+ if (indexOf(tabsUi.m, id) >= 0) setTabs(promote(tabsUi.m, id)); else tabsUi.permanent = id
4294
+ if (location.hash === a.getAttribute('href')) render(); else location.hash = a.getAttribute('href')
4295
+ }, true)
4296
+ document.addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest && e.target.closest('a[href^="#/s/"], #tabStrip .tab-cell')) e.preventDefault() }) // no autoscroll
4297
+ document.addEventListener('auxclick', (e) => {
4298
+ if (e.button !== 1 || !e.target.closest) return
4299
+ const cell = e.target.closest('#tabStrip .tab-cell')
4300
+ if (cell) { e.preventDefault(); closeTabNow(cell.dataset.tabId); return } // middle-click a tab: close it
4301
+ const a = e.target.closest('a[href^="#/s/"]')
4302
+ const id = sessionLinkId(a); if (!id) return
4303
+ e.preventDefault()
4304
+ if (indexOf(tabsUi.m, id) >= 0) { setTabs(promote(tabsUi.m, id)); paintTabs() }
4305
+ else { setTabs(openTab(tabsUi.m, id, { permanent: true })); paintTabs() } // opened in the background, the way a middle-click does
4306
+ })
4307
+ document.addEventListener('click', (e) => {
4308
+ if (!e.target.closest) return
4309
+ const close = e.target.closest('[data-tab-close]')
4310
+ if (close) { e.preventDefault(); return closeTabNow(close.dataset.tabClose) }
4311
+ const keep = e.target.closest('[data-tab-keep]')
4312
+ if (keep) { e.preventDefault(); return keepTab(keep.dataset.tabKeep) }
4313
+ const go = e.target.closest('[data-tab-go]')
4314
+ if (go) { e.preventDefault(); return goTab(go.dataset.tabGo) }
4315
+ const all = e.target.closest('#tabAll')
4316
+ if (all) { e.preventDefault(); return openActionMenu(all, allTabsItems(tabViews(), tabsUi.active), { title: 'Open sessions' }) }
4317
+ const t = e.target.closest('#tabStrip a[data-tab], #openRow a[data-tab], #tabPager a[data-tab]')
4318
+ if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
4319
+ })
4320
+ document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
4321
+ // Working in a session keeps its tab: a Live message, typing there, answering Claude there
4322
+ const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
4323
+ for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
4324
+ 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)
4325
+ // Drag a tab to reorder
4326
+ {
4327
+ let dragId = null
4328
+ 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 {} })
4329
+ 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') })
4330
+ 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() } })
4331
+ document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('#tabStrip .dragging, #tabStrip .drop-before').forEach((x) => x.classList.remove('dragging', 'drop-before')) })
4332
+ }
4333
+ // Phones: swipe left / right on a session page for the next / previous open session
4334
+ {
4335
+ let sw = null
4336
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
4337
+ 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 }
4338
+ document.addEventListener('touchstart', (e) => {
4339
+ sw = null
4340
+ if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
4341
+ const t = e.target
4342
+ if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .moremenu') || scrollsSideways(t)) return
4343
+ sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
4344
+ }, { passive: true })
4345
+ document.addEventListener('touchend', (e) => {
4346
+ if (!sw || !e.changedTouches.length) return
4347
+ const p = e.changedTouches[0]
4348
+ const dir = swipeDir(p.clientX - sw.x, p.clientY - sw.y, Date.now() - sw.at)
4349
+ sw = null
4350
+ if (!dir || (window.getSelection && String(window.getSelection()))) return
4351
+ const n = neighbours(tabsUi.m, tabsUi.active)
4352
+ const to = dir === 'next' ? n.next : n.prev
4353
+ if (!to) return
4354
+ const t = tabsUi.m.tabs.find((x) => x.id === to)
4355
+ document.body.classList.remove('swipe-next', 'swipe-prev'); void document.body.offsetWidth
4356
+ document.body.classList.add('swipe-' + dir); setTimeout(() => document.body.classList.remove('swipe-' + dir), 260)
4357
+ goTab(tabView(t).href)
4358
+ }, { passive: true })
4359
+ }
4360
+ // A width change keeps your place: the element at the top of the page stays at
4361
+ // the top, and a Live chat that was at its newest stays there.
4362
+ function anchorY() {
4363
+ let y = 0
4364
+ for (const el of [document.querySelector('.top'), document.getElementById('tabStrip'), document.getElementById('tabPager')]) {
4365
+ if (!el) continue
4366
+ const cs = getComputedStyle(el); if (cs.display === 'none' || !/(fixed|sticky)/.test(cs.position)) continue
4367
+ const r = el.getBoundingClientRect(); if (r.top <= 1 && r.bottom < innerHeight / 2) y = Math.max(y, r.bottom)
4368
+ }
4369
+ return y
4370
+ }
4371
+ function noteAnchor() {
4372
+ if (Date.now() < tabsUi.quietUntil) return
4373
+ const y = anchorY() + 6, m = $app.getBoundingClientRect()
4374
+ if (m.bottom < y) { tabsUi.anchor = null; return }
4375
+ let el = document.elementFromPoint(Math.max(m.left + 24, Math.min(m.left + m.width / 2, innerWidth - 24)), y)
4376
+ 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 }
4377
+ tabsUi.anchor = el ? { el, top: el.getBoundingClientRect().top, scrollY: window.scrollY } : null
4378
+ }
4379
+ function keepPlace() {
4380
+ const a = tabsUi.anchor
4381
+ tabsUi.quietUntil = Date.now() + 250
4382
+ if (a && a.el.isConnected && a.scrollY > 0) window.scrollBy(0, a.el.getBoundingClientRect().top - a.top)
4383
+ document.querySelectorAll('.live-log').forEach((log) => { const st = tabsUi.logs.get(log); if (st && st.bottom) log.scrollTop = log.scrollHeight })
4384
+ }
4385
+ {
4386
+ let raf = 0
4387
+ window.addEventListener('scroll', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; noteAnchor() }) }, { passive: true })
4388
+ 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)
4389
+ let lay = layoutFor(innerWidth)
4390
+ window.addEventListener('resize', () => {
4391
+ keepPlace()
4392
+ const now = layoutFor(innerWidth)
4393
+ if (now !== lay) { lay = now; document.body.dataset.layout = now; scrollActiveTab() }
4394
+ })
4395
+ document.body.dataset.layout = lay
4396
+ }
4397
+
4398
+ // Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
4399
+ // past the line; the page reads its data again, the same as a live update (no
4400
+ // full reload unless that fails). Never from a field, the Live box or a menu.
4401
+ {
4402
+ const ptr = document.createElement('div')
4403
+ ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
4404
+ 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>'
4405
+ document.body.appendChild(ptr)
4406
+ const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
4407
+ const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden]), .emoji-sheet-wrap')
4408
+ let pull = null, refreshing = false
4409
+ const paint = (step) => {
4410
+ ptr.hidden = !step || (!step.pulling && !refreshing)
4411
+ if (ptr.hidden) return
4412
+ ptr.style.transform = 'translate(-50%, ' + (refreshing ? 56 : step.dist) + 'px)'
4413
+ ptr.style.setProperty('--ptr-turn', String(Math.round((step.progress || 0) * 270)) + 'deg')
4414
+ ptr.style.opacity = refreshing ? '1' : String(Math.min(1, 0.25 + (step.progress || 0)))
4415
+ ptr.classList.toggle('armed', !!step.armed)
4416
+ ptr.classList.toggle('busy', refreshing)
4417
+ }
4418
+ async function pullRefresh() {
4419
+ refreshing = true; paint({ pulling: true, dist: 56, progress: 1, armed: true })
4420
+ const started = Date.now()
4421
+ try {
4422
+ prsUi.at = {}; projectsUi.at = 0
4423
+ const r = state.route || parseRoute()
4424
+ const editing = busy() || ['settings', 'setup'].includes(r.name)
4425
+ await Promise.all([editing ? null : render(), refreshHealth()])
4426
+ } catch { location.reload(); return }
4427
+ await new Promise((res) => setTimeout(res, Math.max(0, 450 - (Date.now() - started)))) // long enough to see it turn
4428
+ refreshing = false; ptr.classList.remove('busy', 'armed'); ptr.hidden = true
4429
+ }
4430
+ window.leftoffPullRefresh = pullRefresh
4431
+ document.addEventListener('touchstart', (e) => {
4432
+ if (refreshing || e.touches.length !== 1) { pull = null; return }
4433
+ const t = e.touches[0]
4434
+ 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
4435
+ }, { passive: true })
4436
+ document.addEventListener('touchmove', (e) => {
4437
+ if (!pull || e.touches.length !== 1) return
4438
+ const t = e.touches[0]
4439
+ const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
4440
+ if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
4441
+ if ((window.scrollY || 0) > 0) { pull = null; paint(null); return }
4442
+ if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
4443
+ pull.step = step
4444
+ paint(step)
4445
+ }, { passive: false })
4446
+ const end = () => {
4447
+ if (!pull) return
4448
+ const what = pullRelease(pull.step)
4449
+ pull = null
4450
+ if (what === 'refresh') pullRefresh()
4451
+ else paint(null)
4452
+ }
4453
+ document.addEventListener('touchend', end, { passive: true })
4454
+ document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
4455
+ }
4456
+
4457
+ // Hide the nav on scroll (phones, 0.9.8): under 700px the top row and the bottom
4458
+ // bar slide away while you scroll down and come back when you scroll up, reach
4459
+ // the top or the very bottom (ui/navhide.js). Only your own scrolling hides
4460
+ // them: a route change, the ‹ › pager or a swipe shows them and holds them until
4461
+ // you next touch, wheel or key; typing, a menu or a sheet keeps them shown. The
4462
+ // page never reflows: the top row stays in the flow and the bar is an overlay
4463
+ // over padding that stays.
4464
+ {
4465
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
4466
+ const top = document.querySelector('.top')
4467
+ let st = { hidden: false, anchor: 0 }, raf = 0, held = true
4468
+ const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
4469
+ const overlay = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .lo-backdrop, .doc-viewer:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden]), .emoji-sheet-wrap, .relay-screen:not([hidden])')
4470
+ const apply = (hidden) => {
4471
+ if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
4472
+ document.body.classList.toggle('nav-hidden', hidden)
4473
+ }
4474
+ const read = () => {
4475
+ raf = 0
4476
+ const se = document.scrollingElement || document.documentElement
4477
+ const y = window.scrollY || se.scrollTop || 0
4478
+ const next = navHideStep(st, { y, max: se.scrollHeight - window.innerHeight, blocked: held || !narrowQ.matches || typing() || overlay() })
4479
+ if (next.hidden !== st.hidden) apply(next.hidden)
4480
+ st = next
4481
+ }
4482
+ const soon = () => { if (!raf) raf = requestAnimationFrame(read) }
4483
+ // no layout read here: this runs on every route change, ahead of the page's own render
4484
+ const showNav = (hold = true) => { if (hold) held = true; const was = st.hidden; st = { hidden: false, anchor: NaN }; if (was) apply(false) }
4485
+ window.addEventListener('scroll', soon, { passive: true })
4486
+ for (const ev of ['touchstart', 'wheel', 'keydown']) window.addEventListener(ev, () => { held = false }, { passive: true, capture: true })
4487
+ window.addEventListener('hashchange', () => showNav(true))
4488
+ document.addEventListener('focusin', (e) => { if (typing() || (top && top.contains(e.target))) showNav(false) })
4489
+ document.addEventListener('click', soon, { passive: true, capture: true }) // a menu or sheet just opened: back they come
4490
+ narrowQ.addEventListener('change', () => showNav(false))
4491
+ window.leftoffNav = { show: () => showNav(true), hidden: () => document.body.classList.contains('nav-hidden') }
3359
4492
  }
3360
4493
 
3361
4494
  // Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
@@ -3412,6 +4545,7 @@ function placeStatus() {
3412
4545
  const pop = document.getElementById('statusPop'), b = document.getElementById('health')
3413
4546
  if (!pop || pop.hidden || !b) return
3414
4547
  const r = b.getBoundingClientRect(), vw = document.documentElement.clientWidth
4548
+ 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
4549
  if (vw < 600) { pop.style.left = '8px'; pop.style.right = '8px'; pop.style.top = (r.bottom + 8) + 'px'; return }
3416
4550
  pop.style.right = 'auto'
3417
4551
  pop.style.top = (r.bottom + 8) + 'px'
@@ -3639,11 +4773,27 @@ document.addEventListener('keydown', (e) => {
3639
4773
 
3640
4774
  // A card opens its session's page from anywhere on it, except its buttons,
3641
4775
  // links, fields and selected text (0.4.3: the timeline was too hidden).
4776
+ // 0.9.8: the tap shows at once (a pressed card with a spinner); the page's
4777
+ // shell follows in the same frame (viewSession).
4778
+ function cardOpening(c) {
4779
+ for (const x of document.querySelectorAll('.card.opening')) if (x !== c) x.classList.remove('opening')
4780
+ c.classList.add('opening')
4781
+ setTimeout(() => { if (c.isConnected) c.classList.remove('opening') }, 4000)
4782
+ }
4783
+ document.addEventListener('pointerdown', (ev) => {
4784
+ const c = ev.target.closest && ev.target.closest('.card[data-href]')
4785
+ if (!c || ev.button > 0 || ev.target.closest('button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
4786
+ const a = ev.target.closest('a')
4787
+ if (a && !/^#\/s\//.test(a.getAttribute('href') || '')) return // a repo chip, a PR link: not opening the session
4788
+ cardOpening(c)
4789
+ }, { passive: true })
4790
+ document.addEventListener('pointercancel', () => { for (const x of document.querySelectorAll('.card.opening')) x.classList.remove('opening') }, true)
3642
4791
  document.addEventListener('click', (ev) => {
3643
4792
  const c = ev.target.closest('.card[data-href]')
3644
4793
  if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
3645
4794
  if (String(window.getSelection && window.getSelection()).trim()) return
3646
4795
  if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
4796
+ cardOpening(c)
3647
4797
  location.hash = c.dataset.href
3648
4798
  })
3649
4799
 
@@ -3670,6 +4820,8 @@ document.addEventListener('input', (ev) => {
3670
4820
  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
4821
 
3672
4822
  window.addEventListener('hashchange', () => closeActionMenu())
4823
+ const tipsUi = wireTips(document) // 0.9.8: the ⓘ tooltips (Always allow here)
4824
+ window.addEventListener('hashchange', () => tipsUi.hide())
3673
4825
  // 0.8.9: a session page's title is a button too (Enter or Space renames it)
3674
4826
  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
4827
  // 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
@@ -4003,6 +5155,13 @@ document.addEventListener('click', async (ev) => {
4003
5155
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
4004
5156
  if (action === 'reply-send') return replyClick(btn)
4005
5157
  if (action === 'live-send') return liveSendClick(btn)
5158
+ if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
5159
+ if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
5160
+ if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
5161
+ if (action === 'att-open') return openAttachment(btn)
5162
+ if (action === 'att-chip-open') return openChip(btn.dataset.lid)
5163
+ if (action === 'live-jump') return liveJumpClick(btn)
5164
+ if (action === 'live-earlier') return liveEarlierClick(btn)
4006
5165
  if (action === 'quick-reply') return quickReplyClick(btn)
4007
5166
  if (action === 'need-more') return needMoreClick(btn)
4008
5167
  if (action === 'need-dismiss') return needDismissClick(btn)
@@ -4130,7 +5289,7 @@ document.addEventListener('click', async (ev) => {
4130
5289
  // Answer a permission prompt or a question from the board. The daemon hands it
4131
5290
  // to the waiting hook or mod; the terminal's own prompt may already have won.
4132
5291
  async function answerClick(btn, action) {
4133
- const need = btn.closest('.need')
5292
+ const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
4134
5293
  if (!need) return
4135
5294
  const askId = need.dataset.ask, key = need.dataset.key
4136
5295
  const s = state.sessions.find((x) => x.id === need.dataset.id)
@@ -4164,6 +5323,7 @@ async function answerClick(btn, action) {
4164
5323
  toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
4165
5324
  } finally {
4166
5325
  delete state.pendingAsk[key]
5326
+ if (need.classList.contains('ql-item')) { await loadQuickLook(); refreshHealth() }
4167
5327
  render()
4168
5328
  }
4169
5329
  }
@@ -4479,7 +5639,7 @@ document.addEventListener('submit', async (ev) => {
4479
5639
 
4480
5640
  // Live updates over SSE; re-render the current view, at most every 800 ms.
4481
5641
  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)
5642
+ 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
5643
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
4484
5644
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
4485
5645
  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 +5647,21 @@ let stream = null
4487
5647
  function connect() {
4488
5648
  if (stream) { try { stream.close() } catch {} }
4489
5649
  const es = stream = new EventSource('/v1/stream')
5650
+ const again = connect.opened // a reconnect: events may have been missed while it was down
5651
+ streamDog.opened()
5652
+ // 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
5653
+ es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
4490
5654
  es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
4491
5655
  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)
5656
+ 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
5657
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
4494
5658
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
4495
5659
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
4496
5660
  // A daemon that restarted (an upgrade, a folder move) comes back on the same
4497
5661
  // port; if the browser gave up on the stream, open a new one.
4498
5662
  es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
4499
- es.onopen = () => refreshHealth()
5663
+ let opens = 0
5664
+ es.onopen = () => { if (stream !== es) return; streamDog.seen(); refreshHealth(); if (again || opens++) refreshView('reconnect'); connect.opened = true }
4500
5665
  }
4501
5666
 
4502
5667
  document.addEventListener('change', async (ev) => {
@@ -4605,3 +5770,6 @@ connect()
4605
5770
  // The home-screen app's service worker (v0.4.6): it only serves an offline page
4606
5771
  // when the Mac can't be reached; it caches no API data and no board pages.
4607
5772
  if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
5773
+ // 0.9.8: phone notifications. The one per-device button (More on a phone,
5774
+ // Settings → General everywhere); the service worker shows the pushes.
5775
+ mountPush({ api })