@leftoff/cli 0.9.8-beta.1 → 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,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, 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: {} }
@@ -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() {
@@ -1346,13 +1363,69 @@ async function liveSection(s) {
1346
1363
  const msgs = (v && v.messages) || []
1347
1364
  const may = state.mayAnswer !== false
1348
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)
1349
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>' +
1350
- (msgs.length ? '<ol class="live-log" aria-live="polite">' + msgs.map(liveEntry).join('') + '</ol>' : '<p class="faint live-empty">Nothing sent yet.</p>') +
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>' +
1351
1374
  (v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
1352
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>' +
1353
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>' +
1354
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>'
1355
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
+ }
1356
1429
  async function liveSendClick(btn) {
1357
1430
  const box = btn.closest('.live-box')
1358
1431
  const ta = box && box.querySelector('.live-text')
@@ -1365,8 +1438,10 @@ async function liveSendClick(btn) {
1365
1438
  if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
1366
1439
  if (ta) ta.value = ''
1367
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
1368
1443
  setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
1369
- } catch (e) { toast(e.message, 4200) } finally { render() }
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' } }
1370
1445
  }
1371
1446
 
1372
1447
  // ------------------------------------------------------------ search inside transcripts (0.9.6)
@@ -3239,7 +3314,7 @@ function parseRoute() {
3239
3314
 
3240
3315
  async function render() {
3241
3316
  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') })
3317
+ paintNav(r)
3243
3318
  try {
3244
3319
  if (r.name === 's' && r.arg) await viewSession(r.arg)
3245
3320
  else if (r.name === 'waiting') await viewWaiting()
@@ -3326,6 +3401,7 @@ async function refreshHealth() {
3326
3401
  if (h.answering) state.mayAnswer = h.answering.allowedHere !== false
3327
3402
  const dot = document.querySelector('#wnTab .newdot')
3328
3403
  if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
3404
+ paintMoreDots()
3329
3405
  paintSettingsDots()
3330
3406
  } catch (e) {
3331
3407
  el.className = 'health down'
@@ -3356,6 +3432,229 @@ function paintTopDot() {
3356
3432
  el.className = 'topdot' + (warn ? ' warn' : '')
3357
3433
  const why = all.flatMap((x) => x.why)
3358
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 })
3359
3658
  }
3360
3659
 
3361
3660
  // Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
@@ -3412,6 +3711,7 @@ function placeStatus() {
3412
3711
  const pop = document.getElementById('statusPop'), b = document.getElementById('health')
3413
3712
  if (!pop || pop.hidden || !b) return
3414
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
3415
3715
  if (vw < 600) { pop.style.left = '8px'; pop.style.right = '8px'; pop.style.top = (r.bottom + 8) + 'px'; return }
3416
3716
  pop.style.right = 'auto'
3417
3717
  pop.style.top = (r.bottom + 8) + 'px'
@@ -3670,6 +3970,8 @@ document.addEventListener('input', (ev) => {
3670
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 })) } })
3671
3971
 
3672
3972
  window.addEventListener('hashchange', () => closeActionMenu())
3973
+ const tipsUi = wireTips(document) // 0.9.8: the ⓘ tooltips (Always allow here)
3974
+ window.addEventListener('hashchange', () => tipsUi.hide())
3673
3975
  // 0.8.9: a session page's title is a button too (Enter or Space renames it)
3674
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() } })
3675
3977
  // 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
@@ -4003,6 +4305,7 @@ document.addEventListener('click', async (ev) => {
4003
4305
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
4004
4306
  if (action === 'reply-send') return replyClick(btn)
4005
4307
  if (action === 'live-send') return liveSendClick(btn)
4308
+ if (action === 'live-jump') return liveJumpClick(btn)
4006
4309
  if (action === 'quick-reply') return quickReplyClick(btn)
4007
4310
  if (action === 'need-more') return needMoreClick(btn)
4008
4311
  if (action === 'need-dismiss') return needDismissClick(btn)
@@ -4130,7 +4433,7 @@ document.addEventListener('click', async (ev) => {
4130
4433
  // Answer a permission prompt or a question from the board. The daemon hands it
4131
4434
  // to the waiting hook or mod; the terminal's own prompt may already have won.
4132
4435
  async function answerClick(btn, action) {
4133
- 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
4134
4437
  if (!need) return
4135
4438
  const askId = need.dataset.ask, key = need.dataset.key
4136
4439
  const s = state.sessions.find((x) => x.id === need.dataset.id)
@@ -4164,6 +4467,7 @@ async function answerClick(btn, action) {
4164
4467
  toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
4165
4468
  } finally {
4166
4469
  delete state.pendingAsk[key]
4470
+ if (need.classList.contains('ql-item')) { await loadQuickLook(); refreshHealth() }
4167
4471
  render()
4168
4472
  }
4169
4473
  }
@@ -4479,7 +4783,7 @@ document.addEventListener('submit', async (ev) => {
4479
4783
 
4480
4784
  // Live updates over SSE; re-render the current view, at most every 800 ms.
4481
4785
  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)
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)
4483
4787
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
4484
4788
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
4485
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) }
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">