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