@leftoff/cli 0.9.7 → 0.9.8-beta.2

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,13 +9,18 @@ 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 { wireTips } from './tips.js'
12
15
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
13
16
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
14
17
  import { mdInline, renderSection } from './changelog-md.js'
15
18
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
16
19
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
17
- import { restoreAccess, hintHtml, restoreWhen } from './restore-gate.js'
20
+ import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
18
21
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
22
+ import { sessionTitle, cardPills, foldsAsEmpty, entryHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom } from './live-view.js' // 0.9.8 Live link polish
23
+ import { statusView, elapsedText } from './live-status.js'
19
24
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
20
25
  const $app = document.getElementById('app')
21
26
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
@@ -112,6 +117,10 @@ function copiedMark(btn) {
112
117
  btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = 'Copy resume command' }, 1500)
113
118
  }
114
119
  const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
120
+ // 0.9.8 Live link: the session's channel is listening (send it prompts from here)
121
+ const liveBadge = (s) => (cardPills(s).live ? ' <span class="pill live-pill" title="Live link: send this session messages from the board or your phone">Live</span>' : '')
122
+ // The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
123
+ const statusPill = (s) => { const st = cardPills(s).status; return st ? pill(st) : '' }
115
124
 
116
125
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
117
126
 
@@ -572,15 +581,15 @@ function card(s) {
572
581
  const nb = s.needsYou ? cardBadge(s.needsYou) : ''
573
582
  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)) + '">' +
574
583
  '<div class="row1"><div class="title">' + liveDot(s) +
575
- (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' +
584
+ (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + esc(sessionTitle(s)) + '</a>' +
576
585
  '<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
577
- '</div>' + pill(s.status) + '</div>' +
578
- '<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>' +
586
+ '</div>' + statusPill(s) + liveBadge(s) + '</div>' +
587
+ '<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
579
588
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
580
589
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
581
590
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
582
591
  '<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
583
- copyResumeIcon(s) +
592
+ chatButton(s) + copyResumeIcon(s) +
584
593
  (s.isLocal ? '' : remoteNote(s)) + moreMenu(s, sessionMenuItems(s)) +
585
594
  '</div></article>'
586
595
  }
@@ -751,7 +760,7 @@ async function viewBoard() {
751
760
  // not a resumed conversation) fold into one "N empty sessions" row. One
752
761
  // appears on the board as itself after its first prompt. Pinned ones, and
753
762
  // ones that need you, always show.
754
- const isFolded = (s) => s.empty && !s.pinned && !(s.needsYou && s.needsYou.blocking)
763
+ const isFolded = (s) => foldsAsEmpty(s) // 0.9.8: never a live session
755
764
  const shown = recent.filter((s) => !isFolded(s))
756
765
  const empties = recent.filter(isFolded)
757
766
  const rank = Object.fromEntries(ORDER.map(([k], i) => [k, i]))
@@ -787,7 +796,7 @@ async function viewBoard() {
787
796
  '<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
788
797
  '<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
789
798
  html += syncDetails() + staleLine(sessions)
790
- html += restoreBanner()
799
+ html += restoreAsksBanner() + restoreBanner()
791
800
  // Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
792
801
  const needs = needsItems(sessions)
793
802
  html += needsStrip(needs, needOpts())
@@ -1039,14 +1048,49 @@ function emptyFold(list) {
1039
1048
  // (0.9.7: or when the terminal quit),
1040
1049
  // with a checkbox each; Restore opens each in a new Ghostty tab, resumed by id.
1041
1050
  // Opening tabs runs on the Mac, so (like Go) only its own browser may press it.
1042
- const restoreUi = { open: false, off: new Set(), result: null }
1051
+ const restoreUi = { open: false, off: new Set(), result: null, ask: null, busy: new Set() }
1043
1052
  function restoreDismissKey(r) { return 'lo.restore.dismissed:' + (r.restartAt || '') }
1044
1053
  function restorePicked() { const r = state.restore; return r ? r.sessions.filter((s) => !restoreUi.off.has(s.id)) : [] }
1045
1054
  // 0.9.7: this Mac's own board, or the hosted board open on it (the direct path)
1046
1055
  function restoreGate() {
1047
1056
  const lr = window.leftoffRelay, r = state.restore || {}
1048
1057
  return restoreAccess({ local: state.viewerLocal !== false, hosted: !!lr, route: lr ? lr.route : null, routeMac: lr ? lr.mac : null, machine: r.machine || null,
1049
- phone: isPhoneUa(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints || 0 }) })
1058
+ phone: isPhoneUa(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints || 0 }), canAsk: Array.isArray(r.asks) })
1059
+ }
1060
+ // 0.9.8: Restore asked from a phone or another Mac, waiting on THIS Mac: confirm
1061
+ // or decline here (this Mac's own board, or the hosted board open on it).
1062
+ function restoreAsksBanner() {
1063
+ const r = state.restore
1064
+ const asks = r && Array.isArray(r.asks) ? r.asks : []
1065
+ if (!asks.length) return ''
1066
+ const gate = restoreGate()
1067
+ const here = gate.how === 'local' || gate.how === 'direct'
1068
+ return asks.map((a) => {
1069
+ const what = a.count + ' session' + (a.count === 1 ? '' : 's') + (a.titles && a.titles.length ? ': ' + a.titles.join(', ') + (a.count > a.titles.length ? '…' : '') : '')
1070
+ const busy = restoreUi.busy.has(a.id)
1071
+ return '<div class="banner warn restore-ask" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to restore ' + esc(what) + '</b> on ' + esc(r.machine || 'this Mac') + '. ' +
1072
+ (here ? 'Open them here, each in a new Ghostty tab?' : 'Someone at ' + esc(r.machine || 'that Mac') + ' has to confirm it there.') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
1073
+ (here ? '<button type="button" class="btn primary" data-action="restore-ask-confirm" data-id="' + esc(a.id) + '"' + (busy ? ' aria-disabled="true"' : '') + '>' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Open ' + a.count + ' tab' + (a.count === 1 ? '' : 's')) + '</button>' +
1074
+ '<button type="button" class="btn" data-action="restore-ask-decline" data-id="' + esc(a.id) + '"' + (busy ? ' aria-disabled="true"' : '') + '>Decline</button>' : '') + '</div>'
1075
+ }).join('')
1076
+ }
1077
+ function restoreAskNote() {
1078
+ const x = askLine(restoreUi.ask, { clock })
1079
+ return x ? '<span class="rs-note rs-ask ' + x.tone + '" id="restoreAskNote" role="status">' + hintHtml(x.text, esc) + '</span>' : ''
1080
+ }
1081
+ // The asker's side: poll the request until it's settled.
1082
+ async function pollRestoreAsk() {
1083
+ const a = restoreUi.ask
1084
+ if (!a || !a.id || !['pending', 'confirming'].includes(a.state)) return
1085
+ try {
1086
+ const r = await api('GET', '/v1/restore/asks/' + encodeURIComponent(a.id) + (a.machine ? '?machine=' + encodeURIComponent(a.machine) : ''))
1087
+ if (restoreUi.ask && restoreUi.ask.id === a.id) {
1088
+ const changed = r.state !== a.state
1089
+ restoreUi.ask = { ...a, ...r, machine: a.machine || r.machine }
1090
+ if (changed) { const el = document.getElementById('restoreBanner'); if (el) el.outerHTML = restoreBanner(); if (r.state === 'confirmed') toast('Restored on ' + (a.machine || 'that Mac')) }
1091
+ }
1092
+ } catch (e) { if (restoreUi.ask && restoreUi.ask.id === a.id && /gone|not_found/.test(String(e.message))) restoreUi.ask = { ...a, state: 'failed', error: e.message } }
1093
+ if (restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)) setTimeout(pollRestoreAsk, 2000)
1050
1094
  }
1051
1095
  function restoreBanner() {
1052
1096
  const r = state.restore
@@ -1062,17 +1106,18 @@ function restoreBanner() {
1062
1106
  }
1063
1107
  const gate = restoreGate()
1064
1108
  const picked = restorePicked().length
1109
+ const asking = restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)
1065
1110
  const rows = r.sessions.map((s) => '<label class="rs-row"><input type="checkbox" name="rs" value="' + esc(s.id) + '"' + (restoreUi.off.has(s.id) ? '' : ' checked') + '>' +
1066
1111
  '<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.modelLabel, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span>' + (s.blocked ? '<span class="rs-m err">' + esc(s.error) + '</span>' : '') + '</span></label>').join('')
1067
1112
  return '<div class="banner info restore open" id="restoreBanner" role="region" aria-label="Restore sessions"><div class="rs-head"><span class="grow">' + head + '. Each opens in a new Ghostty tab, in its folder, with <code>claude --resume</code> and its name.</span>' +
1068
1113
  '<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
1069
1114
  '<div class="rs-list">' + rows + '</div>' +
1070
1115
  '<div class="rs-actions">' +
1071
- '<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked ? '' : ' aria-disabled="true"') +
1072
- (gate.ok ? '' : ' aria-describedby="restoreHint" title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
1116
+ '<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked && !asking ? '' : ' aria-disabled="true"') +
1117
+ (gate.ok && gate.how !== 'ask' ? '' : ' aria-describedby="restoreHint"') + (gate.ok ? '' : ' title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
1073
1118
  '<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
1074
1119
  '<button type="button" class="btn" data-action="restore-copy">Copy commands</button>' +
1075
- (gate.ok ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
1120
+ (restoreUi.ask ? restoreAskNote() : gate.ok && gate.how !== 'ask' ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
1076
1121
  '</div>' + (restoreUi.result ? restoreResult() : '') + '</div>'
1077
1122
  }
1078
1123
  // 0.9.7: the hosted board found (or lost) this Mac's direct path: Restore follows it
@@ -1130,9 +1175,11 @@ async function viewWaiting() {
1130
1175
  // The nav's count, the page title and the favicon dot: visible from a background tab.
1131
1176
  function paintNeedsCount(n) {
1132
1177
  const c = document.getElementById('needsCount')
1133
- if (c) { c.hidden = !n; c.textContent = n > 99 ? '99+' : String(n) }
1178
+ if (c) { c.hidden = !n; c.textContent = badgeText(n) }
1134
1179
  const tab = document.getElementById('needsTab')
1135
1180
  if (tab) { tab.classList.toggle('has-needs', !!n); tab.title = n ? n + ' waiting on you' : 'Nothing waiting on you' }
1181
+ if (quickLookOpen() && paintNeedsCount.count !== n) loadQuickLook()
1182
+ paintNeedsCount.count = n
1136
1183
  document.title = (n ? '(' + n + ') ' : '') + 'Leftoff'
1137
1184
  // The tab icon gets a red dot while something waits (leftoff-favicon-badge,
1138
1185
  // v0.4.5); the PNG is for browsers without SVG favicons.
@@ -1158,7 +1205,7 @@ function needOpts(extra = {}) {
1158
1205
  document.addEventListener('keydown', (ev) => {
1159
1206
  if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.altKey || ev.shiftKey) return
1160
1207
  const box = ev.target && ev.target.closest && ev.target.closest('[data-submit]')
1161
- const need = box && box.closest('.need')
1208
+ const need = box && (box.closest('.need') || box.closest('.live-box')) // 0.9.8: the Live box too
1162
1209
  if (!need) return
1163
1210
  ev.preventDefault()
1164
1211
  if (ev.repeat || ev.isComposing) return
@@ -1233,21 +1280,24 @@ async function viewSession(id) {
1233
1280
  // 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)
1234
1281
  const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
1235
1282
  html += '<div class="pagehead"><div>' + (canRename
1236
- ? '<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>'
1237
- : '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + ' ' + 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>' +
1283
+ ? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + esc(sessionTitle(s)) + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
1284
+ : '<h1>' + esc(sessionTitle(s)) + '</h1>') + '<div class="sub">' + statusPill(s) + liveBadge(s) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' &nbsp;' + esc(s.titleSource ? (s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource + ' title') : '') + '</div></div>' +
1238
1285
  '<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
1239
1286
  html += '<div class="facts">' +
1240
1287
  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)) +
1241
1288
  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) +
1242
1289
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
1243
1290
  fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
1291
+ if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + await liveSection(s)
1244
1292
  html += parkRow(s)
1245
1293
  html += spacePicker(s, spv, 'session', s.id)
1246
1294
  html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
1247
1295
  // 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
1248
1296
  html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
1249
1297
  html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
1250
- if (s.needsYou) html += '<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>'
1298
+ // 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
1299
+ if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
1300
+ if (!isLive(s)) html += await liveSection(s)
1251
1301
  if (s.statusChanges && s.statusChanges.length) {
1252
1302
  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>'
1253
1303
  }
@@ -1273,9 +1323,127 @@ async function viewSession(id) {
1273
1323
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
1274
1324
  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>'
1275
1325
  html += answeredList(s)
1326
+ liveKeeper.before($app)
1276
1327
  $app.innerHTML = html
1328
+ liveAfterRender(s.id)
1277
1329
  aroundWire(s.id)
1278
1330
  }
1331
+ // ------------------------------------------------------------ Live link (0.9.8 beta)
1332
+
1333
+ const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
1334
+ 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>' }
1335
+ // 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
1336
+ // bottom); Claude's answer is the session's own (markdown, like the terminal).
1337
+ const liveKeeper = createLiveKeeper()
1338
+ const liveSeenKey = (id) => 'lo-live-seen:' + id
1339
+ function liveSeen(id) { try { return localStorage.getItem(liveSeenKey(id)) } catch { return null } }
1340
+ function markLiveSeen(id, at) { try { localStorage.setItem(liveSeenKey(id), at || new Date().toISOString()) } catch {} }
1341
+ // "Chat" on a live card: straight to the Live box (#/s/<id>/live), with a dot when Claude replied since you last looked.
1342
+ function chatButton(s) {
1343
+ if (!cardPills(s).live || s.agent) return ''
1344
+ const unread = hasUnread(s.liveLink && s.liveLink.lastReplyAt, liveSeen(s.id))
1345
+ 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>'
1346
+ }
1347
+ // The how-to for a session that isn't live (fetched once: the claude line has this Mac's path).
1348
+ async function liveHow() {
1349
+ if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
1350
+ return state.liveHow
1351
+ }
1352
+ async function liveSection(s) {
1353
+ if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
1354
+ if (!isLive(s)) {
1355
+ if (s.endedAt || s.live === 'closed') return ''
1356
+ const how = await liveHow()
1357
+ return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
1358
+ '<p class="faint">To send this session messages from here or your phone, and answer its permission prompts, start it with <code>leftoff live</code>' +
1359
+ (how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p></section>'
1360
+ }
1361
+ let v = null
1362
+ try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
1363
+ const msgs = (v && v.messages) || []
1364
+ const may = state.mayAnswer !== false
1365
+ const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
1366
+ const now = Date.now()
1367
+ const lastClaude = [...msgs].reverse().find((m) => m.from === 'claude')
1368
+ if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1369
+ return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
1370
+ '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1371
+ msgs.map((m) => entryHtml(m, { esc, ago, md: renderDoc, now })).join('') + '</ol>' +
1372
+ '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
1373
+ '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1374
+ (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1375
+ '<div class="reply-row live-row"><textarea class="reply-text live-text" rows="2" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter') + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
1376
+ '<button type="button" class="btn primary" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '"' + (may ? '' : ' aria-disabled="true"') + '>Send</button></div>' +
1377
+ (may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy &amp; security → Trust my paired devices.</p>') + '</section>'
1378
+ }
1379
+ // After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
1380
+ function liveAfterRender(sid) {
1381
+ const r = liveKeeper.after($app)
1382
+ if (!r) return
1383
+ const ol = r.thread.querySelector('.live-log')
1384
+ const empty = r.thread.querySelector('.live-empty')
1385
+ if (empty && ol) empty.hidden = ol.children.length > 0
1386
+ liveJumpButton(r.thread, liveKeeper.unseen)
1387
+ if (ol && !ol.dataset.wired) {
1388
+ ol.dataset.wired = '1'
1389
+ ol.addEventListener('scroll', () => { if (atBottom(ol)) { liveKeeper.seen(); liveJumpButton(r.thread, 0) } }, { passive: true })
1390
+ }
1391
+ // #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
1392
+ const want = parseLiveRoute(location.hash)
1393
+ if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
1394
+ state.liveFocused = location.hash + '|' + sid
1395
+ const box = document.getElementById('live')
1396
+ if (box) box.scrollIntoView({ block: 'start', behavior: 'auto' })
1397
+ if (ol) ol.scrollTop = ol.scrollHeight
1398
+ const ta = box && box.querySelector('.live-text')
1399
+ if (ta && !ta.disabled && !(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)) ta.focus({ preventScroll: true })
1400
+ }
1401
+ liveTick()
1402
+ }
1403
+ function liveJumpButton(thread, n) {
1404
+ const b = thread && thread.querySelector('.live-jump')
1405
+ if (!b) return
1406
+ b.hidden = !(n > 0)
1407
+ const c = b.querySelector('.live-jump-n')
1408
+ if (c) c.textContent = n > 0 ? ' · ' + n + ' new' : ''
1409
+ }
1410
+ function liveJumpClick(btn) {
1411
+ const thread = btn.closest('.live-thread')
1412
+ const ol = thread && thread.querySelector('.live-log')
1413
+ if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
1414
+ liveKeeper.seen(); liveJumpButton(thread, 0)
1415
+ }
1416
+ // The elapsed time beside "Claude is working…", every second while something is in flight.
1417
+ let liveTimer = null
1418
+ function liveTick() {
1419
+ const els = [...document.querySelectorAll('.lv-status[data-since]')]
1420
+ for (const el of els) {
1421
+ const since = Number(el.dataset.since) || 0
1422
+ const end = Number(el.dataset.final) || 0
1423
+ const t = el.querySelector('.lvs-time')
1424
+ if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
1425
+ }
1426
+ const running = els.some((el) => !el.dataset.final)
1427
+ if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
1428
+ }
1429
+ async function liveSendClick(btn) {
1430
+ const box = btn.closest('.live-box')
1431
+ const ta = box && box.querySelector('.live-text')
1432
+ const text = ta ? ta.value.trim() : ''
1433
+ if (!text) { toast('Type a message first'); return }
1434
+ if (btn.getAttribute('aria-disabled') === 'true') return
1435
+ btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Sending…'
1436
+ try {
1437
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/live', { text })
1438
+ if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
1439
+ if (ta) ta.value = ''
1440
+ if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
1441
+ const ol = box && box.querySelector('.live-log')
1442
+ if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
1443
+ setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
1444
+ } catch (e) { toast(e.message, 4200) } finally { await render(); const nb = document.querySelector('.live-box [data-action="live-send"]'); if (nb) { nb.removeAttribute('aria-disabled'); nb.textContent = 'Send' } }
1445
+ }
1446
+
1279
1447
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
1280
1448
 
1281
1449
  // Plain text + [start, end] ranges -> escaped HTML with <mark>s (ranges that
@@ -3146,7 +3314,7 @@ function parseRoute() {
3146
3314
 
3147
3315
  async function render() {
3148
3316
  const r = (state.route = parseRoute())
3149
- 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') })
3317
+ paintNav(r)
3150
3318
  try {
3151
3319
  if (r.name === 's' && r.arg) await viewSession(r.arg)
3152
3320
  else if (r.name === 'waiting') await viewWaiting()
@@ -3233,6 +3401,7 @@ async function refreshHealth() {
3233
3401
  if (h.answering) state.mayAnswer = h.answering.allowedHere !== false
3234
3402
  const dot = document.querySelector('#wnTab .newdot')
3235
3403
  if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
3404
+ paintMoreDots()
3236
3405
  paintSettingsDots()
3237
3406
  } catch (e) {
3238
3407
  el.className = 'health down'
@@ -3263,6 +3432,229 @@ function paintTopDot() {
3263
3432
  el.className = 'topdot' + (warn ? ' warn' : '')
3264
3433
  const why = all.flatMap((x) => x.why)
3265
3434
  document.getElementById('setTab').title = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
3435
+ paintMoreDots()
3436
+ }
3437
+
3438
+ // ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
3439
+ // From 820px up the sections sit in a rail on the left; under it, in a bottom
3440
+ // bar with the rest under More. Hover the Needs you item (or long-press it on a
3441
+ // phone, or press ↓/→ on it) for Quick look: the count and the five longest
3442
+ // waits, with Allow / Deny on permission prompts (the Needs you page's own
3443
+ // buttons, so the same checks apply and nothing is answered without a click).
3444
+ const railMode = () => window.matchMedia('(min-width: 820px)').matches
3445
+ function paintNav(r = state.route || parseRoute()) {
3446
+ const key = navKey(r.name)
3447
+ 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') })
3448
+ const more = document.getElementById('moreBtn')
3449
+ if (more) more.classList.toggle('on', moreActive(key))
3450
+ }
3451
+ function paintMoreDots() {
3452
+ const wn = document.querySelector('#wnTab .newdot'), st = document.querySelector('#setTab .topdot')
3453
+ const mwn = document.querySelector('#moreMenu .newdot'), mst = document.querySelector('#moreMenu .topdot')
3454
+ if (mwn && wn) mwn.hidden = wn.hidden
3455
+ if (mst && st) { mst.hidden = st.hidden; mst.className = st.className }
3456
+ const md = document.querySelector('#moreBtn .moredot')
3457
+ if (md) { md.hidden = !((wn && !wn.hidden) || (st && !st.hidden)); md.className = 'moredot' + (st && !st.hidden && st.classList.contains('warn') ? ' warn' : '') }
3458
+ }
3459
+
3460
+ // More (phones)
3461
+ const moreOpen = () => { const m = document.getElementById('moreMenu'); return !!m && !m.hidden }
3462
+ function closeMore(focusBtn = false) {
3463
+ const m = document.getElementById('moreMenu'), b = document.getElementById('moreBtn')
3464
+ if (!m || m.hidden) return
3465
+ m.hidden = true
3466
+ if (b) { b.setAttribute('aria-expanded', 'false'); if (focusBtn) b.focus() }
3467
+ }
3468
+ function openMore() {
3469
+ const m = document.getElementById('moreMenu'), b = document.getElementById('moreBtn')
3470
+ if (!m) return
3471
+ closeQuickLook(); toggleStatus(false)
3472
+ paintMoreDots()
3473
+ m.hidden = false
3474
+ if (b) b.setAttribute('aria-expanded', 'true')
3475
+ const first = m.querySelector('a.on') || m.querySelector('a')
3476
+ if (first) first.focus()
3477
+ }
3478
+ document.getElementById('moreBtn').addEventListener('click', () => (moreOpen() ? closeMore() : openMore()))
3479
+ document.getElementById('moreMenu').addEventListener('click', (e) => { if (e.target.closest('a')) closeMore() })
3480
+ document.getElementById('moreMenu').addEventListener('keydown', (e) => {
3481
+ const items = [...document.querySelectorAll('#moreMenu a')]
3482
+ const i = items.indexOf(document.activeElement)
3483
+ if (e.key === 'Escape') { e.preventDefault(); closeMore(true) }
3484
+ 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() }
3485
+ else if (e.key === 'Tab') closeMore()
3486
+ })
3487
+
3488
+ // Quick look
3489
+ const qlUi = { open: false, how: null, openT: 0, closeT: 0, suppressClick: false, model: null, seq: 0 }
3490
+ const quickLookOpen = () => qlUi.open
3491
+ function quickLookFocusables() { const p = document.getElementById('quickLook'); return p ? [...p.querySelectorAll('a[href], button:not([disabled])')] : [] }
3492
+ function paintQuickLook() {
3493
+ const pop = document.getElementById('quickLook')
3494
+ if (!pop || !qlUi.open) return
3495
+ const had = pop.contains(document.activeElement) ? document.activeElement : null
3496
+ 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') })
3497
+ const model = qlUi.model || { total: Number((state.health || {}).needsYou) || 0, items: [], more: 0 }
3498
+ pop.innerHTML = quickLookHtml(model, { mayAnswer: state.mayAnswer, pending: state.pendingAsk })
3499
+ pop.setAttribute('aria-busy', qlUi.model ? 'false' : 'true')
3500
+ placeQuickLook()
3501
+ if (keep) {
3502
+ let el = null
3503
+ 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])') }
3504
+ else if (keep.foot) el = pop.querySelector('.ql-foot')
3505
+ ;(el || quickLookFocusables()[0] || pop).focus()
3506
+ }
3507
+ }
3508
+ function placeQuickLook() {
3509
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
3510
+ if (!pop || pop.hidden || !tab) return
3511
+ if (!railMode()) { pop.style.left = ''; pop.style.top = ''; return } // the CSS puts it above the bottom bar
3512
+ const r = tab.getBoundingClientRect(), rail = document.querySelector('.top').getBoundingClientRect()
3513
+ pop.style.left = (Math.max(r.right, rail.right) + 8) + 'px'
3514
+ pop.style.top = Math.max(8, Math.min(r.top - 6, window.innerHeight - 8 - pop.offsetHeight)) + 'px'
3515
+ }
3516
+ // The sessions behind the popover: what the page already has, then a fresh read.
3517
+ async function loadQuickLook() {
3518
+ const seq = ++qlUi.seq
3519
+ if (state.sessions && state.sessions.length && !qlUi.model) { qlUi.model = quickLookItems(state.sessions); paintQuickLook() }
3520
+ try {
3521
+ const { sessions, viewer } = await api('GET', '/v1/sessions?limit=200', undefined, { timeoutMs: 8000 })
3522
+ if (seq !== qlUi.seq) return
3523
+ if (viewer) state.mayAnswer = viewer.mayAnswer !== false
3524
+ qlUi.model = quickLookItems(sessions || [])
3525
+ } catch { if (!qlUi.model) qlUi.model = { total: 0, items: [], more: 0, failed: true } }
3526
+ paintQuickLook()
3527
+ }
3528
+ function openQuickLook(how = 'hover') {
3529
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
3530
+ if (!pop || !tab) return
3531
+ clearTimeout(qlUi.openT); clearTimeout(qlUi.closeT)
3532
+ if (qlUi.open) { if (how !== 'hover') { qlUi.how = how; const f = quickLookFocusables()[0]; if (f) f.focus() } return }
3533
+ closeMore(); toggleStatus(false)
3534
+ qlUi.open = true; qlUi.how = how; qlUi.model = null
3535
+ pop.hidden = false
3536
+ tab.setAttribute('aria-expanded', 'true')
3537
+ // Hover never takes focus; the keyboard and a long-press move into the card (and Tab stays inside it).
3538
+ pop.setAttribute('aria-modal', how === 'hover' ? 'false' : 'true')
3539
+ paintQuickLook()
3540
+ if (how !== 'hover') { const f = quickLookFocusables()[0]; (f || pop).focus() }
3541
+ loadQuickLook()
3542
+ }
3543
+ function closeQuickLook(focusTab = false) {
3544
+ clearTimeout(qlUi.openT); clearTimeout(qlUi.closeT)
3545
+ if (!qlUi.open) return
3546
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
3547
+ const inside = pop && pop.contains(document.activeElement)
3548
+ qlUi.open = false; qlUi.seq++
3549
+ if (pop) { pop.hidden = true; pop.innerHTML = '' }
3550
+ if (tab) { tab.setAttribute('aria-expanded', 'false'); if (focusTab || inside) tab.focus() }
3551
+ }
3552
+ {
3553
+ const tab = document.getElementById('needsTab'), pop = document.getElementById('quickLook')
3554
+ const canHover = () => window.matchMedia('(hover: hover) and (pointer: fine)').matches
3555
+ const later = (fn, ms) => { clearTimeout(qlUi.closeT); clearTimeout(qlUi.openT); return setTimeout(fn, ms) }
3556
+ tab.addEventListener('mouseenter', () => { if (!canHover()) return; clearTimeout(qlUi.closeT); if (!qlUi.open) qlUi.openT = later(() => openQuickLook('hover'), 280) })
3557
+ tab.addEventListener('mouseleave', () => { clearTimeout(qlUi.openT); if (qlUi.open && qlUi.how === 'hover') qlUi.closeT = setTimeout(() => closeQuickLook(), 320) })
3558
+ pop.addEventListener('mouseenter', () => clearTimeout(qlUi.closeT))
3559
+ pop.addEventListener('mouseleave', () => { if (qlUi.open && qlUi.how === 'hover' && !pop.contains(document.activeElement)) qlUi.closeT = setTimeout(() => closeQuickLook(), 320) })
3560
+ // Long-press on a touch screen; the tap that ends it doesn't navigate.
3561
+ let lp = null
3562
+ tab.addEventListener('pointerdown', (e) => {
3563
+ if (e.pointerType === 'mouse') return
3564
+ const x = e.clientX, y = e.clientY
3565
+ clearTimeout(lp && lp.t)
3566
+ lp = { x, y, t: setTimeout(() => { qlUi.suppressClick = true; openQuickLook('touch'); if (navigator.vibrate) try { navigator.vibrate(10) } catch {} }, 450) }
3567
+ })
3568
+ const cancel = () => { if (lp) { clearTimeout(lp.t); lp = null } }
3569
+ tab.addEventListener('pointerup', cancel)
3570
+ tab.addEventListener('pointercancel', cancel)
3571
+ tab.addEventListener('pointermove', (e) => { if (lp && Math.hypot(e.clientX - lp.x, e.clientY - lp.y) > 10) cancel() })
3572
+ tab.addEventListener('contextmenu', (e) => { if (qlUi.open || lp) e.preventDefault() })
3573
+ tab.addEventListener('click', (e) => {
3574
+ if (qlUi.suppressClick) { qlUi.suppressClick = false; e.preventDefault(); return }
3575
+ // A second tap while it's open goes to the page, as the footer does.
3576
+ closeQuickLook()
3577
+ })
3578
+ tab.addEventListener('keydown', (e) => {
3579
+ if (['ArrowDown', 'ArrowRight', 'ArrowUp'].includes(e.key) && !e.altKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); openQuickLook('keyboard') }
3580
+ })
3581
+ pop.addEventListener('keydown', (e) => {
3582
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeQuickLook(true); return }
3583
+ if (e.key !== 'Tab') return
3584
+ const f = quickLookFocusables()
3585
+ if (!f.length) { e.preventDefault(); return }
3586
+ const i = f.indexOf(document.activeElement)
3587
+ if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus() }
3588
+ else if (!e.shiftKey && (i === -1 || i === f.length - 1)) { e.preventDefault(); f[0].focus() }
3589
+ })
3590
+ pop.addEventListener('focusin', () => { clearTimeout(qlUi.closeT); pop.setAttribute('aria-modal', 'true') })
3591
+ pop.addEventListener('click', (e) => { const a = e.target.closest('a[href]'); if (a) closeQuickLook() })
3592
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && qlUi.open) closeQuickLook(true) })
3593
+ document.addEventListener('pointerdown', (e) => {
3594
+ if (qlUi.open && !e.target.closest('#quickLook, #needsTab')) closeQuickLook()
3595
+ if (moreOpen() && !e.target.closest('#moreMenu, #moreBtn')) closeMore()
3596
+ }, true)
3597
+ window.addEventListener('hashchange', () => { closeQuickLook(); closeMore() })
3598
+ window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
3599
+ }
3600
+
3601
+ // Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
3602
+ // past the line; the page reads its data again, the same as a live update (no
3603
+ // full reload unless that fails). Never from a field, the Live box or a menu.
3604
+ {
3605
+ const ptr = document.createElement('div')
3606
+ ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
3607
+ ptr.innerHTML = '<span class="ptr-ring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></span>'
3608
+ document.body.appendChild(ptr)
3609
+ const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 819.98px)')
3610
+ const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
3611
+ let pull = null, refreshing = false
3612
+ const paint = (step) => {
3613
+ ptr.hidden = !step || (!step.pulling && !refreshing)
3614
+ if (ptr.hidden) return
3615
+ ptr.style.transform = 'translate(-50%, ' + (refreshing ? 56 : step.dist) + 'px)'
3616
+ ptr.style.setProperty('--ptr-turn', String(Math.round((step.progress || 0) * 270)) + 'deg')
3617
+ ptr.style.opacity = refreshing ? '1' : String(Math.min(1, 0.25 + (step.progress || 0)))
3618
+ ptr.classList.toggle('armed', !!step.armed)
3619
+ ptr.classList.toggle('busy', refreshing)
3620
+ }
3621
+ async function pullRefresh() {
3622
+ refreshing = true; paint({ pulling: true, dist: 56, progress: 1, armed: true })
3623
+ const started = Date.now()
3624
+ try {
3625
+ prsUi.at = {}; projectsUi.at = 0
3626
+ const r = state.route || parseRoute()
3627
+ const editing = busy() || ['settings', 'setup'].includes(r.name)
3628
+ await Promise.all([editing ? null : render(), refreshHealth()])
3629
+ } catch { location.reload(); return }
3630
+ await new Promise((res) => setTimeout(res, Math.max(0, 450 - (Date.now() - started)))) // long enough to see it turn
3631
+ refreshing = false; ptr.classList.remove('busy', 'armed'); ptr.hidden = true
3632
+ }
3633
+ window.leftoffPullRefresh = pullRefresh
3634
+ document.addEventListener('touchstart', (e) => {
3635
+ if (refreshing || e.touches.length !== 1) { pull = null; return }
3636
+ const t = e.touches[0]
3637
+ 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
3638
+ }, { passive: true })
3639
+ document.addEventListener('touchmove', (e) => {
3640
+ if (!pull || e.touches.length !== 1) return
3641
+ const t = e.touches[0]
3642
+ const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
3643
+ if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
3644
+ if ((window.scrollY || 0) > 0) { pull = null; paint(null); return }
3645
+ if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
3646
+ pull.step = step
3647
+ paint(step)
3648
+ }, { passive: false })
3649
+ const end = () => {
3650
+ if (!pull) return
3651
+ const what = pullRelease(pull.step)
3652
+ pull = null
3653
+ if (what === 'refresh') pullRefresh()
3654
+ else paint(null)
3655
+ }
3656
+ document.addEventListener('touchend', end, { passive: true })
3657
+ document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
3266
3658
  }
3267
3659
 
3268
3660
  // Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
@@ -3319,6 +3711,7 @@ function placeStatus() {
3319
3711
  const pop = document.getElementById('statusPop'), b = document.getElementById('health')
3320
3712
  if (!pop || pop.hidden || !b) return
3321
3713
  const r = b.getBoundingClientRect(), vw = document.documentElement.clientWidth
3714
+ 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
3322
3715
  if (vw < 600) { pop.style.left = '8px'; pop.style.right = '8px'; pop.style.top = (r.bottom + 8) + 'px'; return }
3323
3716
  pop.style.right = 'auto'
3324
3717
  pop.style.top = (r.bottom + 8) + 'px'
@@ -3577,6 +3970,8 @@ document.addEventListener('input', (ev) => {
3577
3970
  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 })) } })
3578
3971
 
3579
3972
  window.addEventListener('hashchange', () => closeActionMenu())
3973
+ const tipsUi = wireTips(document) // 0.9.8: the ⓘ tooltips (Always allow here)
3974
+ window.addEventListener('hashchange', () => tipsUi.hide())
3580
3975
  // 0.8.9: a session page's title is a button too (Enter or Space renames it)
3581
3976
  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() } })
3582
3977
  // 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
@@ -3726,6 +4121,18 @@ document.addEventListener('click', async (ev) => {
3726
4121
  } catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
3727
4122
  return
3728
4123
  }
4124
+ if (action === 'restore-ask-confirm' || action === 'restore-ask-decline') {
4125
+ const id = btn.dataset.id
4126
+ if (!id || restoreUi.busy.has(id)) return
4127
+ restoreUi.busy.add(id); render()
4128
+ try {
4129
+ const r = await api('POST', '/v1/restore/asks/' + encodeURIComponent(id) + '/' + (action === 'restore-ask-confirm' ? 'confirm' : 'decline'), {}, { timeoutMs: 60000 })
4130
+ toast(r.state === 'confirmed' ? 'Restored ' + ((r.result && r.result.opened) || 0) + ' in Ghostty' : r.state === 'declined' ? 'Declined' : 'That request was ' + r.state)
4131
+ } catch (e) { toast(e.message, 5000) }
4132
+ restoreUi.busy.delete(id)
4133
+ try { state.restore = await api('GET', '/v1/restore') } catch {}
4134
+ return render()
4135
+ }
3729
4136
  if (action === 'restore-open') { restoreUi.open = true; restoreUi.result = null; return render() }
3730
4137
  if (action === 'restore-close') { restoreUi.open = false; return render() }
3731
4138
  if (action === 'restore-dismiss') { try { localStorage.setItem(restoreDismissKey(state.restore || {}), '1') } catch {} restoreUi.open = false; return render() }
@@ -3736,6 +4143,18 @@ document.addEventListener('click', async (ev) => {
3736
4143
  if (!ids.length) { toast('Pick at least one'); return }
3737
4144
  const gate = restoreGate()
3738
4145
  if (!gate.ok) { toast(gate.hint.replace(/`/g, ''), 6000); return }
4146
+ // 0.9.8: from a phone or another Mac, ask that Mac; a person there confirms
4147
+ if (gate.how === 'ask') {
4148
+ if (restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)) return
4149
+ const machine = (state.restore && state.restore.machine) || null
4150
+ btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Asking ' + (machine || 'the Mac') + '…'
4151
+ try {
4152
+ const r = await api('POST', '/v1/restore/asks', { machine, ids })
4153
+ restoreUi.ask = { ...r, machine: machine || r.machine }
4154
+ setTimeout(pollRestoreAsk, 2000)
4155
+ } catch (e) { restoreUi.ask = { state: 'error', machine, error: e.message } }
4156
+ return render()
4157
+ }
3739
4158
  btn.disabled = true; btn.textContent = 'Opening ' + ids.length + '…'
3740
4159
  const r = await api('POST', '/v1/restore', { ids }, { timeoutMs: 60000 })
3741
4160
  restoreUi.result = r.how === 'print' || r.failed ? r : null
@@ -3885,6 +4304,8 @@ document.addEventListener('click', async (ev) => {
3885
4304
  }
3886
4305
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
3887
4306
  if (action === 'reply-send') return replyClick(btn)
4307
+ if (action === 'live-send') return liveSendClick(btn)
4308
+ if (action === 'live-jump') return liveJumpClick(btn)
3888
4309
  if (action === 'quick-reply') return quickReplyClick(btn)
3889
4310
  if (action === 'need-more') return needMoreClick(btn)
3890
4311
  if (action === 'need-dismiss') return needDismissClick(btn)
@@ -4012,7 +4433,7 @@ document.addEventListener('click', async (ev) => {
4012
4433
  // Answer a permission prompt or a question from the board. The daemon hands it
4013
4434
  // to the waiting hook or mod; the terminal's own prompt may already have won.
4014
4435
  async function answerClick(btn, action) {
4015
- const need = btn.closest('.need')
4436
+ const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
4016
4437
  if (!need) return
4017
4438
  const askId = need.dataset.ask, key = need.dataset.key
4018
4439
  const s = state.sessions.find((x) => x.id === need.dataset.id)
@@ -4046,6 +4467,7 @@ async function answerClick(btn, action) {
4046
4467
  toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
4047
4468
  } finally {
4048
4469
  delete state.pendingAsk[key]
4470
+ if (need.classList.contains('ql-item')) { await loadQuickLook(); refreshHealth() }
4049
4471
  render()
4050
4472
  }
4051
4473
  }
@@ -4361,7 +4783,7 @@ document.addEventListener('submit', async (ev) => {
4361
4783
 
4362
4784
  // Live updates over SSE; re-render the current view, at most every 800 ms.
4363
4785
  let pending = null
4364
- 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))
4786
+ 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)
4365
4787
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
4366
4788
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
4367
4789
  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) }