@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/CHANGELOG.md +15 -0
- package/bin/leftoff.js +370 -358
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +226 -13
- package/ui/app.js +336 -20
- package/ui/index.html +14 -6
- package/ui/live-status.js +90 -0
- package/ui/live-view.js +278 -0
- package/ui/nav.js +88 -0
- package/ui/needs.js +32 -1
- package/ui/pull.js +31 -0
- package/ui/sw.js +14 -1
- package/ui/tips.js +117 -0
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
|
|
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
|
|
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>' +
|
|
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
|
|
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 =
|
|
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
|
|
1275
|
-
: '<h1>' + esc(s
|
|
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>' : '') + ' ' + 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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 & 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
|
-
|
|
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">
|