@leftoff/cli 0.9.7 → 0.9.8-beta.10
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 +39 -0
- package/bin/leftoff.js +414 -379
- package/docs/SECURITY.md +10 -0
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +14 -4
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +425 -1
- package/ui/app.js +952 -40
- package/ui/index.html +18 -6
- package/ui/live-status.js +97 -0
- package/ui/live-view.js +374 -0
- package/ui/live-wake.js +96 -0
- package/ui/nav.js +88 -0
- package/ui/navhide.js +35 -0
- package/ui/needs.js +33 -2
- package/ui/pull.js +31 -0
- package/ui/push.js +140 -0
- package/ui/relay-client.js +70 -5
- package/ui/restore-gate.js +26 -2
- package/ui/sw.js +66 -6
- package/ui/tabs.js +216 -0
- package/ui/tips.js +117 -0
package/ui/app.js
CHANGED
|
@@ -9,15 +9,26 @@ import { changedKeys } from './settings-dirty.js'
|
|
|
9
9
|
import { looksMd, stripMd, plainCp } from './cp-text.js'
|
|
10
10
|
import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
|
|
11
11
|
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel } from './repos.js'
|
|
12
|
+
import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
|
|
13
|
+
import { canStartPull, pullStep, pullRelease } from './pull.js'
|
|
14
|
+
import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
15
|
+
import { wireTips } from './tips.js'
|
|
12
16
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
13
17
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
14
18
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
15
19
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
16
20
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
17
|
-
import { restoreAccess, hintHtml, restoreWhen } from './restore-gate.js'
|
|
21
|
+
import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
|
|
18
22
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
23
|
+
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking } from './live-view.js' // 0.9.8 Live link polish
|
|
24
|
+
import { statusView, elapsedText } from './live-status.js'
|
|
25
|
+
import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
|
|
26
|
+
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
27
|
+
import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, moveTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir } from './tabs.js' // 0.9.8 tabs for sessions
|
|
19
28
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
20
29
|
const $app = document.getElementById('app')
|
|
30
|
+
// 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
|
|
31
|
+
if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
|
|
21
32
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
22
33
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
23
34
|
const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
|
|
@@ -108,10 +119,16 @@ async function copy(text) {
|
|
|
108
119
|
// 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
|
|
109
120
|
function copiedMark(btn) {
|
|
110
121
|
clearTimeout(btn._copied)
|
|
122
|
+
const tip = btn._tip || (btn._tip = btn.title || 'Copy resume command')
|
|
111
123
|
btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.title = 'Copied'
|
|
112
|
-
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title =
|
|
124
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
|
|
113
125
|
}
|
|
114
126
|
const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
|
|
127
|
+
// 0.9.8 Live link: the session's channel is listening (send it prompts from here)
|
|
128
|
+
// It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
|
|
129
|
+
const liveBadge = (s) => liveBadgeHtml(s)
|
|
130
|
+
// The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
|
|
131
|
+
const statusPill = (s) => { const st = cardPills(s).status; return st ? pill(st) : '' }
|
|
115
132
|
|
|
116
133
|
const fmtCode = (x) => esc(x).replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
117
134
|
|
|
@@ -175,11 +192,11 @@ function wtChips(s) {
|
|
|
175
192
|
return h
|
|
176
193
|
}
|
|
177
194
|
|
|
178
|
-
function where(s) {
|
|
195
|
+
function where(s, { repo = true } = {}) {
|
|
179
196
|
if (s.agent) return (s.project ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(s.repoLabel) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc(s.repoLabel + ' · show only this project') + '">' + esc(s.repoLabel) + '</button>' : '') + agentChip(s.agent)
|
|
180
197
|
const branch = s.repo && s.repo.branch
|
|
181
198
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
182
|
-
return projChip(s) + '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
|
|
199
|
+
return projChip(s) + (repo ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' : '') + wtChips(s) +
|
|
183
200
|
(multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<button type="button" class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '" data-action="machine-emoji" data-machine="' + esc(s.machine) + '"><span class="memo">' + esc(mEmoji(s.machine)) + '</span> ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</button>' })() : '') + modelChip(s)
|
|
184
201
|
}
|
|
185
202
|
|
|
@@ -378,9 +395,13 @@ function liveNote(s) {
|
|
|
378
395
|
if (s.live === 'open' && s.liveVia === 'heartbeat' && !s.isLocal) return 'open (heartbeat)'
|
|
379
396
|
return s.live === 'open' ? 'open' : ''
|
|
380
397
|
}
|
|
381
|
-
|
|
398
|
+
// 0.9.8: { page: true } is the session page's header, where a Go that can't run
|
|
399
|
+
// is left out (its tooltip was the only place the reason showed, and the grey
|
|
400
|
+
// box read as broken); a card keeps the dimmed Go and its short why.
|
|
401
|
+
function goButton(s, { page = false } = {}) {
|
|
382
402
|
if (s.live === 'closed') return ''
|
|
383
403
|
const j = s.jump || {}
|
|
404
|
+
if (page && (state.viewerLocal === false || !j.supported)) return ''
|
|
384
405
|
// Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
|
|
385
406
|
if (!s.isLocal) {
|
|
386
407
|
if (!j.peer) return ''
|
|
@@ -430,8 +451,12 @@ function agentCard(s) {
|
|
|
430
451
|
// closed), a small copy icon for the resume command, and "⋯" for the rest. The
|
|
431
452
|
// ⋯ menu's items ride in the card as an inert <template> (real buttons with the
|
|
432
453
|
// same data-action as before), so the one click handler below runs them unchanged.
|
|
454
|
+
// What Resume runs: codex resume, claude --resume, or for a Live session (0.9.8)
|
|
455
|
+
// leftoff live -- --resume, so it comes back Live.
|
|
456
|
+
function resumeVerb(s) { return s.codingAgent === 'codex' ? 'codex resume' : s.resumeLive ? 'leftoff live -- --resume' : 'claude --resume' }
|
|
457
|
+
function resumeTip(s, lead) { return lead + ': ' + resumeVerb(s) + (s.resumeLive ? ' (it comes back Live)' : '') }
|
|
433
458
|
function copyResumeIcon(s) {
|
|
434
|
-
return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
|
|
459
|
+
return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + '</button>'
|
|
435
460
|
}
|
|
436
461
|
// items: [{ action, label, icon, title, attrs }]
|
|
437
462
|
// 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
|
|
@@ -548,7 +573,7 @@ function checkpointSplit(s) {
|
|
|
548
573
|
'<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
|
|
549
574
|
}
|
|
550
575
|
return '<span class="split" role="group" aria-label="Checkpoint now">' +
|
|
551
|
-
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '"
|
|
576
|
+
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '" aria-label="Checkpoint now">' + icon('bookmark-plus') + 'Checkpoint</button>' +
|
|
552
577
|
'<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
|
|
553
578
|
}
|
|
554
579
|
function openLengthMenu(btn, id) {
|
|
@@ -572,15 +597,15 @@ function card(s) {
|
|
|
572
597
|
const nb = s.needsYou ? cardBadge(s.needsYou) : ''
|
|
573
598
|
return '<article class="card ' + esc(s.status) + (s.live === 'closed' ? ' is-closed' : '') + (s.needsYou && s.needsYou.blocking ? ' needs-you' : '') + '" data-id="' + esc(s.id) + '" data-href="#/s/' + esc(encodeURIComponent(s.id)) + '">' +
|
|
574
599
|
'<div class="row1"><div class="title">' + liveDot(s) +
|
|
575
|
-
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' +
|
|
600
|
+
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
|
|
576
601
|
'<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
|
|
577
|
-
'</div>' +
|
|
578
|
-
'<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' ? ' · no prompt yet' : '') + '</span>' +
|
|
602
|
+
'</div>' + statusPill(s) + liveBadge(s) + '</div>' +
|
|
603
|
+
'<div class="meta">' + spaceChip(s) + where(s, { repo: !titleIsWhere(s) }) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' && !cardPills(s).live ? ' · no prompt yet' : '') + '</span>' +
|
|
579
604
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
580
605
|
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
|
|
581
606
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
582
607
|
'<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
|
|
583
|
-
copyResumeIcon(s) +
|
|
608
|
+
chatButton(s) + copyResumeIcon(s) +
|
|
584
609
|
(s.isLocal ? '' : remoteNote(s)) + moreMenu(s, sessionMenuItems(s)) +
|
|
585
610
|
'</div></article>'
|
|
586
611
|
}
|
|
@@ -751,7 +776,7 @@ async function viewBoard() {
|
|
|
751
776
|
// not a resumed conversation) fold into one "N empty sessions" row. One
|
|
752
777
|
// appears on the board as itself after its first prompt. Pinned ones, and
|
|
753
778
|
// ones that need you, always show.
|
|
754
|
-
const isFolded = (s) => s
|
|
779
|
+
const isFolded = (s) => foldsAsEmpty(s) // 0.9.8: never a live session
|
|
755
780
|
const shown = recent.filter((s) => !isFolded(s))
|
|
756
781
|
const empties = recent.filter(isFolded)
|
|
757
782
|
const rank = Object.fromEntries(ORDER.map(([k], i) => [k, i]))
|
|
@@ -787,7 +812,7 @@ async function viewBoard() {
|
|
|
787
812
|
'<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
|
|
788
813
|
'<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
|
|
789
814
|
html += syncDetails() + staleLine(sessions)
|
|
790
|
-
html += restoreBanner()
|
|
815
|
+
html += restoreAsksBanner() + restoreBanner()
|
|
791
816
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
792
817
|
const needs = needsItems(sessions)
|
|
793
818
|
html += needsStrip(needs, needOpts())
|
|
@@ -799,6 +824,7 @@ async function viewBoard() {
|
|
|
799
824
|
html += projectRow(spaced, pj, 'board-project')
|
|
800
825
|
if (filter === 'closed') {
|
|
801
826
|
html += await closedSection(f, sp)
|
|
827
|
+
if (parseRoute().name === 's') return // 0.9.8: you opened a session while the board loaded
|
|
802
828
|
$app.innerHTML = banners() + html
|
|
803
829
|
return closedWire()
|
|
804
830
|
}
|
|
@@ -840,6 +866,7 @@ function boardSearch(q, closedHits, n) {
|
|
|
840
866
|
// A repaint from a live update (or the 30 s refresh) while you type in the
|
|
841
867
|
// search box keeps the box focused, with what you typed and the caret.
|
|
842
868
|
function paintBoardKeepingSearch(h) {
|
|
869
|
+
if (parseRoute().name === 's') return // 0.9.8: a slow board answer never paints over the session you opened
|
|
843
870
|
const old = document.getElementById('boardQ')
|
|
844
871
|
const keep = old && document.activeElement === old ? { v: old.value, pos: old.selectionStart } : null
|
|
845
872
|
$app.innerHTML = h
|
|
@@ -919,10 +946,10 @@ function resumeButton(s, viewer = {}) {
|
|
|
919
946
|
const busy = closedUi.busy.has(s.id)
|
|
920
947
|
if (s.isLocal) {
|
|
921
948
|
if (!viewer.ghostty) return ''
|
|
922
|
-
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="
|
|
949
|
+
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc(resumeTip(s, 'Open it in a new Ghostty tab')) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume') + '</button>'
|
|
923
950
|
}
|
|
924
951
|
if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
|
|
925
|
-
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc('Open it in a new Ghostty tab on ' + s.machine) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume on ' + esc(s.machine)) + '</button>'
|
|
952
|
+
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="' + esc(resumeTip(s, 'Open it in a new Ghostty tab on ' + s.machine)) + '">' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Resume on ' + esc(s.machine)) + '</button>'
|
|
926
953
|
}
|
|
927
954
|
function closedCard(s, viewer) {
|
|
928
955
|
const href = '#/s/' + encodeURIComponent(s.id)
|
|
@@ -1039,14 +1066,49 @@ function emptyFold(list) {
|
|
|
1039
1066
|
// (0.9.7: or when the terminal quit),
|
|
1040
1067
|
// with a checkbox each; Restore opens each in a new Ghostty tab, resumed by id.
|
|
1041
1068
|
// Opening tabs runs on the Mac, so (like Go) only its own browser may press it.
|
|
1042
|
-
const restoreUi = { open: false, off: new Set(), result: null }
|
|
1069
|
+
const restoreUi = { open: false, off: new Set(), result: null, ask: null, busy: new Set() }
|
|
1043
1070
|
function restoreDismissKey(r) { return 'lo.restore.dismissed:' + (r.restartAt || '') }
|
|
1044
1071
|
function restorePicked() { const r = state.restore; return r ? r.sessions.filter((s) => !restoreUi.off.has(s.id)) : [] }
|
|
1045
1072
|
// 0.9.7: this Mac's own board, or the hosted board open on it (the direct path)
|
|
1046
1073
|
function restoreGate() {
|
|
1047
1074
|
const lr = window.leftoffRelay, r = state.restore || {}
|
|
1048
1075
|
return restoreAccess({ local: state.viewerLocal !== false, hosted: !!lr, route: lr ? lr.route : null, routeMac: lr ? lr.mac : null, machine: r.machine || null,
|
|
1049
|
-
phone: isPhoneUa(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints || 0 }) })
|
|
1076
|
+
phone: isPhoneUa(navigator.userAgent, { maxTouchPoints: navigator.maxTouchPoints || 0 }), canAsk: Array.isArray(r.asks) })
|
|
1077
|
+
}
|
|
1078
|
+
// 0.9.8: Restore asked from a phone or another Mac, waiting on THIS Mac: confirm
|
|
1079
|
+
// or decline here (this Mac's own board, or the hosted board open on it).
|
|
1080
|
+
function restoreAsksBanner() {
|
|
1081
|
+
const r = state.restore
|
|
1082
|
+
const asks = r && Array.isArray(r.asks) ? r.asks : []
|
|
1083
|
+
if (!asks.length) return ''
|
|
1084
|
+
const gate = restoreGate()
|
|
1085
|
+
const here = gate.how === 'local' || gate.how === 'direct'
|
|
1086
|
+
return asks.map((a) => {
|
|
1087
|
+
const what = a.count + ' session' + (a.count === 1 ? '' : 's') + (a.titles && a.titles.length ? ': ' + a.titles.join(', ') + (a.count > a.titles.length ? '…' : '') : '')
|
|
1088
|
+
const busy = restoreUi.busy.has(a.id)
|
|
1089
|
+
return '<div class="banner warn restore-ask" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to restore ' + esc(what) + '</b> on ' + esc(r.machine || 'this Mac') + '. ' +
|
|
1090
|
+
(here ? 'Open them here, each in a new Ghostty tab?' : 'Someone at ' + esc(r.machine || 'that Mac') + ' has to confirm it there.') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
|
|
1091
|
+
(here ? '<button type="button" class="btn primary" data-action="restore-ask-confirm" data-id="' + esc(a.id) + '"' + (busy ? ' aria-disabled="true"' : '') + '>' + icon('rotate-ccw') + (busy ? 'Opening…' : 'Open ' + a.count + ' tab' + (a.count === 1 ? '' : 's')) + '</button>' +
|
|
1092
|
+
'<button type="button" class="btn" data-action="restore-ask-decline" data-id="' + esc(a.id) + '"' + (busy ? ' aria-disabled="true"' : '') + '>Decline</button>' : '') + '</div>'
|
|
1093
|
+
}).join('')
|
|
1094
|
+
}
|
|
1095
|
+
function restoreAskNote() {
|
|
1096
|
+
const x = askLine(restoreUi.ask, { clock })
|
|
1097
|
+
return x ? '<span class="rs-note rs-ask ' + x.tone + '" id="restoreAskNote" role="status">' + hintHtml(x.text, esc) + '</span>' : ''
|
|
1098
|
+
}
|
|
1099
|
+
// The asker's side: poll the request until it's settled.
|
|
1100
|
+
async function pollRestoreAsk() {
|
|
1101
|
+
const a = restoreUi.ask
|
|
1102
|
+
if (!a || !a.id || !['pending', 'confirming'].includes(a.state)) return
|
|
1103
|
+
try {
|
|
1104
|
+
const r = await api('GET', '/v1/restore/asks/' + encodeURIComponent(a.id) + (a.machine ? '?machine=' + encodeURIComponent(a.machine) : ''))
|
|
1105
|
+
if (restoreUi.ask && restoreUi.ask.id === a.id) {
|
|
1106
|
+
const changed = r.state !== a.state
|
|
1107
|
+
restoreUi.ask = { ...a, ...r, machine: a.machine || r.machine }
|
|
1108
|
+
if (changed) { const el = document.getElementById('restoreBanner'); if (el) el.outerHTML = restoreBanner(); if (r.state === 'confirmed') toast('Restored on ' + (a.machine || 'that Mac')) }
|
|
1109
|
+
}
|
|
1110
|
+
} catch (e) { if (restoreUi.ask && restoreUi.ask.id === a.id && /gone|not_found/.test(String(e.message))) restoreUi.ask = { ...a, state: 'failed', error: e.message } }
|
|
1111
|
+
if (restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)) setTimeout(pollRestoreAsk, 2000)
|
|
1050
1112
|
}
|
|
1051
1113
|
function restoreBanner() {
|
|
1052
1114
|
const r = state.restore
|
|
@@ -1062,17 +1124,18 @@ function restoreBanner() {
|
|
|
1062
1124
|
}
|
|
1063
1125
|
const gate = restoreGate()
|
|
1064
1126
|
const picked = restorePicked().length
|
|
1127
|
+
const asking = restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)
|
|
1065
1128
|
const rows = r.sessions.map((s) => '<label class="rs-row"><input type="checkbox" name="rs" value="' + esc(s.id) + '"' + (restoreUi.off.has(s.id) ? '' : ' checked') + '>' +
|
|
1066
1129
|
'<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.modelLabel, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span>' + (s.blocked ? '<span class="rs-m err">' + esc(s.error) + '</span>' : '') + '</span></label>').join('')
|
|
1067
1130
|
return '<div class="banner info restore open" id="restoreBanner" role="region" aria-label="Restore sessions"><div class="rs-head"><span class="grow">' + head + '. Each opens in a new Ghostty tab, in its folder, with <code>claude --resume</code> and its name.</span>' +
|
|
1068
1131
|
'<button type="button" class="btn x" data-action="restore-close" aria-label="Close" title="Close">✕</button></div>' +
|
|
1069
1132
|
'<div class="rs-list">' + rows + '</div>' +
|
|
1070
1133
|
'<div class="rs-actions">' +
|
|
1071
|
-
'<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked ? '' : ' aria-disabled="true"') +
|
|
1072
|
-
(gate.ok ? '' : ' aria-describedby="restoreHint" title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
|
|
1134
|
+
'<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked && !asking ? '' : ' aria-disabled="true"') +
|
|
1135
|
+
(gate.ok && gate.how !== 'ask' ? '' : ' aria-describedby="restoreHint"') + (gate.ok ? '' : ' title="' + esc(gate.hint.replace(/`/g, '')) + '"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
|
|
1073
1136
|
'<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
|
|
1074
1137
|
'<button type="button" class="btn" data-action="restore-copy">Copy commands</button>' +
|
|
1075
|
-
(gate.ok ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
|
|
1138
|
+
(restoreUi.ask ? restoreAskNote() : gate.ok && gate.how !== 'ask' ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
|
|
1076
1139
|
'</div>' + (restoreUi.result ? restoreResult() : '') + '</div>'
|
|
1077
1140
|
}
|
|
1078
1141
|
// 0.9.7: the hosted board found (or lost) this Mac's direct path: Restore follows it
|
|
@@ -1130,9 +1193,11 @@ async function viewWaiting() {
|
|
|
1130
1193
|
// The nav's count, the page title and the favicon dot: visible from a background tab.
|
|
1131
1194
|
function paintNeedsCount(n) {
|
|
1132
1195
|
const c = document.getElementById('needsCount')
|
|
1133
|
-
if (c) { c.hidden = !n; c.textContent =
|
|
1196
|
+
if (c) { c.hidden = !n; c.textContent = badgeText(n) }
|
|
1134
1197
|
const tab = document.getElementById('needsTab')
|
|
1135
1198
|
if (tab) { tab.classList.toggle('has-needs', !!n); tab.title = n ? n + ' waiting on you' : 'Nothing waiting on you' }
|
|
1199
|
+
if (quickLookOpen() && paintNeedsCount.count !== n) loadQuickLook()
|
|
1200
|
+
paintNeedsCount.count = n
|
|
1136
1201
|
document.title = (n ? '(' + n + ') ' : '') + 'Leftoff'
|
|
1137
1202
|
// The tab icon gets a red dot while something waits (leftoff-favicon-badge,
|
|
1138
1203
|
// v0.4.5); the PNG is for browsers without SVG favicons.
|
|
@@ -1158,7 +1223,7 @@ function needOpts(extra = {}) {
|
|
|
1158
1223
|
document.addEventListener('keydown', (ev) => {
|
|
1159
1224
|
if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.altKey || ev.shiftKey) return
|
|
1160
1225
|
const box = ev.target && ev.target.closest && ev.target.closest('[data-submit]')
|
|
1161
|
-
const need = box && box.closest('.need')
|
|
1226
|
+
const need = box && (box.closest('.need') || box.closest('.live-box')) // 0.9.8: the Live box too
|
|
1162
1227
|
if (!need) return
|
|
1163
1228
|
ev.preventDefault()
|
|
1164
1229
|
if (ev.repeat || ev.isComposing) return
|
|
@@ -1201,8 +1266,8 @@ async function viewAgentSession(s) {
|
|
|
1201
1266
|
const name = esc(a.display || a.name)
|
|
1202
1267
|
const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
|
|
1203
1268
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1204
|
-
html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
|
|
1205
|
-
'<div class="actions">' + agentReplyButton(a) + '</div></div>'
|
|
1269
|
+
html += '<div class="pagehead s-head"><div class="s-head-main"><h1>' + esc(s.title || 'Untitled') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + where(s) + ' <span class="faint">' + esc(s.inFeed === false ? 'no longer in the feed' : AGENT_STATUS[s.feedStatus] || s.feedStatus) + ' · updated ' + esc(ago(s.lastActivityAt)) + '</span></div></div>' +
|
|
1270
|
+
'<div class="actions s-actions">' + agentReplyButton(a) + '</div></div>'
|
|
1206
1271
|
// v0.5.6: agent.url only links the name; the reply affordance and its
|
|
1207
1272
|
// words come from the feed (agent.reply, agent.replyHint), or nothing.
|
|
1208
1273
|
html += '<div class="banner info"><span>Read-only. This is ' + (home ? '<a href="' + esc(home) + '" target="_blank" rel="noopener noreferrer">' + name + '</a>' : name) + '’s work, from its feed' + (a.pullsHere ? ', pulled by this Mac' : a.puller ? ', pulled by ' + esc(a.puller) : '') + (a.lastSuccessAt ? ' (last pull ' + esc(ago(a.lastSuccessAt)) + ')' : '') + '.' + (hint ? ' ' + esc(hint.charAt(0).toUpperCase() + hint.slice(1)) + (/[.!?]$/.test(hint) ? '' : '.') : '') + '</span></div>'
|
|
@@ -1223,39 +1288,110 @@ async function viewAgentSession(s) {
|
|
|
1223
1288
|
$app.innerHTML = banners() + html
|
|
1224
1289
|
}
|
|
1225
1290
|
|
|
1291
|
+
// 0.9.8: another Mac's session whose Mac is offline says so plainly, once, near the top
|
|
1292
|
+
function ownerOffline(s) {
|
|
1293
|
+
if (!s || s.isLocal !== false || s.agent || !s.machine) return false
|
|
1294
|
+
const st = window.leftoffRelay && state.relayStatus
|
|
1295
|
+
const x = st && (st.macs || []).find((y) => y.machine === s.machine)
|
|
1296
|
+
if (x) return !x.online
|
|
1297
|
+
return !!(s.peer && s.peer.code === 'unreachable')
|
|
1298
|
+
}
|
|
1299
|
+
function ownerOfflineNote(s) {
|
|
1300
|
+
if (!ownerOffline(s)) return ''
|
|
1301
|
+
const m = esc(s.machine), sn = staleNote(s.machine)
|
|
1302
|
+
return '<p class="owner-off" role="status">' + icon('wifi-off') + '<span>This session lives on <b>' + m + '</b>, which is offline right now. You’re seeing what it last synced' + (sn ? ' (' + esc(sn) + ')' : '') + '. Live, its transcript and Find lists come back when ' + m + ' is.</span></p>'
|
|
1303
|
+
}
|
|
1304
|
+
// 0.9.8: opening a session is instant. The tap paints the page's shell at
|
|
1305
|
+
// once (title and pills from the card we already have, or the whole page from
|
|
1306
|
+
// the last visit), then every fetch runs in parallel (the session, its Live
|
|
1307
|
+
// thread, spaces, projects, lists) instead of one after another; on a phone
|
|
1308
|
+
// through the relay each was a full round trip. A newer navigation wins: a
|
|
1309
|
+
// slow answer for a page you already left never paints over the one you're on.
|
|
1310
|
+
const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few sessions you opened
|
|
1311
|
+
const SESSION_CACHE_MAX = 8
|
|
1312
|
+
function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
|
|
1313
|
+
const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
|
|
1314
|
+
function sessionShellHtml(s) {
|
|
1315
|
+
const head = s
|
|
1316
|
+
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + statusPill(s) + liveBadge(s) + '</div>'
|
|
1317
|
+
: '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
|
|
1318
|
+
return '<a class="back" href="#/">← All sessions</a><div class="pagehead s-head s-shell" data-sid="' + esc((s && s.id) || '') + '"><div class="s-head-main">' + head + '</div></div>' +
|
|
1319
|
+
'<div class="s-skel" role="status" aria-live="polite" aria-busy="true"><div class="s-skel-note"><span class="lv-spin" aria-hidden="true"></span>Opening…</div>' +
|
|
1320
|
+
'<div class="skel-line"></div><div class="skel-line short"></div><div class="skel-block"></div><div class="skel-line"></div><div class="skel-line short"></div></div>'
|
|
1321
|
+
}
|
|
1322
|
+
function paintSession(s, data) {
|
|
1323
|
+
if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
|
|
1324
|
+
liveKeeper.before($app)
|
|
1325
|
+
$app.innerHTML = sessionPageHtml(s, data)
|
|
1326
|
+
liveAfterRender(s.id)
|
|
1327
|
+
aroundWire(s.id)
|
|
1328
|
+
}
|
|
1226
1329
|
async function viewSession(id) {
|
|
1227
1330
|
if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
|
|
1228
|
-
const
|
|
1331
|
+
const showing = $app.querySelector('.s-head[data-sid="' + CSS.escape(id) + '"]') // a re-render of the page you're on: no shell
|
|
1332
|
+
const cached = sessionCache.get(id)
|
|
1333
|
+
const known = (cached && cached.s) || state.sessions.find((x) => x.id === id) || null
|
|
1334
|
+
if (!showing) {
|
|
1335
|
+
if (cached) paintSession(cached.s, cached) // the last visit, at once; fresh data replaces it below
|
|
1336
|
+
else { liveKeeper.before($app); $app.innerHTML = sessionShellHtml(known) }
|
|
1337
|
+
}
|
|
1338
|
+
// on leftoff.dev, another Mac's session loads its data from that Mac: say so before the first call
|
|
1339
|
+
const owner = (x) => { if (x && window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(x.id, x.isLocal === false && !x.agent ? x.machine : null) }
|
|
1340
|
+
owner(known)
|
|
1341
|
+
const pS = api('GET', '/v1/sessions/' + encodeURIComponent(id))
|
|
1342
|
+
const pLive = !known || liveWanted(known) ? fetchLive(id) : null // the thread doesn't wait for the session
|
|
1343
|
+
const pLists = known && known.lists && known.lists.length ? fetchLists(id) : null
|
|
1344
|
+
const pRest = Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
|
|
1345
|
+
let s
|
|
1346
|
+
try { s = await pS } catch (e) { if (!onSession(id)) return; throw e }
|
|
1229
1347
|
state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
|
|
1348
|
+
owner(s)
|
|
1349
|
+
const status = s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null
|
|
1350
|
+
const lists = s.lists && s.lists.length ? (pLists || fetchLists(id)) : null
|
|
1351
|
+
const live = liveWanted(s) ? (pLive || fetchLive(id)) : !isLive(s) && !s.endedAt && s.live !== 'closed' ? liveHow().then(() => null) : null
|
|
1352
|
+
let [[spv], v, sl] = await Promise.all([pRest, live, lists, status])
|
|
1353
|
+
if (!onSession(id)) return // you moved on while this loaded
|
|
1354
|
+
// a Live fetch that failed keeps the thread you had (its bubbles say "Reconnecting…"), not an empty one
|
|
1355
|
+
const prev = sessionCache.get(id)
|
|
1356
|
+
if (v && v.error && prev && prev.live && !prev.live.error) v = { ...prev.live, error: null }
|
|
1357
|
+
const data = { spv, live: v, lists: sl }
|
|
1358
|
+
if (v && !v.error) cacheSession(id, { s, ...data, live: v })
|
|
1359
|
+
paintSession(s, data)
|
|
1360
|
+
}
|
|
1361
|
+
const fetchLists = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists').catch(() => null)
|
|
1362
|
+
function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {}) {
|
|
1230
1363
|
const branch = s.repo && s.repo.branch
|
|
1231
|
-
const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
|
|
1232
1364
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1233
1365
|
// 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
|
|
1234
1366
|
const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
|
|
1235
|
-
html += '<div class="pagehead"><div>' + (canRename
|
|
1236
|
-
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' +
|
|
1237
|
-
: '<h1>' +
|
|
1238
|
-
|
|
1367
|
+
html += '<div class="pagehead s-head" data-sid="' + esc(s.id) + '"><div class="s-head-main">' + (canRename
|
|
1368
|
+
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + titleHtml(sessionTitle(s), esc) + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
|
|
1369
|
+
: '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1>') + '<div class="sub">' + statusPill(s) + liveBadge(s) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' ' + esc(s.titleSource ? (s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource + ' title') : '') + '</div></div>' +
|
|
1370
|
+
// 0.9.8: one line each, 44px, an icon and a short label; the group drops under the title as a whole when it doesn't fit beside it
|
|
1371
|
+
'<div class="actions s-actions">' + goButton(s, { page: true }) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + 'Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
|
|
1372
|
+
html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
|
|
1239
1373
|
html += '<div class="facts">' +
|
|
1240
1374
|
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
1241
1375
|
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
|
|
1242
1376
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
1243
1377
|
fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
1378
|
+
if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
|
|
1244
1379
|
html += parkRow(s)
|
|
1245
1380
|
html += spacePicker(s, spv, 'session', s.id)
|
|
1246
1381
|
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
1247
1382
|
// 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
|
|
1248
1383
|
html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
|
|
1249
1384
|
html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
|
|
1250
|
-
|
|
1385
|
+
// 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
|
|
1386
|
+
if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
|
|
1387
|
+
if (!isLive(s)) html += liveBoxHtml(s, liveV)
|
|
1251
1388
|
if (s.statusChanges && s.statusChanges.length) {
|
|
1252
1389
|
html += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" title="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
|
|
1253
1390
|
}
|
|
1254
1391
|
// Lists (v0.5.1): its saved lists in full, the hint, and Find lists in this session
|
|
1255
1392
|
if (s.lists && s.lists.length) {
|
|
1256
|
-
const sl = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists').catch(() => null)
|
|
1257
1393
|
state.listFull = {}
|
|
1258
|
-
for (const l of (
|
|
1394
|
+
for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l
|
|
1259
1395
|
}
|
|
1260
1396
|
state.listsMayEdit = state.mayAnswer !== false
|
|
1261
1397
|
html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
|
|
@@ -1273,9 +1409,257 @@ async function viewSession(id) {
|
|
|
1273
1409
|
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
1274
1410
|
if (empties.length) html += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
|
|
1275
1411
|
html += answeredList(s)
|
|
1276
|
-
|
|
1277
|
-
|
|
1412
|
+
return html
|
|
1413
|
+
}
|
|
1414
|
+
// ------------------------------------------------------------ Live link (0.9.8 beta)
|
|
1415
|
+
|
|
1416
|
+
const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
|
|
1417
|
+
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>' }
|
|
1418
|
+
// 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
|
|
1419
|
+
// bottom); Claude's answer is the session's own (markdown, like the terminal).
|
|
1420
|
+
const liveKeeper = createLiveKeeper()
|
|
1421
|
+
const liveSeenKey = (id) => 'lo-live-seen:' + id
|
|
1422
|
+
function liveSeen(id) { try { return localStorage.getItem(liveSeenKey(id)) } catch { return null } }
|
|
1423
|
+
function markLiveSeen(id, at) { try { localStorage.setItem(liveSeenKey(id), at || new Date().toISOString()) } catch {} }
|
|
1424
|
+
// "Chat" on a live card: straight to the Live box (#/s/<id>/live), with a dot when Claude replied since you last looked.
|
|
1425
|
+
function chatButton(s) {
|
|
1426
|
+
if (!cardPills(s).live || s.agent) return ''
|
|
1427
|
+
const unread = hasUnread(s.liveLink && s.liveLink.lastReplyAt, liveSeen(s.id))
|
|
1428
|
+
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>'
|
|
1429
|
+
}
|
|
1430
|
+
// The how-to for a session that isn't live (fetched once: the claude line has this Mac's path).
|
|
1431
|
+
async function liveHow() {
|
|
1432
|
+
if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
|
|
1433
|
+
return state.liveHow
|
|
1434
|
+
}
|
|
1435
|
+
// Does this session's page show a Live thread (and so fetch one)?
|
|
1436
|
+
const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
|
|
1437
|
+
// GET the Live thread; what it says is the newest the page knows (0.9.8: the
|
|
1438
|
+
// time of the last good answer decides "Reconnecting…" on a pending bubble).
|
|
1439
|
+
async function fetchLive(id, { timeoutMs } = {}) {
|
|
1440
|
+
const turns = (state.liveTurns && state.liveTurns[id]) || 20 // "Show earlier" adds 20 at a time
|
|
1441
|
+
try {
|
|
1442
|
+
const v = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/live' + (turns > 20 ? '?turns=' + turns : ''), null, timeoutMs ? { timeoutMs } : {})
|
|
1443
|
+
noteLive(id, v)
|
|
1444
|
+
return v
|
|
1445
|
+
} catch (e) { return { messages: [], error: e.message } }
|
|
1446
|
+
}
|
|
1447
|
+
function liveBoxHtml(s, v) {
|
|
1448
|
+
if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
|
|
1449
|
+
if (!isLive(s)) {
|
|
1450
|
+
if (s.endedAt || s.live === 'closed') return ''
|
|
1451
|
+
const how = state.liveHow
|
|
1452
|
+
return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
|
|
1453
|
+
'<p class="faint">To send this session messages from here or your phone, and answer its permission prompts, start it with <code>leftoff live</code>' +
|
|
1454
|
+
(how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p></section>'
|
|
1455
|
+
}
|
|
1456
|
+
const msgs = (v && v.messages) || []
|
|
1457
|
+
const may = state.mayAnswer !== false
|
|
1458
|
+
const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
|
|
1459
|
+
const now = Date.now()
|
|
1460
|
+
const lastClaude = [...msgs].reverse().find((m) => m.from === 'claude')
|
|
1461
|
+
if (lastClaude) markLiveSeen(s.id, lastClaude.at)
|
|
1462
|
+
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>' +
|
|
1463
|
+
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1464
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now }) + '</ol>' +
|
|
1465
|
+
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
|
|
1466
|
+
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
|
|
1467
|
+
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
1468
|
+
'<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>' +
|
|
1469
|
+
'<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>' +
|
|
1470
|
+
(may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.</p>') + '</section>'
|
|
1471
|
+
}
|
|
1472
|
+
// After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
|
|
1473
|
+
function liveAfterRender(sid) {
|
|
1474
|
+
const r = liveKeeper.after($app)
|
|
1475
|
+
if (!r) return
|
|
1476
|
+
const ol = r.thread.querySelector('.live-log')
|
|
1477
|
+
const empty = r.thread.querySelector('.live-empty')
|
|
1478
|
+
if (empty && ol) empty.hidden = ol.children.length > 0
|
|
1479
|
+
liveJumpButton(r.thread, liveKeeper.unseen)
|
|
1480
|
+
if (ol && !ol.dataset.wired) {
|
|
1481
|
+
ol.dataset.wired = '1'
|
|
1482
|
+
ol._stick = atBottom(ol)
|
|
1483
|
+
// the button follows the scroll: there whenever you're away from the bottom, gone once you're back
|
|
1484
|
+
ol.addEventListener('scroll', () => { ol._stick = atBottom(ol); liveJumpButton(r.thread, liveKeeper.unseen) }, { passive: true })
|
|
1485
|
+
// at the bottom, the thread stays there while an entry grows (a streamed reply, a code block, late fonts)
|
|
1486
|
+
if (typeof ResizeObserver === 'function') {
|
|
1487
|
+
ol._ro = new ResizeObserver(() => { if (ol._stick && !atBottom(ol, 2)) ol.scrollTop = ol.scrollHeight; liveJumpButton(r.thread, liveKeeper.unseen) })
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (ol && ol._ro) { ol._ro.disconnect(); ol._ro.observe(ol); for (const li of ol.children) ol._ro.observe(li) }
|
|
1491
|
+
if (ol && r.follow) ol._stick = true
|
|
1492
|
+
// #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
|
|
1493
|
+
const want = parseLiveRoute(location.hash)
|
|
1494
|
+
if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
|
|
1495
|
+
state.liveFocused = location.hash + '|' + sid
|
|
1496
|
+
const box = document.getElementById('live')
|
|
1497
|
+
if (box) box.scrollIntoView({ block: 'start', behavior: 'auto' })
|
|
1498
|
+
if (ol) ol.scrollTop = ol.scrollHeight
|
|
1499
|
+
const ta = box && box.querySelector('.live-text')
|
|
1500
|
+
if (ta && !ta.disabled && !(window.matchMedia && window.matchMedia('(pointer: coarse)').matches)) ta.focus({ preventScroll: true })
|
|
1501
|
+
}
|
|
1502
|
+
liveTick()
|
|
1503
|
+
}
|
|
1504
|
+
// "Jump to latest ↓" shows whenever the thread is scrolled away from its bottom (with the
|
|
1505
|
+
// count of what came in since, when there is any), and hides once you're back at the bottom.
|
|
1506
|
+
function liveJumpButton(thread, n) {
|
|
1507
|
+
const b = thread && thread.querySelector('.live-jump')
|
|
1508
|
+
if (!b) return
|
|
1509
|
+
const ol = thread.querySelector('.live-log')
|
|
1510
|
+
const v = jumpView(ol, n)
|
|
1511
|
+
if (!v.show && n > 0) liveKeeper.seen()
|
|
1512
|
+
// on its way down after a tap: don't flash back up mid-scroll
|
|
1513
|
+
if (v.show && thread.dataset.jumping && Date.now() < Number(thread.dataset.jumping)) return
|
|
1514
|
+
b.hidden = !v.show
|
|
1515
|
+
const c = b.querySelector('.live-jump-n')
|
|
1516
|
+
if (c) c.textContent = v.count
|
|
1517
|
+
b.setAttribute('aria-label', v.label)
|
|
1518
|
+
}
|
|
1519
|
+
function liveJumpClick(btn) {
|
|
1520
|
+
const thread = btn.closest('.live-thread')
|
|
1521
|
+
const ol = thread && thread.querySelector('.live-log')
|
|
1522
|
+
const smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
1523
|
+
if (thread) thread.dataset.jumping = String(Date.now() + (smooth ? 900 : 0))
|
|
1524
|
+
if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
|
|
1525
|
+
liveKeeper.seen()
|
|
1526
|
+
btn.hidden = true
|
|
1527
|
+
const c = btn.querySelector('.live-jump-n'); if (c) c.textContent = ''
|
|
1528
|
+
// land exactly at the bottom even if something grew mid-scroll, then the button follows the scroll again
|
|
1529
|
+
setTimeout(() => { if (!thread || !thread.isConnected) return; delete thread.dataset.jumping; if (ol && !atBottom(ol, 4)) ol.scrollTop = ol.scrollHeight; liveJumpButton(thread, liveKeeper.unseen) }, smooth ? 950 : 0)
|
|
1530
|
+
}
|
|
1531
|
+
// "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
|
|
1532
|
+
async function liveEarlierClick(btn) {
|
|
1533
|
+
const box = btn.closest('.live-box')
|
|
1534
|
+
const sid = box && box.dataset.id
|
|
1535
|
+
if (!sid) return
|
|
1536
|
+
state.liveTurns = state.liveTurns || {}
|
|
1537
|
+
state.liveTurns[sid] = ((state.liveTurns[sid]) || 20) + 20
|
|
1538
|
+
btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading…'
|
|
1539
|
+
await render()
|
|
1540
|
+
}
|
|
1541
|
+
// The elapsed time beside "Claude is working…", every second while something is in flight.
|
|
1542
|
+
// 0.9.8: when what the page shows is older than STALE_MS (the stream died, the
|
|
1543
|
+
// phone slept), a running bubble says "Reconnecting…" instead of a timer.
|
|
1544
|
+
let liveTimer = null
|
|
1545
|
+
function liveTick() {
|
|
1546
|
+
const els = [...document.querySelectorAll('.lv-status[data-since]')]
|
|
1547
|
+
const box = document.querySelector('#live[data-id]')
|
|
1548
|
+
const stale = !!box && isStale(liveUi.okAt[box.dataset.id], Date.now())
|
|
1549
|
+
for (const el of els) {
|
|
1550
|
+
const since = Number(el.dataset.since) || 0
|
|
1551
|
+
const end = Number(el.dataset.final) || 0
|
|
1552
|
+
const t = el.querySelector('.lvs-time')
|
|
1553
|
+
if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
|
|
1554
|
+
if (!end) {
|
|
1555
|
+
el.classList.toggle('lv-reconnecting', stale)
|
|
1556
|
+
let hint = el.querySelector('.lvs-hint')
|
|
1557
|
+
if (stale && !hint) { hint = document.createElement('span'); hint.className = 'lvs-hint'; hint.textContent = 'Reconnecting…'; (el.querySelector('.lvs-line') || el).append(hint) }
|
|
1558
|
+
if (hint) hint.hidden = !stale
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
const running = els.some((el) => !el.dataset.final)
|
|
1562
|
+
if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// 0.9.8: Live keeps up after the phone sleeps (ui/live-wake.js). Every Live
|
|
1566
|
+
// fetch notes when it answered and whether a message is still going; while
|
|
1567
|
+
// one is, on a visible page, the thread is fetched again every POLL_MS
|
|
1568
|
+
// whatever the event stream does.
|
|
1569
|
+
const liveUi = { okAt: {}, sig: {}, pending: {}, polling: false }
|
|
1570
|
+
function noteLive(id, v) {
|
|
1571
|
+
if (!v || v.error) return
|
|
1572
|
+
liveUi.okAt[id] = Date.now()
|
|
1573
|
+
liveUi.pending[id] = anyPending(v.messages)
|
|
1574
|
+
}
|
|
1575
|
+
const liveOnScreen = () => { const r = state.route; const box = document.querySelector('#live[data-id]'); return r && r.name === 's' && box && box.dataset.id === r.arg ? r.arg : null }
|
|
1576
|
+
// Fetch the thread and diff it into the page, even while you type (busy()
|
|
1577
|
+
// holds full redraws back, never the thread). A turn that ended also redraws
|
|
1578
|
+
// the rest of the page (the pill stops pulsing) once you're not busy.
|
|
1579
|
+
async function refreshLive(reason) {
|
|
1580
|
+
const id = liveOnScreen()
|
|
1581
|
+
if (!id || liveUi.polling) return false
|
|
1582
|
+
liveUi.polling = true; liveUi.triedAt = Date.now()
|
|
1583
|
+
let v
|
|
1584
|
+
try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
|
|
1585
|
+
if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
|
|
1586
|
+
const s = state.sessions.find((x) => x.id === id)
|
|
1587
|
+
const sig = liveSignature(v.messages)
|
|
1588
|
+
const before = liveUi.sig[id]
|
|
1589
|
+
liveUi.sig[id] = sig
|
|
1590
|
+
if (s && before !== sig) {
|
|
1591
|
+
const box = document.querySelector('#live')
|
|
1592
|
+
const cur = box && box.querySelector('.live-thread')
|
|
1593
|
+
if (cur) {
|
|
1594
|
+
const tmp = document.createElement('div')
|
|
1595
|
+
tmp.innerHTML = liveBoxHtml(s, v)
|
|
1596
|
+
const fresh = tmp.querySelector('.live-thread')
|
|
1597
|
+
if (fresh) {
|
|
1598
|
+
const next = cur.nextSibling
|
|
1599
|
+
liveKeeper.before(box)
|
|
1600
|
+
box.insertBefore(fresh, next)
|
|
1601
|
+
const r = liveKeeper.after(box)
|
|
1602
|
+
if (r) liveJumpButton(r.thread, liveKeeper.unseen)
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
const c = sessionCache.get(id); if (c) c.live = v
|
|
1606
|
+
if (!liveUi.pending[id] && !busy()) soon() // settled: the pill, the tab, the card
|
|
1607
|
+
}
|
|
1608
|
+
liveTick()
|
|
1609
|
+
return true
|
|
1278
1610
|
}
|
|
1611
|
+
// The safety net, and the stream's watchdog, on one slow timer.
|
|
1612
|
+
const streamDog = createWatchdog()
|
|
1613
|
+
const wakeGate = createWake()
|
|
1614
|
+
setInterval(() => {
|
|
1615
|
+
if (document.visibilityState === 'hidden') return
|
|
1616
|
+
const id = liveOnScreen()
|
|
1617
|
+
if (shouldPoll({ visible: true, onLive: !!id, pending: !!(id && liveUi.pending[id]) }) && Date.now() - Math.max(liveUi.okAt[id] || 0, liveUi.triedAt || 0) >= POLL_MS - 250) refreshLive('poll')
|
|
1618
|
+
if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
|
|
1619
|
+
}, 1000)
|
|
1620
|
+
// Coming back (iOS: the Home Screen app returns, the screen unlocks, a
|
|
1621
|
+
// bfcache restore, the network returns): fetch what's on screen at once, and
|
|
1622
|
+
// open a new stream if the old one missed its pings.
|
|
1623
|
+
function refreshView(reason) {
|
|
1624
|
+
if (liveOnScreen()) refreshLive(reason)
|
|
1625
|
+
if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
|
|
1626
|
+
refreshHealth()
|
|
1627
|
+
}
|
|
1628
|
+
let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
|
|
1629
|
+
function onWake(ev) {
|
|
1630
|
+
if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
|
|
1631
|
+
const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
|
|
1632
|
+
if (!why) return
|
|
1633
|
+
liveTick() // a stale timer turns into "Reconnecting…" before anything else
|
|
1634
|
+
const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
|
|
1635
|
+
const away = awayAt ? Date.now() - awayAt : 0
|
|
1636
|
+
awayAt = 0
|
|
1637
|
+
// a click back into the window a moment later isn't a wake-up; coming back after a while, a restore or the network is
|
|
1638
|
+
if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
|
|
1639
|
+
if (!wakeGate.due()) return
|
|
1640
|
+
if (why === 'pageshow-bfcache' || dead) connect()
|
|
1641
|
+
refreshView(why)
|
|
1642
|
+
}
|
|
1643
|
+
for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
|
|
1644
|
+
document.addEventListener('visibilitychange', onWake)
|
|
1645
|
+
async function liveSendClick(btn) {
|
|
1646
|
+
const box = btn.closest('.live-box')
|
|
1647
|
+
const ta = box && box.querySelector('.live-text')
|
|
1648
|
+
const text = ta ? ta.value.trim() : ''
|
|
1649
|
+
if (!text) { toast('Type a message first'); return }
|
|
1650
|
+
if (btn.getAttribute('aria-disabled') === 'true') return
|
|
1651
|
+
btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Sending…'
|
|
1652
|
+
try {
|
|
1653
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/live', { text })
|
|
1654
|
+
if (state.drafts) delete state.drafts['live:' + btn.dataset.id]
|
|
1655
|
+
if (ta) ta.value = ''
|
|
1656
|
+
if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
|
|
1657
|
+
const ol = box && box.querySelector('.live-log')
|
|
1658
|
+
if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
|
|
1659
|
+
setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
|
|
1660
|
+
} 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' } }
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1279
1663
|
// ------------------------------------------------------------ search inside transcripts (0.9.6)
|
|
1280
1664
|
|
|
1281
1665
|
// Plain text + [start, end] ranges -> escaped HTML with <mark>s (ranges that
|
|
@@ -2385,6 +2769,7 @@ async function viewSettings() {
|
|
|
2385
2769
|
general: '<div class="section">Needs you</div>' +
|
|
2386
2770
|
'<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a notification when a prompt or question has waited ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
|
|
2387
2771
|
'<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
|
|
2772
|
+
'<div class="full push-slot" data-push-slot hidden></div>' +
|
|
2388
2773
|
'<div class="section">Appearance</div>' +
|
|
2389
2774
|
'<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>' +
|
|
2390
2775
|
advanced('general', '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
|
|
@@ -3146,7 +3531,8 @@ function parseRoute() {
|
|
|
3146
3531
|
|
|
3147
3532
|
async function render() {
|
|
3148
3533
|
const r = (state.route = parseRoute())
|
|
3149
|
-
|
|
3534
|
+
paintNav(r)
|
|
3535
|
+
tabsOnRoute(r)
|
|
3150
3536
|
try {
|
|
3151
3537
|
if (r.name === 's' && r.arg) await viewSession(r.arg)
|
|
3152
3538
|
else if (r.name === 'waiting') await viewWaiting()
|
|
@@ -3161,6 +3547,7 @@ async function render() {
|
|
|
3161
3547
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
3162
3548
|
else if (r.name === 'setup') await viewSetup()
|
|
3163
3549
|
else await viewBoard()
|
|
3550
|
+
tabsAfterRender()
|
|
3164
3551
|
} catch (e) {
|
|
3165
3552
|
if (e.code === 'login_required') return showLogin()
|
|
3166
3553
|
$app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
|
|
@@ -3233,6 +3620,7 @@ async function refreshHealth() {
|
|
|
3233
3620
|
if (h.answering) state.mayAnswer = h.answering.allowedHere !== false
|
|
3234
3621
|
const dot = document.querySelector('#wnTab .newdot')
|
|
3235
3622
|
if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
|
|
3623
|
+
paintMoreDots()
|
|
3236
3624
|
paintSettingsDots()
|
|
3237
3625
|
} catch (e) {
|
|
3238
3626
|
el.className = 'health down'
|
|
@@ -3263,6 +3651,475 @@ function paintTopDot() {
|
|
|
3263
3651
|
el.className = 'topdot' + (warn ? ' warn' : '')
|
|
3264
3652
|
const why = all.flatMap((x) => x.why)
|
|
3265
3653
|
document.getElementById('setTab').title = 'Settings' + (why.length && !el.hidden ? ' · ' + why.join(' · ') : '')
|
|
3654
|
+
paintMoreDots()
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
// ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
|
|
3658
|
+
// From 700px up the sections sit in a rail on the left; under it, in a bottom
|
|
3659
|
+
// bar with the rest under More. Hover the Needs you item (or long-press it on a
|
|
3660
|
+
// phone, or press ↓/→ on it) for Quick look: the count and the five longest
|
|
3661
|
+
// waits, with Allow / Deny on permission prompts (the Needs you page's own
|
|
3662
|
+
// buttons, so the same checks apply and nothing is answered without a click).
|
|
3663
|
+
const railMode = () => window.matchMedia('(min-width: 700px)').matches
|
|
3664
|
+
function paintNav(r = state.route || parseRoute()) {
|
|
3665
|
+
const key = navKey(r.name)
|
|
3666
|
+
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') })
|
|
3667
|
+
const more = document.getElementById('moreBtn')
|
|
3668
|
+
if (more) more.classList.toggle('on', moreActive(key))
|
|
3669
|
+
}
|
|
3670
|
+
function paintMoreDots() {
|
|
3671
|
+
const wn = document.querySelector('#wnTab .newdot'), st = document.querySelector('#setTab .topdot')
|
|
3672
|
+
const mwn = document.querySelector('#moreMenu .newdot'), mst = document.querySelector('#moreMenu .topdot')
|
|
3673
|
+
if (mwn && wn) mwn.hidden = wn.hidden
|
|
3674
|
+
if (mst && st) { mst.hidden = st.hidden; mst.className = st.className }
|
|
3675
|
+
const md = document.querySelector('#moreBtn .moredot')
|
|
3676
|
+
if (md) { md.hidden = !((wn && !wn.hidden) || (st && !st.hidden)); md.className = 'moredot' + (st && !st.hidden && st.classList.contains('warn') ? ' warn' : '') }
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
// More (phones)
|
|
3680
|
+
const moreOpen = () => { const m = document.getElementById('moreMenu'); return !!m && !m.hidden }
|
|
3681
|
+
function closeMore(focusBtn = false) {
|
|
3682
|
+
const m = document.getElementById('moreMenu'), b = document.getElementById('moreBtn')
|
|
3683
|
+
if (!m || m.hidden) return
|
|
3684
|
+
m.hidden = true
|
|
3685
|
+
if (b) { b.setAttribute('aria-expanded', 'false'); if (focusBtn) b.focus() }
|
|
3686
|
+
}
|
|
3687
|
+
function openMore() {
|
|
3688
|
+
const m = document.getElementById('moreMenu'), b = document.getElementById('moreBtn')
|
|
3689
|
+
if (!m) return
|
|
3690
|
+
closeQuickLook(); toggleStatus(false)
|
|
3691
|
+
paintMoreDots()
|
|
3692
|
+
paintPush()
|
|
3693
|
+
m.hidden = false
|
|
3694
|
+
if (b) b.setAttribute('aria-expanded', 'true')
|
|
3695
|
+
const first = m.querySelector('a.on') || m.querySelector('a')
|
|
3696
|
+
if (first) first.focus()
|
|
3697
|
+
}
|
|
3698
|
+
document.getElementById('moreBtn').addEventListener('click', () => (moreOpen() ? closeMore() : openMore()))
|
|
3699
|
+
document.getElementById('moreMenu').addEventListener('click', (e) => { if (e.target.closest('a')) closeMore() })
|
|
3700
|
+
document.getElementById('moreMenu').addEventListener('keydown', (e) => {
|
|
3701
|
+
const items = [...document.querySelectorAll('#moreMenu a')]
|
|
3702
|
+
const i = items.indexOf(document.activeElement)
|
|
3703
|
+
if (e.key === 'Escape') { e.preventDefault(); closeMore(true) }
|
|
3704
|
+
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() }
|
|
3705
|
+
else if (e.key === 'Tab') closeMore()
|
|
3706
|
+
})
|
|
3707
|
+
|
|
3708
|
+
// Quick look
|
|
3709
|
+
const qlUi = { open: false, how: null, openT: 0, closeT: 0, suppressClick: false, model: null, seq: 0 }
|
|
3710
|
+
const quickLookOpen = () => qlUi.open
|
|
3711
|
+
function quickLookFocusables() { const p = document.getElementById('quickLook'); return p ? [...p.querySelectorAll('a[href], button:not([disabled])')] : [] }
|
|
3712
|
+
function paintQuickLook() {
|
|
3713
|
+
const pop = document.getElementById('quickLook')
|
|
3714
|
+
if (!pop || !qlUi.open) return
|
|
3715
|
+
const had = pop.contains(document.activeElement) ? document.activeElement : null
|
|
3716
|
+
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') })
|
|
3717
|
+
const model = qlUi.model || { total: Number((state.health || {}).needsYou) || 0, items: [], more: 0 }
|
|
3718
|
+
pop.innerHTML = quickLookHtml(model, { mayAnswer: state.mayAnswer, pending: state.pendingAsk })
|
|
3719
|
+
pop.setAttribute('aria-busy', qlUi.model ? 'false' : 'true')
|
|
3720
|
+
placeQuickLook()
|
|
3721
|
+
if (keep) {
|
|
3722
|
+
let el = null
|
|
3723
|
+
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])') }
|
|
3724
|
+
else if (keep.foot) el = pop.querySelector('.ql-foot')
|
|
3725
|
+
;(el || quickLookFocusables()[0] || pop).focus()
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
function placeQuickLook() {
|
|
3729
|
+
const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
|
|
3730
|
+
if (!pop || pop.hidden || !tab) return
|
|
3731
|
+
if (!railMode()) { pop.style.left = ''; pop.style.top = ''; return } // the CSS puts it above the bottom bar
|
|
3732
|
+
const r = tab.getBoundingClientRect(), rail = document.querySelector('.top').getBoundingClientRect()
|
|
3733
|
+
pop.style.left = (Math.max(r.right, rail.right) + 8) + 'px'
|
|
3734
|
+
pop.style.top = Math.max(8, Math.min(r.top - 6, window.innerHeight - 8 - pop.offsetHeight)) + 'px'
|
|
3735
|
+
}
|
|
3736
|
+
// The sessions behind the popover: what the page already has, then a fresh read.
|
|
3737
|
+
async function loadQuickLook() {
|
|
3738
|
+
const seq = ++qlUi.seq
|
|
3739
|
+
if (state.sessions && state.sessions.length && !qlUi.model) { qlUi.model = quickLookItems(state.sessions); paintQuickLook() }
|
|
3740
|
+
try {
|
|
3741
|
+
const { sessions, viewer } = await api('GET', '/v1/sessions?limit=200', undefined, { timeoutMs: 8000 })
|
|
3742
|
+
if (seq !== qlUi.seq) return
|
|
3743
|
+
if (viewer) state.mayAnswer = viewer.mayAnswer !== false
|
|
3744
|
+
qlUi.model = quickLookItems(sessions || [])
|
|
3745
|
+
} catch { if (!qlUi.model) qlUi.model = { total: 0, items: [], more: 0, failed: true } }
|
|
3746
|
+
paintQuickLook()
|
|
3747
|
+
}
|
|
3748
|
+
function openQuickLook(how = 'hover') {
|
|
3749
|
+
const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
|
|
3750
|
+
if (!pop || !tab) return
|
|
3751
|
+
clearTimeout(qlUi.openT); clearTimeout(qlUi.closeT)
|
|
3752
|
+
if (qlUi.open) { if (how !== 'hover') { qlUi.how = how; const f = quickLookFocusables()[0]; if (f) f.focus() } return }
|
|
3753
|
+
closeMore(); toggleStatus(false)
|
|
3754
|
+
qlUi.open = true; qlUi.how = how; qlUi.model = null
|
|
3755
|
+
pop.hidden = false
|
|
3756
|
+
tab.setAttribute('aria-expanded', 'true')
|
|
3757
|
+
// Hover never takes focus; the keyboard and a long-press move into the card (and Tab stays inside it).
|
|
3758
|
+
pop.setAttribute('aria-modal', how === 'hover' ? 'false' : 'true')
|
|
3759
|
+
paintQuickLook()
|
|
3760
|
+
if (how !== 'hover') { const f = quickLookFocusables()[0]; (f || pop).focus() }
|
|
3761
|
+
loadQuickLook()
|
|
3762
|
+
}
|
|
3763
|
+
function closeQuickLook(focusTab = false) {
|
|
3764
|
+
clearTimeout(qlUi.openT); clearTimeout(qlUi.closeT)
|
|
3765
|
+
if (!qlUi.open) return
|
|
3766
|
+
const pop = document.getElementById('quickLook'), tab = document.getElementById('needsTab')
|
|
3767
|
+
const inside = pop && pop.contains(document.activeElement)
|
|
3768
|
+
qlUi.open = false; qlUi.seq++
|
|
3769
|
+
if (pop) { pop.hidden = true; pop.innerHTML = '' }
|
|
3770
|
+
if (tab) { tab.setAttribute('aria-expanded', 'false'); if (focusTab || inside) tab.focus() }
|
|
3771
|
+
}
|
|
3772
|
+
{
|
|
3773
|
+
const tab = document.getElementById('needsTab'), pop = document.getElementById('quickLook')
|
|
3774
|
+
const canHover = () => window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
|
3775
|
+
const later = (fn, ms) => { clearTimeout(qlUi.closeT); clearTimeout(qlUi.openT); return setTimeout(fn, ms) }
|
|
3776
|
+
tab.addEventListener('mouseenter', () => { if (!canHover()) return; clearTimeout(qlUi.closeT); if (!qlUi.open) qlUi.openT = later(() => openQuickLook('hover'), 280) })
|
|
3777
|
+
tab.addEventListener('mouseleave', () => { clearTimeout(qlUi.openT); if (qlUi.open && qlUi.how === 'hover') qlUi.closeT = setTimeout(() => closeQuickLook(), 320) })
|
|
3778
|
+
pop.addEventListener('mouseenter', () => clearTimeout(qlUi.closeT))
|
|
3779
|
+
pop.addEventListener('mouseleave', () => { if (qlUi.open && qlUi.how === 'hover' && !pop.contains(document.activeElement)) qlUi.closeT = setTimeout(() => closeQuickLook(), 320) })
|
|
3780
|
+
// Long-press on a touch screen; the tap that ends it doesn't navigate.
|
|
3781
|
+
let lp = null
|
|
3782
|
+
tab.addEventListener('pointerdown', (e) => {
|
|
3783
|
+
if (e.pointerType === 'mouse') return
|
|
3784
|
+
const x = e.clientX, y = e.clientY
|
|
3785
|
+
clearTimeout(lp && lp.t)
|
|
3786
|
+
lp = { x, y, t: setTimeout(() => { qlUi.suppressClick = true; openQuickLook('touch'); if (navigator.vibrate) try { navigator.vibrate(10) } catch {} }, 450) }
|
|
3787
|
+
})
|
|
3788
|
+
const cancel = () => { if (lp) { clearTimeout(lp.t); lp = null } }
|
|
3789
|
+
tab.addEventListener('pointerup', cancel)
|
|
3790
|
+
tab.addEventListener('pointercancel', cancel)
|
|
3791
|
+
tab.addEventListener('pointermove', (e) => { if (lp && Math.hypot(e.clientX - lp.x, e.clientY - lp.y) > 10) cancel() })
|
|
3792
|
+
tab.addEventListener('contextmenu', (e) => { if (qlUi.open || lp) e.preventDefault() })
|
|
3793
|
+
tab.addEventListener('click', (e) => {
|
|
3794
|
+
if (qlUi.suppressClick) { qlUi.suppressClick = false; e.preventDefault(); return }
|
|
3795
|
+
// A second tap while it's open goes to the page, as the footer does.
|
|
3796
|
+
closeQuickLook()
|
|
3797
|
+
})
|
|
3798
|
+
tab.addEventListener('keydown', (e) => {
|
|
3799
|
+
if (['ArrowDown', 'ArrowRight', 'ArrowUp'].includes(e.key) && !e.altKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); openQuickLook('keyboard') }
|
|
3800
|
+
})
|
|
3801
|
+
pop.addEventListener('keydown', (e) => {
|
|
3802
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeQuickLook(true); return }
|
|
3803
|
+
if (e.key !== 'Tab') return
|
|
3804
|
+
const f = quickLookFocusables()
|
|
3805
|
+
if (!f.length) { e.preventDefault(); return }
|
|
3806
|
+
const i = f.indexOf(document.activeElement)
|
|
3807
|
+
if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus() }
|
|
3808
|
+
else if (!e.shiftKey && (i === -1 || i === f.length - 1)) { e.preventDefault(); f[0].focus() }
|
|
3809
|
+
})
|
|
3810
|
+
pop.addEventListener('focusin', () => { clearTimeout(qlUi.closeT); pop.setAttribute('aria-modal', 'true') })
|
|
3811
|
+
pop.addEventListener('click', (e) => { const a = e.target.closest('a[href]'); if (a) closeQuickLook() })
|
|
3812
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && qlUi.open) closeQuickLook(true) })
|
|
3813
|
+
document.addEventListener('pointerdown', (e) => {
|
|
3814
|
+
if (qlUi.open && !e.target.closest('#quickLook, #needsTab')) closeQuickLook()
|
|
3815
|
+
if (moreOpen() && !e.target.closest('#moreMenu, #moreBtn')) closeMore()
|
|
3816
|
+
}, true)
|
|
3817
|
+
window.addEventListener('hashchange', () => { closeQuickLook(); closeMore() })
|
|
3818
|
+
window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
// ------------------------------------------------------------ tabs for sessions (0.9.8, ui/tabs.js)
|
|
3822
|
+
// The board is the first tab; a session opens as the one preview tab (italic)
|
|
3823
|
+
// that the next click replaces, and stays open once you send a Live message,
|
|
3824
|
+
// type there, choose Keep open or double-click its tab (⌘/Ctrl-click or a
|
|
3825
|
+
// middle-click opens it permanent straight away). Kept per device in
|
|
3826
|
+
// localStorage. By width: under 700px the Open row on the board and ‹ N of M ›
|
|
3827
|
+
// (or a swipe) on a session page; from 700px the strip. A width change (fold,
|
|
3828
|
+
// Split View, rotation) only changes the CSS: the open set, the session and
|
|
3829
|
+
// where you were on the page stay.
|
|
3830
|
+
const tabsUi = { m: parseStored(localStorage.getItem(TABS_KEY)), active: null, route: null, permanent: null, html: { strip: '', row: '', pager: '' }, anchor: null, logs: new WeakMap(), quietUntil: 0, fetchT: 0, fetchedAt: 0, scrolls: {} }
|
|
3831
|
+
window.leftoffTabs = { model: () => JSON.parse(serialize(tabsUi.m)) } // read by test:tabs-ui
|
|
3832
|
+
function setTabs(m) {
|
|
3833
|
+
if (m === tabsUi.m) return
|
|
3834
|
+
tabsUi.m = m
|
|
3835
|
+
try { localStorage.setItem(TABS_KEY, serialize(m)) } catch {}
|
|
3836
|
+
}
|
|
3837
|
+
const tabSession = (id) => state.sessions.find((s) => s.id === id)
|
|
3838
|
+
const tabViews = () => tabsUi.m.tabs.map((t) => tabView(t, tabSession(t.id), { title: sessionTitle, working: (s) => liveWorking(s) }))
|
|
3839
|
+
function tabsOnRoute(r) {
|
|
3840
|
+
const id = r.name === 's' && r.arg ? String(r.arg) : null
|
|
3841
|
+
if (tabsUi.active && tabsUi.active !== id) tabsUi.scrolls[tabsUi.active] = window.scrollY || 0
|
|
3842
|
+
if (id) {
|
|
3843
|
+
const permanent = tabsUi.permanent === id
|
|
3844
|
+
setTabs(openTab(tabsUi.m, id, { permanent, hash: keepHash(location.hash), title: (tabSession(id) && sessionTitle(tabSession(id))) || '' }))
|
|
3845
|
+
}
|
|
3846
|
+
tabsUi.permanent = null
|
|
3847
|
+
const changed = tabsUi.active !== id || tabsUi.route !== r.name
|
|
3848
|
+
tabsUi.active = id; tabsUi.route = r.name
|
|
3849
|
+
document.body.dataset.route = r.name
|
|
3850
|
+
paintTabs()
|
|
3851
|
+
if (changed) scrollActiveTab()
|
|
3852
|
+
}
|
|
3853
|
+
function tabsAfterRender() {
|
|
3854
|
+
let m = tabsUi.m
|
|
3855
|
+
for (const t of m.tabs) { const s = tabSession(t.id); if (s) m = remember(m, t.id, { title: sessionTitle(s) }) }
|
|
3856
|
+
setTabs(m)
|
|
3857
|
+
paintTabs()
|
|
3858
|
+
const back = tabsUi.active && tabsUi.restoreScroll === tabsUi.active ? tabsUi.scrolls[tabsUi.active] : null
|
|
3859
|
+
tabsUi.restoreScroll = null
|
|
3860
|
+
if (back != null) window.scrollTo(0, back)
|
|
3861
|
+
noteAnchor()
|
|
3862
|
+
// the other open tabs' pulse and dot: read the sessions again now and then while you're off the board
|
|
3863
|
+
if (tabsUi.route !== 'board' && tabsUi.m.tabs.some((t) => t.id !== tabsUi.active) && Date.now() - tabsUi.fetchedAt > 15000) refreshTabSessions()
|
|
3864
|
+
}
|
|
3865
|
+
async function refreshTabSessions() {
|
|
3866
|
+
tabsUi.fetchedAt = Date.now()
|
|
3867
|
+
try {
|
|
3868
|
+
const { sessions = [], parked = [] } = await api('GET', '/v1/sessions?limit=200')
|
|
3869
|
+
const fresh = new Map(sessions.concat(parked).map((s) => [s.id, s]))
|
|
3870
|
+
state.sessions = state.sessions.map((s) => (s.id !== tabsUi.active && fresh.has(s.id) ? fresh.get(s.id) : s)).concat([...fresh.values()].filter((s) => !state.sessions.some((x) => x.id === s.id)))
|
|
3871
|
+
paintTabs()
|
|
3872
|
+
} catch {}
|
|
3873
|
+
}
|
|
3874
|
+
function paintTabs() {
|
|
3875
|
+
const views = tabViews()
|
|
3876
|
+
const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
|
|
3877
|
+
if (!strip) return
|
|
3878
|
+
const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
|
|
3879
|
+
if (sh !== tabsUi.html.strip) {
|
|
3880
|
+
const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
|
|
3881
|
+
const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
|
|
3882
|
+
strip.innerHTML = sh; tabsUi.html.strip = sh
|
|
3883
|
+
const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
|
|
3884
|
+
if (had) { const el = strip.querySelector('[data-tab="' + CSS.escape(had) + '"], [data-tab-close="' + CSS.escape(had) + '"], [data-tab-keep="' + CSS.escape(had) + '"], #' + CSS.escape(had)); if (el) el.focus({ preventScroll: true }) }
|
|
3885
|
+
}
|
|
3886
|
+
strip.hidden = false
|
|
3887
|
+
const rh = openRowHtml(views, esc)
|
|
3888
|
+
if (rh !== tabsUi.html.row) { const c = row.querySelector('.openrow-chips'), l = c ? c.scrollLeft : 0; row.innerHTML = rh; tabsUi.html.row = rh; const nc = row.querySelector('.openrow-chips'); if (nc) nc.scrollLeft = l }
|
|
3889
|
+
row.hidden = !rh
|
|
3890
|
+
const ph = tabsUi.active ? pagerHtml(tabsUi.m, tabsUi.active, views, esc) : ''
|
|
3891
|
+
if (ph !== tabsUi.html.pager) { pager.innerHTML = ph; tabsUi.html.pager = ph }
|
|
3892
|
+
pager.hidden = !ph
|
|
3893
|
+
}
|
|
3894
|
+
function scrollActiveTab() {
|
|
3895
|
+
const el = document.querySelector('#tabStrip .tab-cell.on'), sc = document.querySelector('#tabStrip .tab-scroll')
|
|
3896
|
+
if (!el || !sc) return
|
|
3897
|
+
const r = el.getBoundingClientRect(), b = sc.getBoundingClientRect()
|
|
3898
|
+
if (r.left < b.left) sc.scrollLeft -= b.left - r.left + 8
|
|
3899
|
+
else if (r.right > b.right) sc.scrollLeft += r.right - b.right + 8
|
|
3900
|
+
}
|
|
3901
|
+
function keepTab(id) { setTabs(promote(tabsUi.m, id)); paintTabs() }
|
|
3902
|
+
function closeTabNow(id) {
|
|
3903
|
+
const { model, next } = closeTab(tabsUi.m, id, tabsUi.active)
|
|
3904
|
+
setTabs(model)
|
|
3905
|
+
delete tabsUi.scrolls[id]
|
|
3906
|
+
if (tabsUi.active === String(id)) {
|
|
3907
|
+
const t = next && model.tabs.find((x) => x.id === next)
|
|
3908
|
+
if (t) tabsUi.restoreScroll = t.id
|
|
3909
|
+
location.hash = t ? tabView(t).href : '#/'
|
|
3910
|
+
} else paintTabs()
|
|
3911
|
+
}
|
|
3912
|
+
function goTab(href) {
|
|
3913
|
+
const id = sessionOfHash(href)
|
|
3914
|
+
if (id) tabsUi.restoreScroll = id
|
|
3915
|
+
if (location.hash === href) render(); else location.hash = href
|
|
3916
|
+
}
|
|
3917
|
+
// ⌘/Ctrl-click or a middle-click on any session link: open it as a permanent tab (here, not a new browser tab)
|
|
3918
|
+
function sessionLinkId(a) { return a && a.getAttribute && sessionOfHash(a.getAttribute('href') || '') }
|
|
3919
|
+
document.addEventListener('click', (e) => {
|
|
3920
|
+
const a = e.target.closest && e.target.closest('a[href^="#/s/"]')
|
|
3921
|
+
if (!a || !(e.metaKey || e.ctrlKey) || e.button !== 0) return
|
|
3922
|
+
const id = sessionLinkId(a); if (!id) return
|
|
3923
|
+
e.preventDefault(); e.stopPropagation()
|
|
3924
|
+
if (indexOf(tabsUi.m, id) >= 0) setTabs(promote(tabsUi.m, id)); else tabsUi.permanent = id
|
|
3925
|
+
if (location.hash === a.getAttribute('href')) render(); else location.hash = a.getAttribute('href')
|
|
3926
|
+
}, true)
|
|
3927
|
+
document.addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest && e.target.closest('a[href^="#/s/"], #tabStrip .tab-cell')) e.preventDefault() }) // no autoscroll
|
|
3928
|
+
document.addEventListener('auxclick', (e) => {
|
|
3929
|
+
if (e.button !== 1 || !e.target.closest) return
|
|
3930
|
+
const cell = e.target.closest('#tabStrip .tab-cell')
|
|
3931
|
+
if (cell) { e.preventDefault(); closeTabNow(cell.dataset.tabId); return } // middle-click a tab: close it
|
|
3932
|
+
const a = e.target.closest('a[href^="#/s/"]')
|
|
3933
|
+
const id = sessionLinkId(a); if (!id) return
|
|
3934
|
+
e.preventDefault()
|
|
3935
|
+
if (indexOf(tabsUi.m, id) >= 0) { setTabs(promote(tabsUi.m, id)); paintTabs() }
|
|
3936
|
+
else { setTabs(openTab(tabsUi.m, id, { permanent: true })); paintTabs() } // opened in the background, the way a middle-click does
|
|
3937
|
+
})
|
|
3938
|
+
document.addEventListener('click', (e) => {
|
|
3939
|
+
if (!e.target.closest) return
|
|
3940
|
+
const close = e.target.closest('[data-tab-close]')
|
|
3941
|
+
if (close) { e.preventDefault(); return closeTabNow(close.dataset.tabClose) }
|
|
3942
|
+
const keep = e.target.closest('[data-tab-keep]')
|
|
3943
|
+
if (keep) { e.preventDefault(); return keepTab(keep.dataset.tabKeep) }
|
|
3944
|
+
const go = e.target.closest('[data-tab-go]')
|
|
3945
|
+
if (go) { e.preventDefault(); return goTab(go.dataset.tabGo) }
|
|
3946
|
+
const all = e.target.closest('#tabAll')
|
|
3947
|
+
if (all) { e.preventDefault(); return openActionMenu(all, allTabsItems(tabViews(), tabsUi.active), { title: 'Open sessions' }) }
|
|
3948
|
+
const t = e.target.closest('#tabStrip a[data-tab], #openRow a[data-tab], #tabPager a[data-tab]')
|
|
3949
|
+
if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
|
|
3950
|
+
})
|
|
3951
|
+
document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
|
|
3952
|
+
// Working in a session keeps its tab: a Live message, typing there, answering Claude there
|
|
3953
|
+
const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
|
|
3954
|
+
for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
|
|
3955
|
+
document.addEventListener('click', (e) => { if (e.target.closest && $app.contains(e.target) && e.target.closest('[data-action="live-send"], .live-box [data-action], .need [data-choice], [data-action="ask-answer"], [data-action="rename"]')) promoteActive() }, true)
|
|
3956
|
+
// Drag a tab to reorder
|
|
3957
|
+
{
|
|
3958
|
+
let dragId = null
|
|
3959
|
+
document.addEventListener('dragstart', (e) => { const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (!c) return; dragId = c.dataset.tabId; c.classList.add('dragging'); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragId) } catch {} })
|
|
3960
|
+
document.addEventListener('dragover', (e) => { if (!dragId) return; const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (!c) return; e.preventDefault(); document.querySelectorAll('#tabStrip .drop-before').forEach((x) => x.classList.remove('drop-before')); c.classList.add('drop-before') })
|
|
3961
|
+
document.addEventListener('drop', (e) => { if (!dragId) return; const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (c) { e.preventDefault(); const to = indexOf(tabsUi.m, c.dataset.tabId); const from = indexOf(tabsUi.m, dragId); setTabs(moveTab(tabsUi.m, dragId, from < to ? to - 1 : to)); paintTabs() } })
|
|
3962
|
+
document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('#tabStrip .dragging, #tabStrip .drop-before').forEach((x) => x.classList.remove('dragging', 'drop-before')) })
|
|
3963
|
+
}
|
|
3964
|
+
// Phones: swipe left / right on a session page for the next / previous open session
|
|
3965
|
+
{
|
|
3966
|
+
let sw = null
|
|
3967
|
+
const narrowQ = window.matchMedia('(max-width: 699.98px)')
|
|
3968
|
+
const scrollsSideways = (el) => { for (let n = el; n && n !== document.body; n = n.parentElement) { if (n.scrollWidth > n.clientWidth + 2 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true } return false }
|
|
3969
|
+
document.addEventListener('touchstart', (e) => {
|
|
3970
|
+
sw = null
|
|
3971
|
+
if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
|
|
3972
|
+
const t = e.target
|
|
3973
|
+
if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .tabs, .moremenu') || scrollsSideways(t)) return
|
|
3974
|
+
sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
|
|
3975
|
+
}, { passive: true })
|
|
3976
|
+
document.addEventListener('touchend', (e) => {
|
|
3977
|
+
if (!sw || !e.changedTouches.length) return
|
|
3978
|
+
const p = e.changedTouches[0]
|
|
3979
|
+
const dir = swipeDir(p.clientX - sw.x, p.clientY - sw.y, Date.now() - sw.at)
|
|
3980
|
+
sw = null
|
|
3981
|
+
if (!dir || (window.getSelection && String(window.getSelection()))) return
|
|
3982
|
+
const n = neighbours(tabsUi.m, tabsUi.active)
|
|
3983
|
+
const to = dir === 'next' ? n.next : n.prev
|
|
3984
|
+
if (!to) return
|
|
3985
|
+
const t = tabsUi.m.tabs.find((x) => x.id === to)
|
|
3986
|
+
document.body.classList.remove('swipe-next', 'swipe-prev'); void document.body.offsetWidth
|
|
3987
|
+
document.body.classList.add('swipe-' + dir); setTimeout(() => document.body.classList.remove('swipe-' + dir), 260)
|
|
3988
|
+
goTab(tabView(t).href)
|
|
3989
|
+
}, { passive: true })
|
|
3990
|
+
}
|
|
3991
|
+
// A width change keeps your place: the element at the top of the page stays at
|
|
3992
|
+
// the top, and a Live chat that was at its newest stays there.
|
|
3993
|
+
function anchorY() {
|
|
3994
|
+
let y = 0
|
|
3995
|
+
for (const el of [document.querySelector('.top'), document.getElementById('tabStrip'), document.getElementById('tabPager')]) {
|
|
3996
|
+
if (!el) continue
|
|
3997
|
+
const cs = getComputedStyle(el); if (cs.display === 'none' || !/(fixed|sticky)/.test(cs.position)) continue
|
|
3998
|
+
const r = el.getBoundingClientRect(); if (r.top <= 1 && r.bottom < innerHeight / 2) y = Math.max(y, r.bottom)
|
|
3999
|
+
}
|
|
4000
|
+
return y
|
|
4001
|
+
}
|
|
4002
|
+
function noteAnchor() {
|
|
4003
|
+
if (Date.now() < tabsUi.quietUntil) return
|
|
4004
|
+
const y = anchorY() + 6, m = $app.getBoundingClientRect()
|
|
4005
|
+
if (m.bottom < y) { tabsUi.anchor = null; return }
|
|
4006
|
+
let el = document.elementFromPoint(Math.max(m.left + 24, Math.min(m.left + m.width / 2, innerWidth - 24)), y)
|
|
4007
|
+
if (!el || !$app.contains(el) || el === $app) { const kids = [...$app.querySelectorAll('h1, h2, h3, p, li, .card, .fact, .lv-msg, section, div')]; el = kids.find((k) => { const r = k.getBoundingClientRect(); return r.bottom > y && r.height > 0 }) || null }
|
|
4008
|
+
tabsUi.anchor = el ? { el, top: el.getBoundingClientRect().top, scrollY: window.scrollY } : null
|
|
4009
|
+
}
|
|
4010
|
+
function keepPlace() {
|
|
4011
|
+
const a = tabsUi.anchor
|
|
4012
|
+
tabsUi.quietUntil = Date.now() + 250
|
|
4013
|
+
if (a && a.el.isConnected && a.scrollY > 0) window.scrollBy(0, a.el.getBoundingClientRect().top - a.top)
|
|
4014
|
+
document.querySelectorAll('.live-log').forEach((log) => { const st = tabsUi.logs.get(log); if (st && st.bottom) log.scrollTop = log.scrollHeight })
|
|
4015
|
+
}
|
|
4016
|
+
{
|
|
4017
|
+
let raf = 0
|
|
4018
|
+
window.addEventListener('scroll', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; noteAnchor() }) }, { passive: true })
|
|
4019
|
+
document.addEventListener('scroll', (e) => { const log = e.target; if (log && log.classList && log.classList.contains('live-log') && Date.now() >= tabsUi.quietUntil) tabsUi.logs.set(log, { bottom: log.scrollHeight - log.scrollTop - log.clientHeight < 24 }) }, true)
|
|
4020
|
+
let lay = layoutFor(innerWidth)
|
|
4021
|
+
window.addEventListener('resize', () => {
|
|
4022
|
+
keepPlace()
|
|
4023
|
+
const now = layoutFor(innerWidth)
|
|
4024
|
+
if (now !== lay) { lay = now; document.body.dataset.layout = now; scrollActiveTab() }
|
|
4025
|
+
})
|
|
4026
|
+
document.body.dataset.layout = lay
|
|
4027
|
+
}
|
|
4028
|
+
|
|
4029
|
+
// Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
|
|
4030
|
+
// past the line; the page reads its data again, the same as a live update (no
|
|
4031
|
+
// full reload unless that fails). Never from a field, the Live box or a menu.
|
|
4032
|
+
{
|
|
4033
|
+
const ptr = document.createElement('div')
|
|
4034
|
+
ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
|
|
4035
|
+
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>'
|
|
4036
|
+
document.body.appendChild(ptr)
|
|
4037
|
+
const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
|
|
4038
|
+
const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
|
|
4039
|
+
let pull = null, refreshing = false
|
|
4040
|
+
const paint = (step) => {
|
|
4041
|
+
ptr.hidden = !step || (!step.pulling && !refreshing)
|
|
4042
|
+
if (ptr.hidden) return
|
|
4043
|
+
ptr.style.transform = 'translate(-50%, ' + (refreshing ? 56 : step.dist) + 'px)'
|
|
4044
|
+
ptr.style.setProperty('--ptr-turn', String(Math.round((step.progress || 0) * 270)) + 'deg')
|
|
4045
|
+
ptr.style.opacity = refreshing ? '1' : String(Math.min(1, 0.25 + (step.progress || 0)))
|
|
4046
|
+
ptr.classList.toggle('armed', !!step.armed)
|
|
4047
|
+
ptr.classList.toggle('busy', refreshing)
|
|
4048
|
+
}
|
|
4049
|
+
async function pullRefresh() {
|
|
4050
|
+
refreshing = true; paint({ pulling: true, dist: 56, progress: 1, armed: true })
|
|
4051
|
+
const started = Date.now()
|
|
4052
|
+
try {
|
|
4053
|
+
prsUi.at = {}; projectsUi.at = 0
|
|
4054
|
+
const r = state.route || parseRoute()
|
|
4055
|
+
const editing = busy() || ['settings', 'setup'].includes(r.name)
|
|
4056
|
+
await Promise.all([editing ? null : render(), refreshHealth()])
|
|
4057
|
+
} catch { location.reload(); return }
|
|
4058
|
+
await new Promise((res) => setTimeout(res, Math.max(0, 450 - (Date.now() - started)))) // long enough to see it turn
|
|
4059
|
+
refreshing = false; ptr.classList.remove('busy', 'armed'); ptr.hidden = true
|
|
4060
|
+
}
|
|
4061
|
+
window.leftoffPullRefresh = pullRefresh
|
|
4062
|
+
document.addEventListener('touchstart', (e) => {
|
|
4063
|
+
if (refreshing || e.touches.length !== 1) { pull = null; return }
|
|
4064
|
+
const t = e.touches[0]
|
|
4065
|
+
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
|
|
4066
|
+
}, { passive: true })
|
|
4067
|
+
document.addEventListener('touchmove', (e) => {
|
|
4068
|
+
if (!pull || e.touches.length !== 1) return
|
|
4069
|
+
const t = e.touches[0]
|
|
4070
|
+
const step = pullStep(t.clientY - pull.y, t.clientX - pull.x)
|
|
4071
|
+
if (!step.pulling && !pull.step) { if (t.clientY - pull.y < -4 || Math.abs(t.clientX - pull.x) > 10) pull = null; return }
|
|
4072
|
+
if ((window.scrollY || 0) > 0) { pull = null; paint(null); return }
|
|
4073
|
+
if (e.cancelable) e.preventDefault() // ours, not the page's rubber band or the browser's own pull
|
|
4074
|
+
pull.step = step
|
|
4075
|
+
paint(step)
|
|
4076
|
+
}, { passive: false })
|
|
4077
|
+
const end = () => {
|
|
4078
|
+
if (!pull) return
|
|
4079
|
+
const what = pullRelease(pull.step)
|
|
4080
|
+
pull = null
|
|
4081
|
+
if (what === 'refresh') pullRefresh()
|
|
4082
|
+
else paint(null)
|
|
4083
|
+
}
|
|
4084
|
+
document.addEventListener('touchend', end, { passive: true })
|
|
4085
|
+
document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
// Hide the nav on scroll (phones, 0.9.8): under 700px the top row and the bottom
|
|
4089
|
+
// bar slide away while you scroll down and come back when you scroll up, reach
|
|
4090
|
+
// the top or the very bottom (ui/navhide.js). Only your own scrolling hides
|
|
4091
|
+
// them: a route change, the ‹ › pager or a swipe shows them and holds them until
|
|
4092
|
+
// you next touch, wheel or key; typing, a menu or a sheet keeps them shown. The
|
|
4093
|
+
// page never reflows: the top row stays in the flow and the bar is an overlay
|
|
4094
|
+
// over padding that stays.
|
|
4095
|
+
{
|
|
4096
|
+
const narrowQ = window.matchMedia('(max-width: 699.98px)')
|
|
4097
|
+
const top = document.querySelector('.top')
|
|
4098
|
+
let st = { hidden: false, anchor: 0 }, raf = 0, held = true
|
|
4099
|
+
const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
|
|
4100
|
+
const overlay = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .lo-backdrop, .doc-viewer:not([hidden]), .emoji-sheet-wrap, .relay-screen:not([hidden])')
|
|
4101
|
+
const apply = (hidden) => {
|
|
4102
|
+
if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
|
|
4103
|
+
document.body.classList.toggle('nav-hidden', hidden)
|
|
4104
|
+
}
|
|
4105
|
+
const read = () => {
|
|
4106
|
+
raf = 0
|
|
4107
|
+
const se = document.scrollingElement || document.documentElement
|
|
4108
|
+
const y = window.scrollY || se.scrollTop || 0
|
|
4109
|
+
const next = navHideStep(st, { y, max: se.scrollHeight - window.innerHeight, blocked: held || !narrowQ.matches || typing() || overlay() })
|
|
4110
|
+
if (next.hidden !== st.hidden) apply(next.hidden)
|
|
4111
|
+
st = next
|
|
4112
|
+
}
|
|
4113
|
+
const soon = () => { if (!raf) raf = requestAnimationFrame(read) }
|
|
4114
|
+
// no layout read here: this runs on every route change, ahead of the page's own render
|
|
4115
|
+
const showNav = (hold = true) => { if (hold) held = true; const was = st.hidden; st = { hidden: false, anchor: NaN }; if (was) apply(false) }
|
|
4116
|
+
window.addEventListener('scroll', soon, { passive: true })
|
|
4117
|
+
for (const ev of ['touchstart', 'wheel', 'keydown']) window.addEventListener(ev, () => { held = false }, { passive: true, capture: true })
|
|
4118
|
+
window.addEventListener('hashchange', () => showNav(true))
|
|
4119
|
+
document.addEventListener('focusin', (e) => { if (typing() || (top && top.contains(e.target))) showNav(false) })
|
|
4120
|
+
document.addEventListener('click', soon, { passive: true, capture: true }) // a menu or sheet just opened: back they come
|
|
4121
|
+
narrowQ.addEventListener('change', () => showNav(false))
|
|
4122
|
+
window.leftoffNav = { show: () => showNav(true), hidden: () => document.body.classList.contains('nav-hidden') }
|
|
3266
4123
|
}
|
|
3267
4124
|
|
|
3268
4125
|
// Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
|
|
@@ -3319,6 +4176,7 @@ function placeStatus() {
|
|
|
3319
4176
|
const pop = document.getElementById('statusPop'), b = document.getElementById('health')
|
|
3320
4177
|
if (!pop || pop.hidden || !b) return
|
|
3321
4178
|
const r = b.getBoundingClientRect(), vw = document.documentElement.clientWidth
|
|
4179
|
+
if (railMode()) { pop.style.right = 'auto'; pop.style.left = (document.querySelector('.top').getBoundingClientRect().right + 8) + 'px'; pop.style.top = Math.max(8, Math.min(r.bottom - pop.offsetHeight, window.innerHeight - 8 - pop.offsetHeight)) + 'px'; return } // 0.9.8: beside the rail
|
|
3322
4180
|
if (vw < 600) { pop.style.left = '8px'; pop.style.right = '8px'; pop.style.top = (r.bottom + 8) + 'px'; return }
|
|
3323
4181
|
pop.style.right = 'auto'
|
|
3324
4182
|
pop.style.top = (r.bottom + 8) + 'px'
|
|
@@ -3546,11 +4404,27 @@ document.addEventListener('keydown', (e) => {
|
|
|
3546
4404
|
|
|
3547
4405
|
// A card opens its session's page from anywhere on it, except its buttons,
|
|
3548
4406
|
// links, fields and selected text (0.4.3: the timeline was too hidden).
|
|
4407
|
+
// 0.9.8: the tap shows at once (a pressed card with a spinner); the page's
|
|
4408
|
+
// shell follows in the same frame (viewSession).
|
|
4409
|
+
function cardOpening(c) {
|
|
4410
|
+
for (const x of document.querySelectorAll('.card.opening')) if (x !== c) x.classList.remove('opening')
|
|
4411
|
+
c.classList.add('opening')
|
|
4412
|
+
setTimeout(() => { if (c.isConnected) c.classList.remove('opening') }, 4000)
|
|
4413
|
+
}
|
|
4414
|
+
document.addEventListener('pointerdown', (ev) => {
|
|
4415
|
+
const c = ev.target.closest && ev.target.closest('.card[data-href]')
|
|
4416
|
+
if (!c || ev.button > 0 || ev.target.closest('button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
|
|
4417
|
+
const a = ev.target.closest('a')
|
|
4418
|
+
if (a && !/^#\/s\//.test(a.getAttribute('href') || '')) return // a repo chip, a PR link: not opening the session
|
|
4419
|
+
cardOpening(c)
|
|
4420
|
+
}, { passive: true })
|
|
4421
|
+
document.addEventListener('pointercancel', () => { for (const x of document.querySelectorAll('.card.opening')) x.classList.remove('opening') }, true)
|
|
3549
4422
|
document.addEventListener('click', (ev) => {
|
|
3550
4423
|
const c = ev.target.closest('.card[data-href]')
|
|
3551
4424
|
if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
|
|
3552
4425
|
if (String(window.getSelection && window.getSelection()).trim()) return
|
|
3553
4426
|
if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
|
|
4427
|
+
cardOpening(c)
|
|
3554
4428
|
location.hash = c.dataset.href
|
|
3555
4429
|
})
|
|
3556
4430
|
|
|
@@ -3577,6 +4451,8 @@ document.addEventListener('input', (ev) => {
|
|
|
3577
4451
|
document.addEventListener('keydown', (ev) => { if (ev.target.id === 'prs-q' && ev.key === 'Escape' && ev.target.value) { ev.preventDefault(); ev.target.value = ''; ev.target.dispatchEvent(new Event('input', { bubbles: true })) } })
|
|
3578
4452
|
|
|
3579
4453
|
window.addEventListener('hashchange', () => closeActionMenu())
|
|
4454
|
+
const tipsUi = wireTips(document) // 0.9.8: the ⓘ tooltips (Always allow here)
|
|
4455
|
+
window.addEventListener('hashchange', () => tipsUi.hide())
|
|
3580
4456
|
// 0.8.9: a session page's title is a button too (Enter or Space renames it)
|
|
3581
4457
|
document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"]')) { e.preventDefault(); t.click() } })
|
|
3582
4458
|
// 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
|
|
@@ -3726,6 +4602,18 @@ document.addEventListener('click', async (ev) => {
|
|
|
3726
4602
|
} catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
|
|
3727
4603
|
return
|
|
3728
4604
|
}
|
|
4605
|
+
if (action === 'restore-ask-confirm' || action === 'restore-ask-decline') {
|
|
4606
|
+
const id = btn.dataset.id
|
|
4607
|
+
if (!id || restoreUi.busy.has(id)) return
|
|
4608
|
+
restoreUi.busy.add(id); render()
|
|
4609
|
+
try {
|
|
4610
|
+
const r = await api('POST', '/v1/restore/asks/' + encodeURIComponent(id) + '/' + (action === 'restore-ask-confirm' ? 'confirm' : 'decline'), {}, { timeoutMs: 60000 })
|
|
4611
|
+
toast(r.state === 'confirmed' ? 'Restored ' + ((r.result && r.result.opened) || 0) + ' in Ghostty' : r.state === 'declined' ? 'Declined' : 'That request was ' + r.state)
|
|
4612
|
+
} catch (e) { toast(e.message, 5000) }
|
|
4613
|
+
restoreUi.busy.delete(id)
|
|
4614
|
+
try { state.restore = await api('GET', '/v1/restore') } catch {}
|
|
4615
|
+
return render()
|
|
4616
|
+
}
|
|
3729
4617
|
if (action === 'restore-open') { restoreUi.open = true; restoreUi.result = null; return render() }
|
|
3730
4618
|
if (action === 'restore-close') { restoreUi.open = false; return render() }
|
|
3731
4619
|
if (action === 'restore-dismiss') { try { localStorage.setItem(restoreDismissKey(state.restore || {}), '1') } catch {} restoreUi.open = false; return render() }
|
|
@@ -3736,6 +4624,18 @@ document.addEventListener('click', async (ev) => {
|
|
|
3736
4624
|
if (!ids.length) { toast('Pick at least one'); return }
|
|
3737
4625
|
const gate = restoreGate()
|
|
3738
4626
|
if (!gate.ok) { toast(gate.hint.replace(/`/g, ''), 6000); return }
|
|
4627
|
+
// 0.9.8: from a phone or another Mac, ask that Mac; a person there confirms
|
|
4628
|
+
if (gate.how === 'ask') {
|
|
4629
|
+
if (restoreUi.ask && ['pending', 'confirming'].includes(restoreUi.ask.state)) return
|
|
4630
|
+
const machine = (state.restore && state.restore.machine) || null
|
|
4631
|
+
btn.setAttribute('aria-disabled', 'true'); btn.textContent = 'Asking ' + (machine || 'the Mac') + '…'
|
|
4632
|
+
try {
|
|
4633
|
+
const r = await api('POST', '/v1/restore/asks', { machine, ids })
|
|
4634
|
+
restoreUi.ask = { ...r, machine: machine || r.machine }
|
|
4635
|
+
setTimeout(pollRestoreAsk, 2000)
|
|
4636
|
+
} catch (e) { restoreUi.ask = { state: 'error', machine, error: e.message } }
|
|
4637
|
+
return render()
|
|
4638
|
+
}
|
|
3739
4639
|
btn.disabled = true; btn.textContent = 'Opening ' + ids.length + '…'
|
|
3740
4640
|
const r = await api('POST', '/v1/restore', { ids }, { timeoutMs: 60000 })
|
|
3741
4641
|
restoreUi.result = r.how === 'print' || r.failed ? r : null
|
|
@@ -3885,6 +4785,9 @@ document.addEventListener('click', async (ev) => {
|
|
|
3885
4785
|
}
|
|
3886
4786
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
3887
4787
|
if (action === 'reply-send') return replyClick(btn)
|
|
4788
|
+
if (action === 'live-send') return liveSendClick(btn)
|
|
4789
|
+
if (action === 'live-jump') return liveJumpClick(btn)
|
|
4790
|
+
if (action === 'live-earlier') return liveEarlierClick(btn)
|
|
3888
4791
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
3889
4792
|
if (action === 'need-more') return needMoreClick(btn)
|
|
3890
4793
|
if (action === 'need-dismiss') return needDismissClick(btn)
|
|
@@ -4012,7 +4915,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
4012
4915
|
// Answer a permission prompt or a question from the board. The daemon hands it
|
|
4013
4916
|
// to the waiting hook or mod; the terminal's own prompt may already have won.
|
|
4014
4917
|
async function answerClick(btn, action) {
|
|
4015
|
-
const need = btn.closest('.need')
|
|
4918
|
+
const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
|
|
4016
4919
|
if (!need) return
|
|
4017
4920
|
const askId = need.dataset.ask, key = need.dataset.key
|
|
4018
4921
|
const s = state.sessions.find((x) => x.id === need.dataset.id)
|
|
@@ -4046,6 +4949,7 @@ async function answerClick(btn, action) {
|
|
|
4046
4949
|
toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
|
|
4047
4950
|
} finally {
|
|
4048
4951
|
delete state.pendingAsk[key]
|
|
4952
|
+
if (need.classList.contains('ql-item')) { await loadQuickLook(); refreshHealth() }
|
|
4049
4953
|
render()
|
|
4050
4954
|
}
|
|
4051
4955
|
}
|
|
@@ -4361,7 +5265,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
4361
5265
|
|
|
4362
5266
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
4363
5267
|
let pending = null
|
|
4364
|
-
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))
|
|
5268
|
+
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value)
|
|
4365
5269
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
4366
5270
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
4367
5271
|
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) }
|
|
@@ -4369,16 +5273,21 @@ let stream = null
|
|
|
4369
5273
|
function connect() {
|
|
4370
5274
|
if (stream) { try { stream.close() } catch {} }
|
|
4371
5275
|
const es = stream = new EventSource('/v1/stream')
|
|
5276
|
+
const again = connect.opened // a reconnect: events may have been missed while it was down
|
|
5277
|
+
streamDog.opened()
|
|
5278
|
+
// 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
|
|
5279
|
+
es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
|
|
4372
5280
|
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
4373
5281
|
es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
|
|
4374
|
-
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'projects.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, soon)
|
|
5282
|
+
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'projects.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, () => { if (stream === es) streamDog.seen(); soon() })
|
|
4375
5283
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
4376
5284
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
4377
5285
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
|
4378
5286
|
// A daemon that restarted (an upgrade, a folder move) comes back on the same
|
|
4379
5287
|
// port; if the browser gave up on the stream, open a new one.
|
|
4380
5288
|
es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
|
|
4381
|
-
|
|
5289
|
+
let opens = 0
|
|
5290
|
+
es.onopen = () => { if (stream !== es) return; streamDog.seen(); refreshHealth(); if (again || opens++) refreshView('reconnect'); connect.opened = true }
|
|
4382
5291
|
}
|
|
4383
5292
|
|
|
4384
5293
|
document.addEventListener('change', async (ev) => {
|
|
@@ -4487,3 +5396,6 @@ connect()
|
|
|
4487
5396
|
// The home-screen app's service worker (v0.4.6): it only serves an offline page
|
|
4488
5397
|
// when the Mac can't be reached; it caches no API data and no board pages.
|
|
4489
5398
|
if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
|
|
5399
|
+
// 0.9.8: phone notifications. The one per-device button (More on a phone,
|
|
5400
|
+
// Settings → General everywhere); the service worker shows the pushes.
|
|
5401
|
+
mountPush({ api })
|