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

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,9 @@ 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'
@@ -16,6 +19,8 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
16
19
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
17
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, threadHtml, earlierHtml, 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: {} }
@@ -113,7 +118,9 @@ function copiedMark(btn) {
113
118
  }
114
119
  const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
115
120
  // 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>' : '')
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) : '' }
117
124
 
118
125
  const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
119
126
 
@@ -574,15 +581,15 @@ function card(s) {
574
581
  const nb = s.needsYou ? cardBadge(s.needsYou) : ''
575
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)) + '">' +
576
583
  '<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>' +
584
+ (s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + esc(sessionTitle(s)) + '</a>' +
578
585
  '<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>' +
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>' +
581
588
  (s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
582
589
  cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
583
590
  '<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
584
591
  '<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) +
592
+ chatButton(s) + copyResumeIcon(s) +
586
593
  (s.isLocal ? '' : remoteNote(s)) + moreMenu(s, sessionMenuItems(s)) +
587
594
  '</div></article>'
588
595
  }
@@ -753,7 +760,7 @@ async function viewBoard() {
753
760
  // not a resumed conversation) fold into one "N empty sessions" row. One
754
761
  // appears on the board as itself after its first prompt. Pinned ones, and
755
762
  // ones that need you, always show.
756
- 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
757
764
  const shown = recent.filter((s) => !isFolded(s))
758
765
  const empties = recent.filter(isFolded)
759
766
  const rank = Object.fromEntries(ORDER.map(([k], i) => [k, i]))
@@ -1168,9 +1175,11 @@ async function viewWaiting() {
1168
1175
  // The nav's count, the page title and the favicon dot: visible from a background tab.
1169
1176
  function paintNeedsCount(n) {
1170
1177
  const c = document.getElementById('needsCount')
1171
- if (c) { c.hidden = !n; c.textContent = n > 99 ? '99+' : String(n) }
1178
+ if (c) { c.hidden = !n; c.textContent = badgeText(n) }
1172
1179
  const tab = document.getElementById('needsTab')
1173
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
1174
1183
  document.title = (n ? '(' + n + ') ' : '') + 'Leftoff'
1175
1184
  // The tab icon gets a red dot while something waits (leftoff-favicon-badge,
1176
1185
  // v0.4.5); the PNG is for browsers without SVG favicons.
@@ -1271,8 +1280,8 @@ async function viewSession(id) {
1271
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)
1272
1281
  const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
1273
1282
  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>' +
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>' +
1276
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>'
1277
1286
  html += '<div class="facts">' +
1278
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)) +
@@ -1314,18 +1323,26 @@ async function viewSession(id) {
1314
1323
  const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
1315
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>'
1316
1325
  html += answeredList(s)
1326
+ liveKeeper.before($app)
1317
1327
  $app.innerHTML = html
1328
+ liveAfterRender(s.id)
1318
1329
  aroundWire(s.id)
1319
1330
  }
1320
1331
  // ------------------------------------------------------------ Live link (0.9.8 beta)
1321
1332
 
1322
1333
  const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
1323
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>' }
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>'
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>'
1329
1346
  }
1330
1347
  // The how-to for a session that isn't live (fetched once: the claude line has this Mac's path).
1331
1348
  async function liveHow() {
@@ -1342,17 +1359,84 @@ async function liveSection(s) {
1342
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>'
1343
1360
  }
1344
1361
  let v = null
1345
- try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
1362
+ const turns = (state.liveTurns && state.liveTurns[s.id]) || 20 // "Show earlier" adds 20 at a time
1363
+ try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live' + (turns > 20 ? '?turns=' + turns : '')) } catch (e) { v = { messages: [], error: e.message } }
1346
1364
  const msgs = (v && v.messages) || []
1347
1365
  const may = state.mayAnswer !== false
1348
1366
  const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
1367
+ const now = Date.now()
1368
+ const lastClaude = [...msgs].reverse().find((m) => m.from === 'claude')
1369
+ if (lastClaude) markLiveSeen(s.id, lastClaude.at)
1349
1370
  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>') +
1371
+ '<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
1372
+ earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now }) + '</ol>' +
1373
+ '<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
1374
+ '<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
1351
1375
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1352
1376
  '<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
1377
  '<button type="button" class="btn primary" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '"' + (may ? '' : ' aria-disabled="true"') + '>Send</button></div>' +
1354
1378
  (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
1379
  }
1380
+ // After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
1381
+ function liveAfterRender(sid) {
1382
+ const r = liveKeeper.after($app)
1383
+ if (!r) return
1384
+ const ol = r.thread.querySelector('.live-log')
1385
+ const empty = r.thread.querySelector('.live-empty')
1386
+ if (empty && ol) empty.hidden = ol.children.length > 0
1387
+ liveJumpButton(r.thread, liveKeeper.unseen)
1388
+ if (ol && !ol.dataset.wired) {
1389
+ ol.dataset.wired = '1'
1390
+ ol.addEventListener('scroll', () => { if (atBottom(ol)) { liveKeeper.seen(); liveJumpButton(r.thread, 0) } }, { passive: true })
1391
+ }
1392
+ // #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
1393
+ const want = parseLiveRoute(location.hash)
1394
+ if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
1395
+ state.liveFocused = location.hash + '|' + sid
1396
+ const box = document.getElementById('live')
1397
+ if (box) box.scrollIntoView({ block: 'start', behavior: 'auto' })
1398
+ if (ol) ol.scrollTop = ol.scrollHeight
1399
+ const ta = box && box.querySelector('.live-text')
1400
+ if (ta && !ta.disabled && !(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)) ta.focus({ preventScroll: true })
1401
+ }
1402
+ liveTick()
1403
+ }
1404
+ function liveJumpButton(thread, n) {
1405
+ const b = thread && thread.querySelector('.live-jump')
1406
+ if (!b) return
1407
+ b.hidden = !(n > 0)
1408
+ const c = b.querySelector('.live-jump-n')
1409
+ if (c) c.textContent = n > 0 ? ' · ' + n + ' new' : ''
1410
+ }
1411
+ function liveJumpClick(btn) {
1412
+ const thread = btn.closest('.live-thread')
1413
+ const ol = thread && thread.querySelector('.live-log')
1414
+ if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
1415
+ liveKeeper.seen(); liveJumpButton(thread, 0)
1416
+ }
1417
+ // "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
1418
+ async function liveEarlierClick(btn) {
1419
+ const box = btn.closest('.live-box')
1420
+ const sid = box && box.dataset.id
1421
+ if (!sid) return
1422
+ state.liveTurns = state.liveTurns || {}
1423
+ state.liveTurns[sid] = ((state.liveTurns[sid]) || 20) + 20
1424
+ btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading…'
1425
+ await render()
1426
+ }
1427
+ // The elapsed time beside "Claude is working…", every second while something is in flight.
1428
+ let liveTimer = null
1429
+ function liveTick() {
1430
+ const els = [...document.querySelectorAll('.lv-status[data-since]')]
1431
+ for (const el of els) {
1432
+ const since = Number(el.dataset.since) || 0
1433
+ const end = Number(el.dataset.final) || 0
1434
+ const t = el.querySelector('.lvs-time')
1435
+ if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
1436
+ }
1437
+ const running = els.some((el) => !el.dataset.final)
1438
+ if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
1439
+ }
1356
1440
  async function liveSendClick(btn) {
1357
1441
  const box = btn.closest('.live-box')
1358
1442
  const ta = box && box.querySelector('.live-text')
@@ -1365,8 +1449,10 @@ async function liveSendClick(btn) {
1365
1449
  if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
1366
1450
  if (ta) ta.value = ''
1367
1451
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
1452
+ const ol = box && box.querySelector('.live-log')
1453
+ if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
1368
1454
  setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
1369
- } catch (e) { toast(e.message, 4200) } finally { render() }
1455
+ } catch (e) { toast(e.message, 4200) } finally { await render(); const nb = document.querySelector('.live-box [data-action="live-send"]'); if (nb) { nb.removeAttribute('aria-disabled'); nb.textContent = 'Send' } }
1370
1456
  }
1371
1457
 
1372
1458
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
@@ -3239,7 +3325,7 @@ function parseRoute() {
3239
3325
 
3240
3326
  async function render() {
3241
3327
  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') })
3328
+ paintNav(r)
3243
3329
  try {
3244
3330
  if (r.name === 's' && r.arg) await viewSession(r.arg)
3245
3331
  else if (r.name === 'waiting') await viewWaiting()
@@ -3326,6 +3412,7 @@ async function refreshHealth() {
3326
3412
  if (h.answering) state.mayAnswer = h.answering.allowedHere !== false
3327
3413
  const dot = document.querySelector('#wnTab .newdot')
3328
3414
  if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
3415
+ paintMoreDots()
3329
3416
  paintSettingsDots()
3330
3417
  } catch (e) {
3331
3418
  el.className = 'health down'
@@ -3356,6 +3443,229 @@ function paintTopDot() {
3356
3443
  el.className = 'topdot' + (warn ? ' warn' : '')
3357
3444
  const why = all.flatMap((x) => x.why)
3358
3445
  document.getElementById('setTab').title = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
3446
+ paintMoreDots()
3447
+ }
3448
+
3449
+ // ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
3450
+ // From 820px up the sections sit in a rail on the left; under it, in a bottom
3451
+ // bar with the rest under More. Hover the Needs you item (or long-press it on a
3452
+ // phone, or press ↓/→ on it) for Quick look: the count and the five longest
3453
+ // waits, with Allow / Deny on permission prompts (the Needs you page's own
3454
+ // buttons, so the same checks apply and nothing is answered without a click).
3455
+ const railMode = () => window.matchMedia('(min-width: 820px)').matches
3456
+ function paintNav(r = state.route || parseRoute()) {
3457
+ const key = navKey(r.name)
3458
+ 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') })
3459
+ const more = document.getElementById('moreBtn')
3460
+ if (more) more.classList.toggle('on', moreActive(key))
3461
+ }
3462
+ function paintMoreDots() {
3463
+ const wn = document.querySelector('#wnTab .newdot'), st = document.querySelector('#setTab .topdot')
3464
+ const mwn = document.querySelector('#moreMenu .newdot'), mst = document.querySelector('#moreMenu .topdot')
3465
+ if (mwn && wn) mwn.hidden = wn.hidden
3466
+ if (mst && st) { mst.hidden = st.hidden; mst.className = st.className }
3467
+ const md = document.querySelector('#moreBtn .moredot')
3468
+ if (md) { md.hidden = !((wn && !wn.hidden) || (st && !st.hidden)); md.className = 'moredot' + (st && !st.hidden && st.classList.contains('warn') ? ' warn' : '') }
3469
+ }
3470
+
3471
+ // More (phones)
3472
+ const moreOpen = () => { const m = document.getElementById('moreMenu'); return !!m && !m.hidden }
3473
+ function closeMore(focusBtn = false) {
3474
+ const m = document.getElementById('moreMenu'), b = document.getElementById('moreBtn')
3475
+ if (!m || m.hidden) return
3476
+ m.hidden = true
3477
+ if (b) { b.setAttribute('aria-expanded', 'false'); if (focusBtn) b.focus() }
3478
+ }
3479
+ function openMore() {
3480
+ const m = document.getElementById('moreMenu'), b = document.getElementById('moreBtn')
3481
+ if (!m) return
3482
+ closeQuickLook(); toggleStatus(false)
3483
+ paintMoreDots()
3484
+ m.hidden = false
3485
+ if (b) b.setAttribute('aria-expanded', 'true')
3486
+ const first = m.querySelector('a.on') || m.querySelector('a')
3487
+ if (first) first.focus()
3488
+ }
3489
+ document.getElementById('moreBtn').addEventListener('click', () => (moreOpen() ? closeMore() : openMore()))
3490
+ document.getElementById('moreMenu').addEventListener('click', (e) => { if (e.target.closest('a')) closeMore() })
3491
+ document.getElementById('moreMenu').addEventListener('keydown', (e) => {
3492
+ const items = [...document.querySelectorAll('#moreMenu a')]
3493
+ const i = items.indexOf(document.activeElement)
3494
+ if (e.key === 'Escape') { e.preventDefault(); closeMore(true) }
3495
+ 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() }
3496
+ else if (e.key === 'Tab') closeMore()
3497
+ })
3498
+
3499
+ // Quick look
3500
+ const qlUi = { open: false, how: null, openT: 0, closeT: 0, suppressClick: false, model: null, seq: 0 }
3501
+ const quickLookOpen = () => qlUi.open
3502
+ function quickLookFocusables() { const p = document.getElementById('quickLook'); return p ? [...p.querySelectorAll('a[href], button:not([disabled])')] : [] }
3503
+ function paintQuickLook() {
3504
+ const pop = document.getElementById('quickLook')
3505
+ if (!pop || !qlUi.open) return
3506
+ const had = pop.contains(document.activeElement) ? document.activeElement : null
3507
+ 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') })
3508
+ const model = qlUi.model || { total: Number((state.health || {}).needsYou) || 0, items: [], more: 0 }
3509
+ pop.innerHTML = quickLookHtml(model, { mayAnswer: state.mayAnswer, pending: state.pendingAsk })
3510
+ pop.setAttribute('aria-busy', qlUi.model ? 'false' : 'true')
3511
+ placeQuickLook()
3512
+ if (keep) {
3513
+ let el = null
3514
+ 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])') }
3515
+ else if (keep.foot) el = pop.querySelector('.ql-foot')
3516
+ ;(el || quickLookFocusables()[0] || pop).focus()
3517
+ }
3518
+ }
3519
+ function placeQuickLook() {
3520
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
3521
+ if (!pop || pop.hidden || !tab) return
3522
+ if (!railMode()) { pop.style.left = ''; pop.style.top = ''; return } // the CSS puts it above the bottom bar
3523
+ const r = tab.getBoundingClientRect(), rail = document.querySelector('.top').getBoundingClientRect()
3524
+ pop.style.left = (Math.max(r.right, rail.right) + 8) + 'px'
3525
+ pop.style.top = Math.max(8, Math.min(r.top - 6, window.innerHeight - 8 - pop.offsetHeight)) + 'px'
3526
+ }
3527
+ // The sessions behind the popover: what the page already has, then a fresh read.
3528
+ async function loadQuickLook() {
3529
+ const seq = ++qlUi.seq
3530
+ if (state.sessions && state.sessions.length && !qlUi.model) { qlUi.model = quickLookItems(state.sessions); paintQuickLook() }
3531
+ try {
3532
+ const { sessions, viewer } = await api('GET', '/v1/sessions?limit=200', undefined, { timeoutMs: 8000 })
3533
+ if (seq !== qlUi.seq) return
3534
+ if (viewer) state.mayAnswer = viewer.mayAnswer !== false
3535
+ qlUi.model = quickLookItems(sessions || [])
3536
+ } catch { if (!qlUi.model) qlUi.model = { total: 0, items: [], more: 0, failed: true } }
3537
+ paintQuickLook()
3538
+ }
3539
+ function openQuickLook(how = 'hover') {
3540
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
3541
+ if (!pop || !tab) return
3542
+ clearTimeout(qlUi.openT); clearTimeout(qlUi.closeT)
3543
+ if (qlUi.open) { if (how !== 'hover') { qlUi.how = how; const f = quickLookFocusables()[0]; if (f) f.focus() } return }
3544
+ closeMore(); toggleStatus(false)
3545
+ qlUi.open = true; qlUi.how = how; qlUi.model = null
3546
+ pop.hidden = false
3547
+ tab.setAttribute('aria-expanded', 'true')
3548
+ // Hover never takes focus; the keyboard and a long-press move into the card (and Tab stays inside it).
3549
+ pop.setAttribute('aria-modal', how === 'hover' ? 'false' : 'true')
3550
+ paintQuickLook()
3551
+ if (how !== 'hover') { const f = quickLookFocusables()[0]; (f || pop).focus() }
3552
+ loadQuickLook()
3553
+ }
3554
+ function closeQuickLook(focusTab = false) {
3555
+ clearTimeout(qlUi.openT); clearTimeout(qlUi.closeT)
3556
+ if (!qlUi.open) return
3557
+ const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
3558
+ const inside = pop && pop.contains(document.activeElement)
3559
+ qlUi.open = false; qlUi.seq++
3560
+ if (pop) { pop.hidden = true; pop.innerHTML = '' }
3561
+ if (tab) { tab.setAttribute('aria-expanded', 'false'); if (focusTab || inside) tab.focus() }
3562
+ }
3563
+ {
3564
+ const tab = document.getElementById('needsTab'), pop = document.getElementById('quickLook')
3565
+ const canHover = () => window.matchMedia('(hover: hover) and (pointer: fine)').matches
3566
+ const later = (fn, ms) => { clearTimeout(qlUi.closeT); clearTimeout(qlUi.openT); return setTimeout(fn, ms) }
3567
+ tab.addEventListener('mouseenter', () => { if (!canHover()) return; clearTimeout(qlUi.closeT); if (!qlUi.open) qlUi.openT = later(() => openQuickLook('hover'), 280) })
3568
+ tab.addEventListener('mouseleave', () => { clearTimeout(qlUi.openT); if (qlUi.open && qlUi.how === 'hover') qlUi.closeT = setTimeout(() => closeQuickLook(), 320) })
3569
+ pop.addEventListener('mouseenter', () => clearTimeout(qlUi.closeT))
3570
+ pop.addEventListener('mouseleave', () => { if (qlUi.open && qlUi.how === 'hover' && !pop.contains(document.activeElement)) qlUi.closeT = setTimeout(() => closeQuickLook(), 320) })
3571
+ // Long-press on a touch screen; the tap that ends it doesn't navigate.
3572
+ let lp = null
3573
+ tab.addEventListener('pointerdown', (e) => {
3574
+ if (e.pointerType === 'mouse') return
3575
+ const x = e.clientX, y = e.clientY
3576
+ clearTimeout(lp && lp.t)
3577
+ lp = { x, y, t: setTimeout(() => { qlUi.suppressClick = true; openQuickLook('touch'); if (navigator.vibrate) try { navigator.vibrate(10) } catch {} }, 450) }
3578
+ })
3579
+ const cancel = () => { if (lp) { clearTimeout(lp.t); lp = null } }
3580
+ tab.addEventListener('pointerup', cancel)
3581
+ tab.addEventListener('pointercancel', cancel)
3582
+ tab.addEventListener('pointermove', (e) => { if (lp && Math.hypot(e.clientX - lp.x, e.clientY - lp.y) > 10) cancel() })
3583
+ tab.addEventListener('contextmenu', (e) => { if (qlUi.open || lp) e.preventDefault() })
3584
+ tab.addEventListener('click', (e) => {
3585
+ if (qlUi.suppressClick) { qlUi.suppressClick = false; e.preventDefault(); return }
3586
+ // A second tap while it's open goes to the page, as the footer does.
3587
+ closeQuickLook()
3588
+ })
3589
+ tab.addEventListener('keydown', (e) => {
3590
+ if (['ArrowDown', 'ArrowRight', 'ArrowUp'].includes(e.key) && !e.altKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); openQuickLook('keyboard') }
3591
+ })
3592
+ pop.addEventListener('keydown', (e) => {
3593
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeQuickLook(true); return }
3594
+ if (e.key !== 'Tab') return
3595
+ const f = quickLookFocusables()
3596
+ if (!f.length) { e.preventDefault(); return }
3597
+ const i = f.indexOf(document.activeElement)
3598
+ if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus() }
3599
+ else if (!e.shiftKey && (i === -1 || i === f.length - 1)) { e.preventDefault(); f[0].focus() }
3600
+ })
3601
+ pop.addEventListener('focusin', () => { clearTimeout(qlUi.closeT); pop.setAttribute('aria-modal', 'true') })
3602
+ pop.addEventListener('click', (e) => { const a = e.target.closest('a[href]'); if (a) closeQuickLook() })
3603
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && qlUi.open) closeQuickLook(true) })
3604
+ document.addEventListener('pointerdown', (e) => {
3605
+ if (qlUi.open && !e.target.closest('#quickLook, #needsTab')) closeQuickLook()
3606
+ if (moreOpen() && !e.target.closest('#moreMenu, #moreBtn')) closeMore()
3607
+ }, true)
3608
+ window.addEventListener('hashchange', () => { closeQuickLook(); closeMore() })
3609
+ window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
3610
+ }
3611
+
3612
+ // Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
3613
+ // past the line; the page reads its data again, the same as a live update (no
3614
+ // full reload unless that fails). Never from a field, the Live box or a menu.
3615
+ {
3616
+ const ptr = document.createElement('div')
3617
+ ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
3618
+ 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>'
3619
+ document.body.appendChild(ptr)
3620
+ const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 819.98px)')
3621
+ const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
3622
+ let pull = null, refreshing = false
3623
+ const paint = (step) => {
3624
+ ptr.hidden = !step || (!step.pulling && !refreshing)
3625
+ if (ptr.hidden) return
3626
+ ptr.style.transform = 'translate(-50%, ' + (refreshing ? 56 : step.dist) + 'px)'
3627
+ ptr.style.setProperty('--ptr-turn', String(Math.round((step.progress || 0) * 270)) + 'deg')
3628
+ ptr.style.opacity = refreshing ? '1' : String(Math.min(1, 0.25 + (step.progress || 0)))
3629
+ ptr.classList.toggle('armed', !!step.armed)
3630
+ ptr.classList.toggle('busy', refreshing)
3631
+ }
3632
+ async function pullRefresh() {
3633
+ refreshing = true; paint({ pulling: true, dist: 56, progress: 1, armed: true })
3634
+ const started = Date.now()
3635
+ try {
3636
+ prsUi.at = {}; projectsUi.at = 0
3637
+ const r = state.route || parseRoute()
3638
+ const editing = busy() || ['settings', 'setup'].includes(r.name)
3639
+ await Promise.all([editing ? null : render(), refreshHealth()])
3640
+ } catch { location.reload(); return }
3641
+ await new Promise((res) => setTimeout(res, Math.max(0, 450 - (Date.now() - started)))) // long enough to see it turn
3642
+ refreshing = false; ptr.classList.remove('busy', 'armed'); ptr.hidden = true
3643
+ }
3644
+ window.leftoffPullRefresh = pullRefresh
3645
+ document.addEventListener('touchstart', (e) => {
3646
+ if (refreshing || e.touches.length !== 1) { pull = null; return }
3647
+ const t = e.touches[0]
3648
+ 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
3649
+ }, { passive: true })
3650
+ document.addEventListener('touchmove', (e) => {
3651
+ if (!pull || e.touches.length !== 1) return
3652
+ const t = e.touches[0]
3653
+ const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
3654
+ if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
3655
+ if ((window.scrollY || 0) > 0) { pull = null; paint(null); return }
3656
+ if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
3657
+ pull.step = step
3658
+ paint(step)
3659
+ }, { passive: false })
3660
+ const end = () => {
3661
+ if (!pull) return
3662
+ const what = pullRelease(pull.step)
3663
+ pull = null
3664
+ if (what === 'refresh') pullRefresh()
3665
+ else paint(null)
3666
+ }
3667
+ document.addEventListener('touchend', end, { passive: true })
3668
+ document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
3359
3669
  }
3360
3670
 
3361
3671
  // Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
@@ -3412,6 +3722,7 @@ function placeStatus() {
3412
3722
  const pop = document.getElementById('statusPop'), b = document.getElementById('health')
3413
3723
  if (!pop || pop.hidden || !b) return
3414
3724
  const r = b.getBoundingClientRect(), vw = document.documentElement.clientWidth
3725
+ 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
3726
  if (vw < 600) { pop.style.left = '8px'; pop.style.right = '8px'; pop.style.top = (r.bottom + 8) + 'px'; return }
3416
3727
  pop.style.right = 'auto'
3417
3728
  pop.style.top = (r.bottom + 8) + 'px'
@@ -3670,6 +3981,8 @@ document.addEventListener('input', (ev) => {
3670
3981
  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
3982
 
3672
3983
  window.addEventListener('hashchange', () => closeActionMenu())
3984
+ const tipsUi = wireTips(document) // 0.9.8: the ⓘ tooltips (Always allow here)
3985
+ window.addEventListener('hashchange', () => tipsUi.hide())
3673
3986
  // 0.8.9: a session page's title is a button too (Enter or Space renames it)
3674
3987
  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
3988
  // 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
@@ -4003,6 +4316,8 @@ document.addEventListener('click', async (ev) => {
4003
4316
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
4004
4317
  if (action === 'reply-send') return replyClick(btn)
4005
4318
  if (action === 'live-send') return liveSendClick(btn)
4319
+ if (action === 'live-jump') return liveJumpClick(btn)
4320
+ if (action === 'live-earlier') return liveEarlierClick(btn)
4006
4321
  if (action === 'quick-reply') return quickReplyClick(btn)
4007
4322
  if (action === 'need-more') return needMoreClick(btn)
4008
4323
  if (action === 'need-dismiss') return needDismissClick(btn)
@@ -4130,7 +4445,7 @@ document.addEventListener('click', async (ev) => {
4130
4445
  // Answer a permission prompt or a question from the board. The daemon hands it
4131
4446
  // to the waiting hook or mod; the terminal's own prompt may already have won.
4132
4447
  async function answerClick(btn, action) {
4133
- const need = btn.closest('.need')
4448
+ const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
4134
4449
  if (!need) return
4135
4450
  const askId = need.dataset.ask, key = need.dataset.key
4136
4451
  const s = state.sessions.find((x) => x.id === need.dataset.id)
@@ -4164,6 +4479,7 @@ async function answerClick(btn, action) {
4164
4479
  toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
4165
4480
  } finally {
4166
4481
  delete state.pendingAsk[key]
4482
+ if (need.classList.contains('ql-item')) { await loadQuickLook(); refreshHealth() }
4167
4483
  render()
4168
4484
  }
4169
4485
  }
@@ -4479,7 +4795,7 @@ document.addEventListener('submit', async (ev) => {
4479
4795
 
4480
4796
  // Live updates over SSE; re-render the current view, at most every 800 ms.
4481
4797
  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)
4798
+ 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
4799
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
4484
4800
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
4485
4801
  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) }
package/ui/index.html CHANGED
@@ -21,25 +21,33 @@
21
21
  </head>
22
22
  <body>
23
23
  <header class="top">
24
- <a class="brand" href="#/" id="brand" title="Leftoff">
24
+ <a class="brand" href="#/" id="brand" title="Leftoff" aria-label="Leftoff">
25
25
  <img src="favicon.svg" class="logo" alt="" width="22" height="22">
26
26
  <span>Leftoff</span>
27
27
  </a>
28
28
  <nav class="tabs" aria-label="Sections">
29
29
  <a href="#/" data-route="board"><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"><rect width="7" height="9" x="3" y="3" rx="1"/> <rect width="7" height="5" x="14" y="3" rx="1"/> <rect width="7" height="9" x="14" y="12" rx="1"/> <rect width="7" height="5" x="3" y="16" rx="1"/></svg><span class="lbl">Board</span></a>
30
- <a href="#/waiting" data-route="waiting" id="needsTab"><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="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
30
+ <a href="#/waiting" data-route="waiting" id="needsTab" aria-haspopup="dialog" aria-expanded="false" aria-controls="quickLook"><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="M10.268 21a2 2 0 0 0 3.464 0"/> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg><span class="lbl">Needs you</span><span class="lbl-short">Needs</span><span class="navcount" id="needsCount" hidden></span></a>
31
31
  <a href="#/lists" data-route="lists" id="listsTab"><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="M13 5h8"/> <path d="M13 12h8"/> <path d="M13 19h8"/> <path d="m3 17 2 2 4-4"/> <path d="m3 7 2 2 4-4"/></svg><span class="lbl">Lists</span><span class="navcount" id="listsCount" hidden></span></a>
32
32
  <a href="#/prs" data-route="prs" id="prsTab"><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"><circle cx="18" cy="18" r="3"/> <circle cx="6" cy="6" r="3"/> <path d="M13 6h3a2 2 0 0 1 2 2v7"/> <line x1="6" x2="6" y1="9" y2="21"/></svg><span class="lbl">PRs</span></a>
33
- <a href="#/timeline" data-route="timeline"><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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
34
- <a href="#/digest" data-route="digest"><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="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span class="lbl">Digest</span></a>
33
+ <a href="#/timeline" data-route="timeline" class="tab-extra"><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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span class="lbl">Timeline</span></a>
34
+ <a href="#/digest" data-route="digest" class="tab-extra"><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="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span class="lbl">Digest</span></a>
35
+ <button type="button" class="tab-more" id="moreBtn" aria-haspopup="menu" aria-expanded="false" aria-controls="moreMenu"><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"><circle cx="12" cy="12" r="1"/> <circle cx="19" cy="12" r="1"/> <circle cx="5" cy="12" r="1"/></svg><span class="lbl">More</span><span class="moredot" hidden></span></button>
35
36
  </nav>
36
37
  <div class="topright">
37
38
  <button type="button" class="searchbtn" id="searchBtn" title="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><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"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
38
39
  <button type="button" class="health" id="health" title="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
39
- <a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><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="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span class="newdot" hidden></span></a>
40
- <a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><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="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span class="topdot" hidden></span></a>
40
+ <a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><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="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span class="lbl">What’s new</span><span class="newdot" hidden></span></a>
41
+ <a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><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="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span class="lbl">Settings</span><span class="topdot" hidden></span></a>
41
42
  </div>
42
43
  </header>
44
+ <div class="moremenu" id="moreMenu" role="menu" aria-label="More" hidden>
45
+ <a href="#/timeline" data-route="timeline" role="menuitem"><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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/> <path d="M3 3v5h5"/> <path d="M12 7v5l4 2"/></svg><span>Timeline</span></a>
46
+ <a href="#/digest" data-route="digest" role="menuitem"><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="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span>Digest</span></a>
47
+ <a href="#/whats-new" data-route="whats-new" role="menuitem"><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="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span>What’s new</span><span class="newdot" hidden></span></a>
48
+ <a href="#/settings" data-route="settings" role="menuitem"><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="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span>Settings</span><span class="topdot" hidden></span></a>
49
+ </div>
50
+ <div class="quicklook" id="quickLook" role="dialog" aria-labelledby="qlTitle" hidden></div>
43
51
  <div class="statuspop" id="statusPop" role="dialog" aria-label="Status" hidden></div>
44
52
  <div class="cmdk" id="cmdk" hidden>
45
53
  <div class="cmdk-panel" role="dialog" aria-modal="true" aria-label="Search">