@leftoff/cli 0.9.8-beta.2 → 0.9.8-beta.21
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 +43 -2
- package/bin/leftoff.js +423 -392
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +24 -4
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +492 -22
- package/ui/app.js +1200 -93
- package/ui/attach.js +211 -0
- package/ui/composer.js +46 -0
- package/ui/index.html +4 -0
- package/ui/lightbox.js +452 -0
- package/ui/live-status.js +17 -9
- package/ui/live-view.js +229 -12
- package/ui/live-wake.js +96 -0
- package/ui/navhide.js +35 -0
- package/ui/pdfview.js +510 -0
- package/ui/prs.js +10 -5
- package/ui/push.js +140 -0
- package/ui/relay-client.js +70 -5
- package/ui/sw.js +52 -5
- package/ui/tabs.js +216 -0
- package/ui/tour.css +63 -0
- package/ui/tour.js +364 -0
- package/ui/update-bar.js +158 -0
- package/ui/vendor/PDFJS-LICENSE.txt +179 -0
- package/ui/vendor/pdfjs/pdf.min.js +27 -0
- package/ui/vendor/pdfjs/pdf.worker.min.js +27 -0
package/ui/app.js
CHANGED
|
@@ -5,13 +5,20 @@ import { icon } from './icons.js'
|
|
|
5
5
|
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet } from './picker.js'
|
|
6
6
|
import { SETTINGS, settingsKeyFor } from './settings-nav.js'
|
|
7
7
|
import { mountWizard, shouldAutoOpen } from './wizard.js'
|
|
8
|
+
import { shouldAutoTour, startTour } from './tour.js' // 0.9.8 the product tour
|
|
8
9
|
import { changedKeys } from './settings-dirty.js'
|
|
9
10
|
import { looksMd, stripMd, plainCp } from './cp-text.js'
|
|
10
11
|
import { beginWriting, settleWriting, reconcileWriting, dismissWriting, isWriting, writingText, WRITING_SYNC_MS, FRESH_MS } from './writing.js'
|
|
11
12
|
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches, saveProject, inProject, projectChips, projectLabel } from './repos.js'
|
|
12
13
|
import { navKey, moreActive, badgeText, quickLookItems, quickLookHtml } from './nav.js'
|
|
13
14
|
import { canStartPull, pullStep, pullRelease } from './pull.js'
|
|
15
|
+
import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
16
|
+
import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
|
|
17
|
+
import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
|
|
18
|
+
import { isBlank, composerKey, isTouchUi, composerHeight } from './composer.js' // 0.9.8 the Live composer
|
|
19
|
+
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
14
20
|
import { wireTips } from './tips.js'
|
|
21
|
+
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
15
22
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
16
23
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
17
24
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
@@ -19,11 +26,20 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
|
|
|
19
26
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
20
27
|
import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
|
|
21
28
|
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
22
|
-
import { sessionTitle, cardPills, foldsAsEmpty,
|
|
29
|
+
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
|
|
23
30
|
import { statusView, elapsedText } from './live-status.js'
|
|
31
|
+
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
|
|
32
|
+
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
33
|
+
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
|
|
24
34
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
25
35
|
const $app = document.getElementById('app')
|
|
36
|
+
// 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
|
|
37
|
+
if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
|
|
26
38
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
39
|
+
// 0.9.8: drafts the update bar's Refresh carried over (ui/update-bar.js)
|
|
40
|
+
const carried = takeCarry()
|
|
41
|
+
if (carried) Object.assign(state.drafts, carried.drafts)
|
|
42
|
+
let updateBar = null
|
|
27
43
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
28
44
|
const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
|
|
29
45
|
|
|
@@ -113,12 +129,14 @@ async function copy(text) {
|
|
|
113
129
|
// 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
|
|
114
130
|
function copiedMark(btn) {
|
|
115
131
|
clearTimeout(btn._copied)
|
|
132
|
+
const tip = btn._tip || (btn._tip = btn.title || 'Copy resume command')
|
|
116
133
|
btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.title = 'Copied'
|
|
117
|
-
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title =
|
|
134
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = tip }, 1500)
|
|
118
135
|
}
|
|
119
136
|
const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
|
|
120
137
|
// 0.9.8 Live link: the session's channel is listening (send it prompts from here)
|
|
121
|
-
|
|
138
|
+
// It pulses while Claude is running a turn (liveWorking), steady when idle; lockPulses keeps redraws from restarting it.
|
|
139
|
+
const liveBadge = (s) => liveBadgeHtml(s)
|
|
122
140
|
// The status pill beside it: a live session that is only waiting shows just Live (never "dormant").
|
|
123
141
|
const statusPill = (s) => { const st = cardPills(s).status; return st ? pill(st) : '' }
|
|
124
142
|
|
|
@@ -184,11 +202,11 @@ function wtChips(s) {
|
|
|
184
202
|
return h
|
|
185
203
|
}
|
|
186
204
|
|
|
187
|
-
function where(s) {
|
|
205
|
+
function where(s, { repo = true } = {}) {
|
|
188
206
|
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)
|
|
189
207
|
const branch = s.repo && s.repo.branch
|
|
190
208
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
191
|
-
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) +
|
|
209
|
+
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) +
|
|
192
210
|
(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)
|
|
193
211
|
}
|
|
194
212
|
|
|
@@ -387,9 +405,13 @@ function liveNote(s) {
|
|
|
387
405
|
if (s.live === 'open' && s.liveVia === 'heartbeat' && !s.isLocal) return 'open (heartbeat)'
|
|
388
406
|
return s.live === 'open' ? 'open' : ''
|
|
389
407
|
}
|
|
390
|
-
|
|
408
|
+
// 0.9.8: { page: true } is the session page's header, where a Go that can't run
|
|
409
|
+
// is left out (its tooltip was the only place the reason showed, and the grey
|
|
410
|
+
// box read as broken); a card keeps the dimmed Go and its short why.
|
|
411
|
+
function goButton(s, { page = false } = {}) {
|
|
391
412
|
if (s.live === 'closed') return ''
|
|
392
413
|
const j = s.jump || {}
|
|
414
|
+
if (page && (state.viewerLocal === false || !j.supported)) return ''
|
|
393
415
|
// Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
|
|
394
416
|
if (!s.isLocal) {
|
|
395
417
|
if (!j.peer) return ''
|
|
@@ -439,8 +461,12 @@ function agentCard(s) {
|
|
|
439
461
|
// closed), a small copy icon for the resume command, and "⋯" for the rest. The
|
|
440
462
|
// ⋯ menu's items ride in the card as an inert <template> (real buttons with the
|
|
441
463
|
// same data-action as before), so the one click handler below runs them unchanged.
|
|
464
|
+
// What Resume runs: codex resume, claude --resume, or for a Live session (0.9.8)
|
|
465
|
+
// leftoff live -- --resume, so it comes back Live.
|
|
466
|
+
function resumeVerb(s) { return s.codingAgent === 'codex' ? 'codex resume' : s.resumeLive ? 'leftoff live -- --resume' : 'claude --resume' }
|
|
467
|
+
function resumeTip(s, lead) { return lead + ': ' + resumeVerb(s) + (s.resumeLive ? ' (it comes back Live)' : '') }
|
|
442
468
|
function copyResumeIcon(s) {
|
|
443
|
-
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>'
|
|
469
|
+
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>'
|
|
444
470
|
}
|
|
445
471
|
// items: [{ action, label, icon, title, attrs }]
|
|
446
472
|
// 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
|
|
@@ -557,7 +583,7 @@ function checkpointSplit(s) {
|
|
|
557
583
|
'<button type="button" class="btn primary split-more" disabled aria-disabled="true" aria-label="Checkpoint length">' + icon('chevron-down') + '</button></span>'
|
|
558
584
|
}
|
|
559
585
|
return '<span class="split" role="group" aria-label="Checkpoint now">' +
|
|
560
|
-
'<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)) + '"
|
|
586
|
+
'<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>' +
|
|
561
587
|
'<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>'
|
|
562
588
|
}
|
|
563
589
|
function openLengthMenu(btn, id) {
|
|
@@ -581,10 +607,10 @@ function card(s) {
|
|
|
581
607
|
const nb = s.needsYou ? cardBadge(s.needsYou) : ''
|
|
582
608
|
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)) + '">' +
|
|
583
609
|
'<div class="row1"><div class="title">' + liveDot(s) +
|
|
584
|
-
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' +
|
|
610
|
+
(s.pinned ? '<span class="pin">★ </span>' : '') + '<a href="#/s/' + encodeURIComponent(s.id) + '">' + titleHtml(sessionTitle(s), esc) + '</a>' +
|
|
585
611
|
'<button type="button" class="icon" data-action="rename" title="Rename">✎</button>' +
|
|
586
612
|
'</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>' +
|
|
613
|
+
'<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>' +
|
|
588
614
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
589
615
|
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpArea(s, cpBlock(s.latestCheckpoint)) +
|
|
590
616
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
@@ -808,11 +834,13 @@ async function viewBoard() {
|
|
|
808
834
|
html += projectRow(spaced, pj, 'board-project')
|
|
809
835
|
if (filter === 'closed') {
|
|
810
836
|
html += await closedSection(f, sp)
|
|
837
|
+
if (parseRoute().name === 's') return // 0.9.8: you opened a session while the board loaded
|
|
811
838
|
$app.innerHTML = banners() + html
|
|
812
839
|
return closedWire()
|
|
813
840
|
}
|
|
814
841
|
html += repoRow(inSpace, f)
|
|
815
842
|
html += boardSearch(bq, closedHits, list.length)
|
|
843
|
+
if ((state.boardQ || '').trim() !== bq) return // you typed on while this one loaded: the newer search paints
|
|
816
844
|
if (bq) {
|
|
817
845
|
if (list.length) html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
|
|
818
846
|
paintBoardKeepingSearch(banners() + html)
|
|
@@ -846,38 +874,63 @@ function boardSearch(q, closedHits, n) {
|
|
|
846
874
|
(closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') +
|
|
847
875
|
' <a class="linkish tx-board-link" href="#/search?q=' + encodeURIComponent(q) + '">' + icon('message-square') + 'Search what was said →</a></div>' : '') + '</div>'
|
|
848
876
|
}
|
|
849
|
-
// A repaint from a live update (or the 30 s refresh
|
|
850
|
-
// search
|
|
877
|
+
// A repaint from a live update (or the 30 s refresh, or your own typing's
|
|
878
|
+
// search) never rebuilds the search box: everything around it is replaced and
|
|
879
|
+
// the box itself stays the same element, so the caret, a selection, a word
|
|
880
|
+
// being composed and the focus are exactly as you left them. (Rebuilding it
|
|
881
|
+
// and putting the caret back moved the caret while you typed: the typing's
|
|
882
|
+
// own search put back where the caret was when it started, after you'd typed on.)
|
|
851
883
|
function paintBoardKeepingSearch(h) {
|
|
884
|
+
if (parseRoute().name === 's') return // 0.9.8: a slow board answer never paints over the session you opened
|
|
852
885
|
const old = document.getElementById('boardQ')
|
|
853
|
-
const
|
|
886
|
+
const row = old && old.closest('.board-search-row')
|
|
887
|
+
if (row && row.parentNode === $app) {
|
|
888
|
+
const tpl = document.createElement('template')
|
|
889
|
+
tpl.innerHTML = h
|
|
890
|
+
const nrow = tpl.content.querySelector('.board-search-row')
|
|
891
|
+
const nbox = nrow && nrow.querySelector('#boardQ')
|
|
892
|
+
if (nbox && nrow.parentNode === tpl.content) {
|
|
893
|
+
const kids = [...tpl.content.childNodes], at = kids.indexOf(nrow)
|
|
894
|
+
while ($app.firstChild && $app.firstChild !== row) $app.removeChild($app.firstChild)
|
|
895
|
+
while (row.nextSibling) $app.removeChild(row.nextSibling)
|
|
896
|
+
row.before(...kids.slice(0, at))
|
|
897
|
+
row.after(...kids.slice(at + 1))
|
|
898
|
+
// the note under the box (n matches, closed matches) follows the new answer
|
|
899
|
+
for (const n of [...row.childNodes]) if (!n.contains(old)) row.removeChild(n)
|
|
900
|
+
for (const n of [...nrow.childNodes]) if (!n.contains(nbox)) row.appendChild(n)
|
|
901
|
+
// the words change only when you aren't typing (the address changed, Esc cleared it)
|
|
902
|
+
if (document.activeElement !== old && !boardQTimer && old.value !== nbox.value) old.value = nbox.value
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
const keep = old && document.activeElement === old ? { v: old.value, a: old.selectionStart, b: old.selectionEnd, d: old.selectionDirection } : null
|
|
854
907
|
$app.innerHTML = h
|
|
855
908
|
if (!keep) return
|
|
856
909
|
const q = document.getElementById('boardQ')
|
|
857
910
|
if (!q) return
|
|
858
911
|
if (q.value !== keep.v) q.value = keep.v
|
|
859
|
-
q.focus(); try { q.setSelectionRange(keep.
|
|
912
|
+
q.focus({ preventScroll: true }); try { q.setSelectionRange(keep.a, keep.b, keep.d || 'none') } catch {}
|
|
860
913
|
}
|
|
861
914
|
let boardQTimer = null
|
|
862
915
|
function boardSearchWire() {
|
|
863
916
|
const q = document.getElementById('boardQ')
|
|
864
|
-
if (!q) return
|
|
917
|
+
if (!q || q.dataset.wired) return // the same box across repaints: wired once
|
|
918
|
+
q.dataset.wired = '1'
|
|
865
919
|
q.addEventListener('input', () => {
|
|
866
920
|
clearTimeout(boardQTimer)
|
|
867
921
|
boardQTimer = setTimeout(async () => {
|
|
868
|
-
|
|
922
|
+
boardQTimer = null
|
|
923
|
+
if (!q.isConnected) return
|
|
869
924
|
state.boardQ = q.value
|
|
870
925
|
try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
|
|
871
926
|
state.route = parseRoute()
|
|
872
|
-
await viewBoard()
|
|
873
|
-
const again = document.getElementById('boardQ')
|
|
874
|
-
if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
|
|
927
|
+
await viewBoard() // the box isn't rebuilt, so the caret stays wherever you've typed to since
|
|
875
928
|
}, 160)
|
|
876
929
|
})
|
|
877
930
|
q.addEventListener('keydown', (ev) => {
|
|
878
931
|
if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) }
|
|
879
932
|
// 0.9.6: Enter (Search on the phone's keyboard) opens the full results, transcripts included
|
|
880
|
-
else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
|
|
933
|
+
else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); boardQTimer = null; location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
|
|
881
934
|
})
|
|
882
935
|
}
|
|
883
936
|
|
|
@@ -928,10 +981,10 @@ function resumeButton(s, viewer = {}) {
|
|
|
928
981
|
const busy = closedUi.busy.has(s.id)
|
|
929
982
|
if (s.isLocal) {
|
|
930
983
|
if (!viewer.ghostty) return ''
|
|
931
|
-
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="
|
|
984
|
+
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>'
|
|
932
985
|
}
|
|
933
986
|
if (!(s.peer && s.peer.ok)) return '' // that Mac can't be reached from here: Copy resume
|
|
934
|
-
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>'
|
|
987
|
+
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>'
|
|
935
988
|
}
|
|
936
989
|
function closedCard(s, viewer) {
|
|
937
990
|
const href = '#/s/' + encodeURIComponent(s.id)
|
|
@@ -1248,8 +1301,8 @@ async function viewAgentSession(s) {
|
|
|
1248
1301
|
const name = esc(a.display || a.name)
|
|
1249
1302
|
const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
|
|
1250
1303
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1251
|
-
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>' +
|
|
1252
|
-
'<div class="actions">' + agentReplyButton(a) + '</div></div>'
|
|
1304
|
+
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>' +
|
|
1305
|
+
'<div class="actions s-actions">' + agentReplyButton(a) + '</div></div>'
|
|
1253
1306
|
// v0.5.6: agent.url only links the name; the reply affordance and its
|
|
1254
1307
|
// words come from the feed (agent.reply, agent.replyHint), or nothing.
|
|
1255
1308
|
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>'
|
|
@@ -1270,42 +1323,133 @@ async function viewAgentSession(s) {
|
|
|
1270
1323
|
$app.innerHTML = banners() + html
|
|
1271
1324
|
}
|
|
1272
1325
|
|
|
1326
|
+
// 0.9.8: another Mac's session whose Mac is offline says so plainly, once, near the top
|
|
1327
|
+
function ownerOffline(s) {
|
|
1328
|
+
if (!s || s.isLocal !== false || s.agent || !s.machine) return false
|
|
1329
|
+
const st = window.leftoffRelay && state.relayStatus
|
|
1330
|
+
const x = st && (st.macs || []).find((y) => y.machine === s.machine)
|
|
1331
|
+
if (x) return !x.online
|
|
1332
|
+
return !!(s.peer && s.peer.code === 'unreachable')
|
|
1333
|
+
}
|
|
1334
|
+
function ownerOfflineNote(s) {
|
|
1335
|
+
if (!ownerOffline(s)) return ''
|
|
1336
|
+
const m = esc(s.machine), sn = staleNote(s.machine)
|
|
1337
|
+
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>'
|
|
1338
|
+
}
|
|
1339
|
+
// 0.9.8: opening a session is instant. The tap paints the page's shell at
|
|
1340
|
+
// once (title and pills from the card we already have, or the whole page from
|
|
1341
|
+
// the last visit), then every fetch runs in parallel (the session, its Live
|
|
1342
|
+
// thread, spaces, projects, lists) instead of one after another; on a phone
|
|
1343
|
+
// through the relay each was a full round trip. A newer navigation wins: a
|
|
1344
|
+
// slow answer for a page you already left never paints over the one you're on.
|
|
1345
|
+
const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few sessions you opened
|
|
1346
|
+
const SESSION_CACHE_MAX = 8
|
|
1347
|
+
function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
|
|
1348
|
+
const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
|
|
1349
|
+
function sessionShellHtml(s) {
|
|
1350
|
+
const head = s
|
|
1351
|
+
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + statusPill(s) + liveBadge(s) + '</div>'
|
|
1352
|
+
: '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
|
|
1353
|
+
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>' +
|
|
1354
|
+
'<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>' +
|
|
1355
|
+
'<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>'
|
|
1356
|
+
}
|
|
1357
|
+
// A selector that finds the same control again after a redraw (null: it can't be told apart).
|
|
1358
|
+
function focusKey(el) {
|
|
1359
|
+
if (!el || !el.tagName) return null
|
|
1360
|
+
if (el.id) return '#' + CSS.escape(el.id)
|
|
1361
|
+
const d = el.dataset || {}
|
|
1362
|
+
const keys = ['action', 'lid', 'sid', 'att', 'id', 'tab', 'submitFor'].filter((k) => d[k] != null)
|
|
1363
|
+
if (!keys.length) return null
|
|
1364
|
+
const attr = (k) => '[data-' + k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + '="' + CSS.escape(d[k]) + '"]'
|
|
1365
|
+
return el.tagName.toLowerCase() + keys.map(attr).join('')
|
|
1366
|
+
}
|
|
1367
|
+
function paintSession(s, data) {
|
|
1368
|
+
if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
|
|
1369
|
+
// a redraw (the live poll, a settled reply) keeps the composer focused with its caret where it was
|
|
1370
|
+
const fa = document.activeElement
|
|
1371
|
+
const kf = fa && fa.classList && fa.classList.contains('composer-input') && $app.contains(fa) ? { d: fa.dataset.draft, a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
1372
|
+
// …and any other control that had focus (a chip, a thumbnail, a button: a
|
|
1373
|
+
// preview closed with Esc puts focus back on one, then a redraw replaced it)
|
|
1374
|
+
const kb = !kf && fa && fa !== document.body && $app.contains(fa) ? focusKey(fa) : null
|
|
1375
|
+
liveKeeper.before($app)
|
|
1376
|
+
$app.innerHTML = sessionPageHtml(s, data)
|
|
1377
|
+
liveAfterRender(s.id)
|
|
1378
|
+
if (kf) {
|
|
1379
|
+
const nt = $app.querySelector('.composer-input')
|
|
1380
|
+
if (nt && nt.dataset.draft === kf.d && !nt.disabled) { nt.focus({ preventScroll: true }); try { const n = nt.value.length; nt.setSelectionRange(Math.min(kf.a, n), Math.min(kf.b, n)) } catch {} }
|
|
1381
|
+
} else if (kb && !$app.contains(document.activeElement)) {
|
|
1382
|
+
const hits = $app.querySelectorAll(kb)
|
|
1383
|
+
if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true })
|
|
1384
|
+
}
|
|
1385
|
+
aroundWire(s.id)
|
|
1386
|
+
}
|
|
1273
1387
|
async function viewSession(id) {
|
|
1274
1388
|
if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
|
|
1275
|
-
const
|
|
1389
|
+
const showing = $app.querySelector('.s-head[data-sid="' + CSS.escape(id) + '"]') // a re-render of the page you're on: no shell
|
|
1390
|
+
const cached = sessionCache.get(id)
|
|
1391
|
+
const known = (cached && cached.s) || state.sessions.find((x) => x.id === id) || null
|
|
1392
|
+
if (!showing) {
|
|
1393
|
+
if (cached) paintSession(cached.s, cached) // the last visit, at once; fresh data replaces it below
|
|
1394
|
+
else { liveKeeper.before($app); $app.innerHTML = sessionShellHtml(known) }
|
|
1395
|
+
}
|
|
1396
|
+
// on leftoff.dev, another Mac's session loads its data from that Mac: say so before the first call
|
|
1397
|
+
const owner = (x) => { if (x && window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(x.id, x.isLocal === false && !x.agent ? x.machine : null) }
|
|
1398
|
+
owner(known)
|
|
1399
|
+
const pS = api('GET', '/v1/sessions/' + encodeURIComponent(id))
|
|
1400
|
+
const pLive = !known || liveWanted(known) ? fetchLive(id) : null // the thread doesn't wait for the session
|
|
1401
|
+
const pLists = known && known.lists && known.lists.length ? fetchLists(id) : null
|
|
1402
|
+
const pRest = Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
|
|
1403
|
+
let s
|
|
1404
|
+
try { s = await pS } catch (e) { if (!onSession(id)) return; throw e }
|
|
1276
1405
|
state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
|
|
1406
|
+
owner(s)
|
|
1407
|
+
const status = s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null
|
|
1408
|
+
const lists = s.lists && s.lists.length ? (pLists || fetchLists(id)) : null
|
|
1409
|
+
const live = liveWanted(s) ? (pLive || fetchLive(id)) : !isLive(s) && !s.endedAt && s.live !== 'closed' ? liveHow().then(() => null) : null
|
|
1410
|
+
let [[spv], v, sl] = await Promise.all([pRest, live, lists, status])
|
|
1411
|
+
if (!onSession(id)) return // you moved on while this loaded
|
|
1412
|
+
// a Live fetch that failed keeps the thread you had (its bubbles say "Reconnecting…"), not an empty one
|
|
1413
|
+
const prev = sessionCache.get(id)
|
|
1414
|
+
if (v && v.error && prev && prev.live && !prev.live.error) v = { ...prev.live, error: null }
|
|
1415
|
+
const data = { spv, live: v, lists: sl }
|
|
1416
|
+
if (v && !v.error) cacheSession(id, { s, ...data, live: v })
|
|
1417
|
+
paintSession(s, data)
|
|
1418
|
+
}
|
|
1419
|
+
const fetchLists = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists').catch(() => null)
|
|
1420
|
+
function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {}) {
|
|
1277
1421
|
const branch = s.repo && s.repo.branch
|
|
1278
|
-
const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
|
|
1279
1422
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1280
1423
|
// 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)
|
|
1281
1424
|
const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
|
|
1282
|
-
html += '<div class="pagehead"><div>' + (canRename
|
|
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">' +
|
|
1284
|
-
: '<h1>' +
|
|
1285
|
-
|
|
1425
|
+
html += '<div class="pagehead s-head" data-sid="' + esc(s.id) + '"><div class="s-head-main">' + (canRename
|
|
1426
|
+
? '<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>'
|
|
1427
|
+
: '<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>' +
|
|
1428
|
+
// 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
|
|
1429
|
+
'<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>' + (s.liveResumeCommand ? '<button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(s.liveResumeCommand) + '" title="Exit the session in its terminal, then run this to bring it back Live">' + icon('copy') + 'Copy Live 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>'
|
|
1430
|
+
html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
|
|
1286
1431
|
html += '<div class="facts">' +
|
|
1287
1432
|
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)) +
|
|
1288
1433
|
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) +
|
|
1289
1434
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
1290
1435
|
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) : '') +
|
|
1436
|
+
if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
|
|
1292
1437
|
html += parkRow(s)
|
|
1293
1438
|
html += spacePicker(s, spv, 'session', s.id)
|
|
1294
|
-
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
1439
|
+
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
|
|
1295
1440
|
// 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
|
|
1296
1441
|
html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
|
|
1297
1442
|
html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
|
|
1298
1443
|
// 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
|
|
1299
1444
|
if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
|
|
1300
|
-
if (!isLive(s)) html +=
|
|
1445
|
+
if (!isLive(s)) html += liveBoxHtml(s, liveV)
|
|
1301
1446
|
if (s.statusChanges && s.statusChanges.length) {
|
|
1302
1447
|
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>'
|
|
1303
1448
|
}
|
|
1304
1449
|
// Lists (v0.5.1): its saved lists in full, the hint, and Find lists in this session
|
|
1305
1450
|
if (s.lists && s.lists.length) {
|
|
1306
|
-
const sl = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists').catch(() => null)
|
|
1307
1451
|
state.listFull = {}
|
|
1308
|
-
for (const l of (
|
|
1452
|
+
for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l
|
|
1309
1453
|
}
|
|
1310
1454
|
state.listsMayEdit = state.mayAnswer !== false
|
|
1311
1455
|
html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
|
|
@@ -1323,10 +1467,7 @@ async function viewSession(id) {
|
|
|
1323
1467
|
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
1324
1468
|
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>'
|
|
1325
1469
|
html += answeredList(s)
|
|
1326
|
-
|
|
1327
|
-
$app.innerHTML = html
|
|
1328
|
-
liveAfterRender(s.id)
|
|
1329
|
-
aroundWire(s.id)
|
|
1470
|
+
return html
|
|
1330
1471
|
}
|
|
1331
1472
|
// ------------------------------------------------------------ Live link (0.9.8 beta)
|
|
1332
1473
|
|
|
@@ -1349,17 +1490,28 @@ async function liveHow() {
|
|
|
1349
1490
|
if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
|
|
1350
1491
|
return state.liveHow
|
|
1351
1492
|
}
|
|
1352
|
-
|
|
1493
|
+
// Does this session's page show a Live thread (and so fetch one)?
|
|
1494
|
+
const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
|
|
1495
|
+
// GET the Live thread; what it says is the newest the page knows (0.9.8: the
|
|
1496
|
+
// time of the last good answer decides "Reconnecting…" on a pending bubble).
|
|
1497
|
+
async function fetchLive(id, { timeoutMs } = {}) {
|
|
1498
|
+
const turns = (state.liveTurns && state.liveTurns[id]) || 20 // "Show earlier" adds 20 at a time
|
|
1499
|
+
try {
|
|
1500
|
+
const v = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/live' + (turns > 20 ? '?turns=' + turns : ''), null, timeoutMs ? { timeoutMs } : {})
|
|
1501
|
+
noteLive(id, v)
|
|
1502
|
+
return v
|
|
1503
|
+
} catch (e) { return { messages: [], error: e.message } }
|
|
1504
|
+
}
|
|
1505
|
+
function liveBoxHtml(s, v) {
|
|
1353
1506
|
if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
|
|
1354
1507
|
if (!isLive(s)) {
|
|
1355
1508
|
if (s.endedAt || s.live === 'closed') return ''
|
|
1356
|
-
const how =
|
|
1509
|
+
const how = state.liveHow
|
|
1357
1510
|
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
1511
|
'<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
|
|
1512
|
+
(how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p>' +
|
|
1513
|
+
(s.liveResumeCommand ? '<p class="faint">Already running? Exit it in its terminal, then paste this to bring it back Live:</p><div class="live-resume-row"><code class="mono">' + esc(s.liveResumeCommand) + '</code><button type="button" class="copy-float" data-action="copy-cmd" data-cmd="' + esc(s.liveResumeCommand) + '" aria-label="Copy Live resume" data-tip="Copy">' + icon('copy') + '</button></div>' : '') + '</section>'
|
|
1360
1514
|
}
|
|
1361
|
-
let v = null
|
|
1362
|
-
try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
|
|
1363
1515
|
const msgs = (v && v.messages) || []
|
|
1364
1516
|
const may = state.mayAnswer !== false
|
|
1365
1517
|
const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
|
|
@@ -1368,17 +1520,343 @@ async function liveSection(s) {
|
|
|
1368
1520
|
if (lastClaude) markLiveSeen(s.id, lastClaude.at)
|
|
1369
1521
|
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
1522
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1371
|
-
|
|
1523
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
1372
1524
|
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
|
|
1373
1525
|
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
|
|
1374
1526
|
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
1375
|
-
|
|
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>' +
|
|
1527
|
+
composerHtml(s, draft, may) +
|
|
1377
1528
|
(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
1529
|
}
|
|
1530
|
+
// The Live composer: one rounded box, a textarea that starts at one line and
|
|
1531
|
+
// grows, and a round ↑ in its corner (dim until there's something to send).
|
|
1532
|
+
// No interrupt reaches Claude Code from the board, so while it works the
|
|
1533
|
+
// button still sends: the message waits for its next turn, as before.
|
|
1534
|
+
const SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
|
|
1535
|
+
const CLIP_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
|
|
1536
|
+
const FILE_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>'
|
|
1537
|
+
function composerHtml(s, draft, may) {
|
|
1538
|
+
const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
|
|
1539
|
+
const atts = attachList(s.id)
|
|
1540
|
+
const off = !may || (isBlank(draft) && !atts.length)
|
|
1541
|
+
return '<div class="composer live-row' + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '" data-sid="' + esc(s.id) + '">' +
|
|
1542
|
+
'<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
|
|
1543
|
+
'<textarea class="composer-input live-text" rows="1" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" autocomplete="off" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd) + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
|
|
1544
|
+
// the phone's own picker (Photo Library, Take Photo, Choose File): any file
|
|
1545
|
+
'<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
|
|
1546
|
+
'<button type="button" class="composer-attach" data-action="attach" data-id="' + esc(s.id) + '" aria-label="Attach" title="Attach photos or files"' + (may ? '' : ' disabled') + '><span class="composer-clip">' + CLIP_IC + '</span></button>' +
|
|
1547
|
+
'<button type="button" class="composer-send" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '" aria-label="Send" title="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
|
|
1551
|
+
// Each file uploads to the Mac that runs the session as soon as you attach it;
|
|
1552
|
+
// the chips live here (not in the DOM), so a redraw of the page keeps them.
|
|
1553
|
+
const attachUi = { by: new Map(), urls: new Map(), blobs: new Map(), loading: new Map(), seq: 0, running: 0, queue: [] }
|
|
1554
|
+
const attachList = (sid) => attachUi.by.get(sid) || []
|
|
1555
|
+
function chipHtml(a) {
|
|
1556
|
+
const pct = a.total ? Math.min(100, Math.round((a.sent / a.total) * 100)) : 0
|
|
1557
|
+
const status = a.status === 'failed' ? 'Didn’t upload' : a.status === 'done' ? fmtSize(a.size) : a.status === 'prep' ? 'Preparing…' : fmtSize(a.size) + ' · ' + pct + '%'
|
|
1558
|
+
return '<div class="att-chip ' + esc(a.status) + '" data-lid="' + esc(a.lid) + '"' + (a.error ? ' title="' + esc(a.error) + '"' : '') + '>' +
|
|
1559
|
+
'<button type="button" class="att-open" data-action="att-chip-open" data-lid="' + esc(a.lid) + '" aria-label="Preview ' + esc(a.name) + '">' +
|
|
1560
|
+
'<span class="att-thumb">' + (a.preview ? '<img src="' + esc(a.preview) + '" alt="">' : FILE_IC) + '</span>' +
|
|
1561
|
+
'<span class="att-meta"><span class="att-name" title="' + esc(a.name) + '">' + esc(shortName(a.name)) + '</span><span class="att-size">' + esc(status) + '</span></span></button>' +
|
|
1562
|
+
(a.status === 'failed' ? '<button type="button" class="att-btn att-retry" data-action="att-retry" data-lid="' + esc(a.lid) + '" aria-label="Retry ' + esc(a.name) + '" title="Retry">↻</button>' : '') +
|
|
1563
|
+
'<button type="button" class="att-btn att-x" data-action="att-remove" data-lid="' + esc(a.lid) + '" aria-label="Remove ' + esc(a.name) + '" title="Remove">×</button>' +
|
|
1564
|
+
(a.status === 'up' || a.status === 'prep' ? '<span class="att-bar" aria-hidden="true"><span data-pct="' + pct + '"></span></span>' : '') + '</div>'
|
|
1565
|
+
}
|
|
1566
|
+
// The board's CSP takes no inline styles: a progress bar's width is set here.
|
|
1567
|
+
function barWidths(root) { for (const b of root.querySelectorAll('.att-bar > span[data-pct]')) b.style.width = b.dataset.pct + '%' }
|
|
1568
|
+
// Redraw one session's chips in place (and the ↑'s state).
|
|
1569
|
+
function paintChips(sid) {
|
|
1570
|
+
const box = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"]')
|
|
1571
|
+
if (!box) return
|
|
1572
|
+
const atts = attachList(sid)
|
|
1573
|
+
const wrap = box.querySelector('.att-chips')
|
|
1574
|
+
const fa = wrap.contains(document.activeElement) ? focusKey(document.activeElement) : null // a progress tick keeps the chip you're on focused
|
|
1575
|
+
wrap.innerHTML = atts.map(chipHtml).join('')
|
|
1576
|
+
if (fa) { const t = wrap.querySelector(fa); if (t) t.focus({ preventScroll: true }) }
|
|
1577
|
+
barWidths(wrap)
|
|
1578
|
+
wrap.hidden = !atts.length
|
|
1579
|
+
box.classList.toggle('has-atts', !!atts.length)
|
|
1580
|
+
const ta = box.querySelector('.composer-input')
|
|
1581
|
+
if (ta) syncComposer(ta)
|
|
1582
|
+
}
|
|
1583
|
+
function addFiles(sid, files, { pasted = false } = {}) {
|
|
1584
|
+
const list = Array.from(files || []).filter(Boolean)
|
|
1585
|
+
if (!sid || !list.length) return
|
|
1586
|
+
const have = attachList(sid)
|
|
1587
|
+
const plan = attachPlan(list, have.length)
|
|
1588
|
+
if (plan.skipped.length) toast('Not attached: ' + skippedText(plan.skipped), 4200)
|
|
1589
|
+
const add = plan.take.map((f) => {
|
|
1590
|
+
const file = pasted && /^image\.\w+$|^$/.test(f.name || '') ? new File([f], pastedName(f.type), { type: f.type, lastModified: Date.now() }) : f
|
|
1591
|
+
return { lid: 'a' + (++attachUi.seq), sid, file, name: file.name || 'file', size: file.size, type: file.type || '', kind: attachKind(file), status: 'prep', sent: 0, total: file.size, id: null, preview: null, thumb: null, error: null }
|
|
1592
|
+
})
|
|
1593
|
+
if (!add.length) return
|
|
1594
|
+
attachUi.by.set(sid, [...have, ...add])
|
|
1595
|
+
paintChips(sid)
|
|
1596
|
+
try { promoteActive() } catch {}
|
|
1597
|
+
for (const a of add) startUpload(a)
|
|
1598
|
+
}
|
|
1599
|
+
function startUpload(a) {
|
|
1600
|
+
a.status = 'prep'; a.error = null; a.sent = 0
|
|
1601
|
+
attachUi.queue.push(a); pumpUploads()
|
|
1602
|
+
}
|
|
1603
|
+
// Three at a time: a phone on a slow line shouldn't open ten uploads at once.
|
|
1604
|
+
function pumpUploads() {
|
|
1605
|
+
while (attachUi.running < 3 && attachUi.queue.length) {
|
|
1606
|
+
const a = attachUi.queue.shift()
|
|
1607
|
+
if (!attachList(a.sid).includes(a)) continue
|
|
1608
|
+
attachUi.running++
|
|
1609
|
+
runUpload(a).finally(() => { attachUi.running--; pumpUploads() })
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
async function runUpload(a) {
|
|
1613
|
+
const gone = () => !attachList(a.sid).includes(a)
|
|
1614
|
+
try {
|
|
1615
|
+
if (!a.prepared) {
|
|
1616
|
+
const p = await prepareFile(a.file)
|
|
1617
|
+
a.prepared = p.file; a.thumb = p.thumb
|
|
1618
|
+
a.name = p.file.name || a.name; a.size = p.file.size; a.total = p.file.size; a.type = p.file.type || a.type
|
|
1619
|
+
if (attachKind(p.file) === 'image' && !a.preview && p.thumb) a.preview = 'data:image/jpeg;base64,' + p.thumb
|
|
1620
|
+
// a PDF's first page, for its chip and its bubble (a few seconds at most; without it, the file icon)
|
|
1621
|
+
if (attachKind(p.file) === 'pdf' && !a.thumb) {
|
|
1622
|
+
const t = await Promise.race([pdfThumb(p.file), new Promise((r) => setTimeout(() => r(null), 5000))]).catch(() => null)
|
|
1623
|
+
if (t) { a.thumb = t; a.preview = 'data:image/jpeg;base64,' + t }
|
|
1624
|
+
}
|
|
1625
|
+
if (p.note === 'heic') a.error = 'This browser can’t convert HEIC; it goes as is'
|
|
1626
|
+
}
|
|
1627
|
+
if (gone()) return
|
|
1628
|
+
a.status = 'up'; paintChips(a.sid)
|
|
1629
|
+
let last = 0
|
|
1630
|
+
const f = await uploadFile({ api, sessionId: a.sid, file: a.prepared, thumb: a.thumb, isCancelled: gone,
|
|
1631
|
+
onProgress: (sent, total) => { a.sent = sent; a.total = total; const t = Date.now(); if (t - last > 120 || sent >= total) { last = t; paintChips(a.sid) } } })
|
|
1632
|
+
a.id = f.id; a.size = f.size; a.kind = f.kind; a.status = 'done'
|
|
1633
|
+
if (a.preview) attachUi.urls.set(a.sid + '/' + f.id, a.preview) // the bubble shows it without asking the Mac
|
|
1634
|
+
} catch (e) {
|
|
1635
|
+
if (e.code === 'removed' || gone()) return
|
|
1636
|
+
a.status = 'failed'; a.error = e.message || 'Upload failed'
|
|
1637
|
+
}
|
|
1638
|
+
paintChips(a.sid)
|
|
1639
|
+
attachWaiters()
|
|
1640
|
+
}
|
|
1641
|
+
let attachWaitList = []
|
|
1642
|
+
function attachWaiters() { const w = attachWaitList; attachWaitList = []; for (const f of w) f() }
|
|
1643
|
+
// Wait until nothing of this session's is still uploading.
|
|
1644
|
+
async function uploadsSettled(sid) {
|
|
1645
|
+
while (attachList(sid).some((a) => a.status === 'prep' || a.status === 'up')) await new Promise((r) => { attachWaitList.push(r); setTimeout(r, 1000) })
|
|
1646
|
+
}
|
|
1647
|
+
function removeAttachment(lid) {
|
|
1648
|
+
for (const [sid, list] of attachUi.by) {
|
|
1649
|
+
const a = list.find((x) => x.lid === lid)
|
|
1650
|
+
if (!a) continue
|
|
1651
|
+
attachUi.by.set(sid, list.filter((x) => x !== a))
|
|
1652
|
+
paintChips(sid)
|
|
1653
|
+
const ta = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"] .composer-input')
|
|
1654
|
+
if (ta && !isTouchUi(window.matchMedia)) ta.focus({ preventScroll: true })
|
|
1655
|
+
return
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
function retryAttachment(lid) {
|
|
1659
|
+
for (const list of attachUi.by.values()) { const a = list.find((x) => x.lid === lid); if (a) { startUpload(a); paintChips(a.sid); return } }
|
|
1660
|
+
}
|
|
1661
|
+
// The composer a drop or a paste is for: the session page's own (enabled).
|
|
1662
|
+
function composerFor(el) {
|
|
1663
|
+
const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
|
|
1664
|
+
return box && !box.classList.contains('off') ? box : null
|
|
1665
|
+
}
|
|
1666
|
+
document.addEventListener('change', (ev) => {
|
|
1667
|
+
const inp = ev.target
|
|
1668
|
+
if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
|
|
1669
|
+
addFiles(inp.dataset.sid, inp.files)
|
|
1670
|
+
inp.value = '' // the same file can be picked again
|
|
1671
|
+
})
|
|
1672
|
+
document.addEventListener('paste', (ev) => {
|
|
1673
|
+
const t = ev.target
|
|
1674
|
+
const inComposer = t && t.classList && t.classList.contains('composer-input')
|
|
1675
|
+
if (!inComposer && t && t.closest && t.closest('input, textarea, [contenteditable]')) return
|
|
1676
|
+
const cd = ev.clipboardData
|
|
1677
|
+
const files = cd ? Array.from(cd.files || []).length ? Array.from(cd.files) : Array.from(cd.items || []).filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter(Boolean) : []
|
|
1678
|
+
if (!files.length) return
|
|
1679
|
+
const box = composerFor(inComposer ? t : null)
|
|
1680
|
+
if (!box) return
|
|
1681
|
+
ev.preventDefault()
|
|
1682
|
+
addFiles(box.dataset.sid, files, { pasted: true })
|
|
1683
|
+
})
|
|
1684
|
+
{
|
|
1685
|
+
// Drag files onto the session page: the box lights up; drop attaches.
|
|
1686
|
+
const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
|
|
1687
|
+
let leaveT = 0
|
|
1688
|
+
const lit = (on) => { const b = document.querySelector('#app .composer[data-sid]'); if (b) b.classList.toggle('att-drop', on) }
|
|
1689
|
+
document.addEventListener('dragover', (e) => { if (!hasFiles(e) || !composerFor(null)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; lit(true); clearTimeout(leaveT); leaveT = setTimeout(() => lit(false), 300) })
|
|
1690
|
+
document.addEventListener('drop', (e) => {
|
|
1691
|
+
if (!hasFiles(e)) return
|
|
1692
|
+
const box = composerFor(null)
|
|
1693
|
+
e.preventDefault() // never let a stray drop navigate away from the board
|
|
1694
|
+
lit(false)
|
|
1695
|
+
if (box) addFiles(box.dataset.sid, e.dataTransfer.files)
|
|
1696
|
+
})
|
|
1697
|
+
}
|
|
1698
|
+
// A sent message's attachments in its bubble: thumbnails (tap for full size) or file chips.
|
|
1699
|
+
function attBubbleHtml(atts, sid) {
|
|
1700
|
+
return '<div class="lv-atts">' + atts.map((a) => {
|
|
1701
|
+
const s0 = a.sessionId || sid, key = s0 + '/' + a.id
|
|
1702
|
+
const label = esc(a.name) + ' · ' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '')
|
|
1703
|
+
const data = ' data-action="att-open" data-sid="' + esc(s0) + '" data-att="' + esc(a.id) + '" data-name="' + esc(a.name) + '" data-kind="' + esc(a.kind) + '" data-thumb="' + (a.thumb === false ? '0' : '1') + '" data-size="' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '') + '"'
|
|
1704
|
+
if (a.kind === 'image') { const u = attachUi.urls.get(key); return '<button type="button" class="lv-att lv-att-img"' + data + ' aria-label="Open ' + esc(a.name) + '" title="' + label + '"><img alt="' + esc(a.name) + '" data-att-thumb="' + esc(key) + '"' + (u ? ' src="' + esc(u) + '"' : '') + '></button>' }
|
|
1705
|
+
const firstPage = a.kind === 'pdf' && a.thumb !== false ? '<img alt="" data-att-thumb="' + esc(key) + '"' + (attachUi.urls.get(key) ? ' src="' + esc(attachUi.urls.get(key)) + '"' : '') + '>' : '' // its first page, when there is one
|
|
1706
|
+
return '<button type="button" class="lv-att lv-att-file"' + data + ' aria-label="Open ' + esc(a.name) + '" title="' + label + '"><span class="att-thumb' + (firstPage ? ' att-thumb-pdf' : '') + '">' + FILE_IC + firstPage + '</span><span class="att-meta"><span class="att-name">' + esc(a.name) + '</span><span class="att-size">' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '') + '</span></span></button>'
|
|
1707
|
+
}).join('') + '</div>'
|
|
1708
|
+
}
|
|
1709
|
+
// Thumbnails come from the Mac once, then from memory. data: URLs, because the
|
|
1710
|
+
// board's CSP allows images from itself and data: only (no blob:).
|
|
1711
|
+
const blobToDataUrl = (b) => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = () => reject(r.error || new Error('unreadable')); r.readAsDataURL(b) })
|
|
1712
|
+
function attData(sid, id, { thumb = false } = {}) {
|
|
1713
|
+
const key = sid + '/' + id + (thumb ? '' : '#full')
|
|
1714
|
+
if (attachUi.urls.has(key)) return Promise.resolve({ url: attachUi.urls.get(key), blob: attachUi.blobs.get(key) || null })
|
|
1715
|
+
if (attachUi.loading.has(key)) return attachUi.loading.get(key)
|
|
1716
|
+
const p = fetchAttachment({ api, sessionId: sid, id, thumb }).then(async (b) => { const url = await blobToDataUrl(b); attachUi.urls.set(key, url); attachUi.blobs.set(key, b); return { url, blob: b } }).finally(() => attachUi.loading.delete(key))
|
|
1717
|
+
attachUi.loading.set(key, p)
|
|
1718
|
+
return p
|
|
1719
|
+
}
|
|
1720
|
+
function hydrateAttThumbs() {
|
|
1721
|
+
for (const img of document.querySelectorAll('#app img[data-att-thumb]:not([src])')) {
|
|
1722
|
+
const key = img.dataset.attThumb
|
|
1723
|
+
if (attachUi.urls.has(key)) { img.src = attachUi.urls.get(key); continue }
|
|
1724
|
+
const btn = img.closest('.lv-att'); if (!btn || btn.dataset.loading) continue
|
|
1725
|
+
btn.dataset.loading = '1'
|
|
1726
|
+
const thumb = btn.dataset.thumb !== '0'
|
|
1727
|
+
attData(btn.dataset.sid, btn.dataset.att, { thumb }).then(({ url }) => { attachUi.urls.set(key, url); for (const i of document.querySelectorAll('#app img[data-att-thumb="' + CSS.escape(key) + '"]')) i.src = url })
|
|
1728
|
+
.catch((e) => {
|
|
1729
|
+
// a PDF sent without a first-page picture (before 0.9.8, or it couldn't be drawn) keeps its icon
|
|
1730
|
+
if (btn.dataset.kind === 'pdf' && e && e.code === 'no_thumb') { for (const i of btn.querySelectorAll('img[data-att-thumb]')) i.remove(); return }
|
|
1731
|
+
btn.classList.add('gone'); btn.title = 'Not on that Mac any more'
|
|
1732
|
+
})
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
// An image opens full screen (ui/lightbox.js) with the other pictures of the
|
|
1736
|
+
// same message beside it; the thumbnail shows (blurred) while the full one
|
|
1737
|
+
// comes from the Mac. Text and other files keep the docs viewer.
|
|
1738
|
+
const attSel = (sid, id) => '.lv-att[data-sid="' + CSS.escape(sid) + '"][data-att="' + CSS.escape(id) + '"]'
|
|
1739
|
+
function openImageAttachment(btn) {
|
|
1740
|
+
const group = btn.closest('.lv-atts')
|
|
1741
|
+
const btns = group ? [...group.querySelectorAll('.lv-att[data-kind="image"]')] : [btn]
|
|
1742
|
+
const items = btns.map((b) => {
|
|
1743
|
+
const im = b.querySelector('img'), sid = b.dataset.sid, id = b.dataset.att
|
|
1744
|
+
const full = attachUi.urls.get(sid + '/' + id + '#full')
|
|
1745
|
+
return { name: b.dataset.name || 'image', sizeLabel: b.dataset.size || '', thumb: (im && im.getAttribute('src')) || null, url: full || null,
|
|
1746
|
+
w: im && im.naturalWidth, h: im && im.naturalHeight, load: () => attData(sid, id).then((r) => r.url) }
|
|
1747
|
+
})
|
|
1748
|
+
const sid = btn.dataset.sid, id = btn.dataset.att
|
|
1749
|
+
openLightbox({ items, index: Math.max(0, btns.indexOf(btn)), from: btn.querySelector('img') || btn, refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
|
|
1750
|
+
}
|
|
1751
|
+
// A chip in the box before it's sent: the picture from the file you picked
|
|
1752
|
+
// (a data: URL; the CSP takes no blob:), the other pictures in the box beside it.
|
|
1753
|
+
// Any other file: its name and size in the docs viewer (nothing to download yet).
|
|
1754
|
+
function openChip(lid) {
|
|
1755
|
+
let a = null
|
|
1756
|
+
for (const list of attachUi.by.values()) { a = list.find((x) => x.lid === lid); if (a) break }
|
|
1757
|
+
if (!a) return
|
|
1758
|
+
const chip = document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
|
|
1759
|
+
const isImg = (x) => x.kind === 'image' && (x.preview || /^image\/(png|jpeg|gif|webp|bmp|svg\+xml|avif)$/i.test((x.prepared || x.file || {}).type || ''))
|
|
1760
|
+
const refocus = () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
|
|
1761
|
+
if (a.kind === 'pdf') {
|
|
1762
|
+
const f = a.prepared || a.file
|
|
1763
|
+
openPdfView({ name: a.name, sizeLabel: fmtSize(a.size), download: false, from: chip, refocus, load: async () => ({ bytes: new Uint8Array(await f.arrayBuffer()) }) })
|
|
1764
|
+
return
|
|
1765
|
+
}
|
|
1766
|
+
if (isImg(a)) {
|
|
1767
|
+
const list = attachList(a.sid).filter(isImg)
|
|
1768
|
+
const items = list.map((x) => ({ name: x.name, sizeLabel: fmtSize(x.size), thumb: x.preview || null, download: false,
|
|
1769
|
+
load: () => { if (!x.fullUrl) x.fullUrl = blobToDataUrl(x.prepared || x.file); return x.fullUrl } }))
|
|
1770
|
+
openLightbox({ items, index: Math.max(0, list.indexOf(a)), from: (chip && chip.querySelector('img')) || chip, refocus: () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]') })
|
|
1771
|
+
return
|
|
1772
|
+
}
|
|
1773
|
+
const el = docViewerEl()
|
|
1774
|
+
el.querySelector('.dv-title').textContent = a.name
|
|
1775
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
1776
|
+
el.querySelector('.dv-dl').hidden = true
|
|
1777
|
+
docUi.r = null; docUi.name = a.name
|
|
1778
|
+
el.querySelector('.dv-body').innerHTML = '<div class="empty att-chip-info"><b>' + esc(a.name) + '</b><br>' + esc(fmtSize(a.size)) + (a.status === 'done' ? ' · goes with your next message' : a.status === 'failed' ? ' · didn’t upload' : ' · uploading') + '</div>'
|
|
1779
|
+
el.hidden = false
|
|
1780
|
+
document.documentElement.classList.add('dv-open')
|
|
1781
|
+
el.querySelector('.dv-x') && el.querySelector('.dv-x').focus({ preventScroll: true })
|
|
1782
|
+
}
|
|
1783
|
+
// A PDF opens in its own full-screen preview (ui/pdfview.js): the pages drawn
|
|
1784
|
+
// from the file the Mac sends back (the same chunked read as a picture).
|
|
1785
|
+
function openPdfAttachment(btn) {
|
|
1786
|
+
const sid = btn.dataset.sid, id = btn.dataset.att
|
|
1787
|
+
openPdfView({ name: btn.dataset.name || 'file.pdf', sizeLabel: btn.dataset.size || '', from: btn,
|
|
1788
|
+
load: async () => { const r = await attData(sid, id); return { bytes: new Uint8Array(await r.blob.arrayBuffer()), url: r.url } },
|
|
1789
|
+
refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
|
|
1790
|
+
}
|
|
1791
|
+
async function openAttachment(btn) {
|
|
1792
|
+
const sid = btn.dataset.sid, id = btn.dataset.att, name = btn.dataset.name || 'file', kind = btn.dataset.kind
|
|
1793
|
+
if (kind === 'image') return openImageAttachment(btn)
|
|
1794
|
+
if (kind === 'pdf') return openPdfAttachment(btn)
|
|
1795
|
+
const el = docViewerEl()
|
|
1796
|
+
el.querySelector('.dv-title').textContent = name
|
|
1797
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
1798
|
+
el.querySelector('.dv-dl').hidden = true
|
|
1799
|
+
const body = el.querySelector('.dv-body')
|
|
1800
|
+
body.innerHTML = '<div class="empty">Opening…</div>'
|
|
1801
|
+
docUi.r = null; docUi.name = name
|
|
1802
|
+
el.hidden = false
|
|
1803
|
+
document.documentElement.classList.add('dv-open')
|
|
1804
|
+
try {
|
|
1805
|
+
const { url: u, blob } = await attData(sid, id)
|
|
1806
|
+
if (el.hidden) return
|
|
1807
|
+
const dl = '<a class="btn" href="' + esc(u) + '" download="' + esc(name) + '">' + icon('download') + 'Download</a>'
|
|
1808
|
+
if (kind === 'image') body.innerHTML = '<div class="att-full"><img src="' + esc(u) + '" alt="' + esc(name) + '"></div><div class="att-full-actions">' + dl + '</div>'
|
|
1809
|
+
else if (kind === 'text') { const t = blob ? await blob.text() : ''; body.innerHTML = '<pre class="att-text">' + esc(t.slice(0, 200000)) + '</pre><div class="att-full-actions">' + dl + '</div>' }
|
|
1810
|
+
else body.innerHTML = '<div class="empty">' + esc(name) + '</div><div class="att-full-actions">' + dl + '</div>'
|
|
1811
|
+
} catch (e) { if (!el.hidden) body.innerHTML = '<div class="empty">Couldn’t open it: ' + esc(e.message) + '</div>' }
|
|
1812
|
+
}
|
|
1813
|
+
// Fit the box to its words (one line up to COMPOSER_MAX_LINES, then it scrolls) and light the ↑ when there's something to send.
|
|
1814
|
+
function fitComposer(ta) {
|
|
1815
|
+
if (!ta || !ta.isConnected) return
|
|
1816
|
+
const cs = getComputedStyle(ta)
|
|
1817
|
+
const lh = parseFloat(cs.lineHeight) || 22
|
|
1818
|
+
const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
|
|
1819
|
+
const keep = ta.scrollTop
|
|
1820
|
+
ta.style.height = 'auto'
|
|
1821
|
+
const r = composerHeight({ scrollHeight: ta.scrollHeight, lineHeight: lh, padY })
|
|
1822
|
+
ta.style.height = r.height + 'px'
|
|
1823
|
+
ta.style.overflowY = r.scroll ? 'auto' : 'hidden'
|
|
1824
|
+
if (r.scroll) ta.scrollTop = keep
|
|
1825
|
+
syncComposer(ta)
|
|
1826
|
+
}
|
|
1827
|
+
function syncComposer(ta) {
|
|
1828
|
+
const box = ta && ta.closest('.composer')
|
|
1829
|
+
const btn = box && box.querySelector('.composer-send')
|
|
1830
|
+
if (!btn || btn.classList.contains('sending')) return
|
|
1831
|
+
btn.disabled = ta.disabled || (isBlank(ta.value) && !attachList(box.dataset.sid).length)
|
|
1832
|
+
}
|
|
1833
|
+
document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
|
|
1834
|
+
// Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
|
|
1835
|
+
document.addEventListener('keydown', (ev) => {
|
|
1836
|
+
const ta = ev.target
|
|
1837
|
+
if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
|
|
1838
|
+
if (ev.metaKey || ev.ctrlKey) return // ⌘↵ / Ctrl+↵: the Needs-you handler presses the button
|
|
1839
|
+
if (composerKey(ev, { touch: isTouchUi(window.matchMedia) }) !== 'send') return
|
|
1840
|
+
ev.preventDefault()
|
|
1841
|
+
const cb = ta.closest('.composer')
|
|
1842
|
+
if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
|
|
1843
|
+
const box = ta.closest('.live-box')
|
|
1844
|
+
const btn = box && box.querySelector('[data-submit-for="live"]')
|
|
1845
|
+
if (!btn || btn.disabled) return
|
|
1846
|
+
const last = Number(box.dataset.kbdAt || 0)
|
|
1847
|
+
if (Date.now() - last < 1500) return
|
|
1848
|
+
box.dataset.kbdAt = String(Date.now())
|
|
1849
|
+
btn.click()
|
|
1850
|
+
})
|
|
1851
|
+
// A tap anywhere on the box (its padding) is a tap in the textarea.
|
|
1852
|
+
document.addEventListener('click', (ev) => { const b = ev.target && ev.target.classList && ev.target.classList.contains('composer') ? ev.target : null; const ta = b && b.querySelector('.composer-input'); if (ta && !ta.disabled) ta.focus() })
|
|
1853
|
+
// Pressing ↑ keeps the keyboard up (the textarea keeps focus) on a phone.
|
|
1854
|
+
document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.composer-send') && document.activeElement && document.activeElement.classList.contains('composer-input')) ev.preventDefault() })
|
|
1379
1855
|
// After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
|
|
1380
1856
|
function liveAfterRender(sid) {
|
|
1857
|
+
for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
|
|
1381
1858
|
const r = liveKeeper.after($app)
|
|
1859
|
+
hydrateAttThumbs(); barWidths($app)
|
|
1382
1860
|
if (!r) return
|
|
1383
1861
|
const ol = r.thread.querySelector('.live-log')
|
|
1384
1862
|
const empty = r.thread.querySelector('.live-empty')
|
|
@@ -1386,8 +1864,16 @@ function liveAfterRender(sid) {
|
|
|
1386
1864
|
liveJumpButton(r.thread, liveKeeper.unseen)
|
|
1387
1865
|
if (ol && !ol.dataset.wired) {
|
|
1388
1866
|
ol.dataset.wired = '1'
|
|
1389
|
-
ol.
|
|
1867
|
+
ol._stick = atBottom(ol)
|
|
1868
|
+
// the button follows the scroll: there whenever you're away from the bottom, gone once you're back
|
|
1869
|
+
ol.addEventListener('scroll', () => { ol._stick = atBottom(ol); liveJumpButton(r.thread, liveKeeper.unseen) }, { passive: true })
|
|
1870
|
+
// at the bottom, the thread stays there while an entry grows (a streamed reply, a code block, late fonts)
|
|
1871
|
+
if (typeof ResizeObserver === 'function') {
|
|
1872
|
+
ol._ro = new ResizeObserver(() => { if (ol._stick && !atBottom(ol, 2)) ol.scrollTop = ol.scrollHeight; liveJumpButton(r.thread, liveKeeper.unseen) })
|
|
1873
|
+
}
|
|
1390
1874
|
}
|
|
1875
|
+
if (ol && ol._ro) { ol._ro.disconnect(); ol._ro.observe(ol); for (const li of ol.children) ol._ro.observe(li) }
|
|
1876
|
+
if (ol && r.follow) ol._stick = true
|
|
1391
1877
|
// #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
|
|
1392
1878
|
const want = parseLiveRoute(location.hash)
|
|
1393
1879
|
if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
|
|
@@ -1400,48 +1886,179 @@ function liveAfterRender(sid) {
|
|
|
1400
1886
|
}
|
|
1401
1887
|
liveTick()
|
|
1402
1888
|
}
|
|
1889
|
+
// "Jump to latest ↓" shows whenever the thread is scrolled away from its bottom (with the
|
|
1890
|
+
// count of what came in since, when there is any), and hides once you're back at the bottom.
|
|
1403
1891
|
function liveJumpButton(thread, n) {
|
|
1404
1892
|
const b = thread && thread.querySelector('.live-jump')
|
|
1405
1893
|
if (!b) return
|
|
1406
|
-
|
|
1894
|
+
const ol = thread.querySelector('.live-log')
|
|
1895
|
+
const v = jumpView(ol, n)
|
|
1896
|
+
if (!v.show && n > 0) liveKeeper.seen()
|
|
1897
|
+
// on its way down after a tap: don't flash back up mid-scroll
|
|
1898
|
+
if (v.show && thread.dataset.jumping && Date.now() < Number(thread.dataset.jumping)) return
|
|
1899
|
+
b.hidden = !v.show
|
|
1407
1900
|
const c = b.querySelector('.live-jump-n')
|
|
1408
|
-
if (c) c.textContent =
|
|
1901
|
+
if (c) c.textContent = v.count
|
|
1902
|
+
b.setAttribute('aria-label', v.label)
|
|
1409
1903
|
}
|
|
1410
1904
|
function liveJumpClick(btn) {
|
|
1411
1905
|
const thread = btn.closest('.live-thread')
|
|
1412
1906
|
const ol = thread && thread.querySelector('.live-log')
|
|
1413
|
-
|
|
1414
|
-
|
|
1907
|
+
const smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
1908
|
+
if (thread) thread.dataset.jumping = String(Date.now() + (smooth ? 900 : 0))
|
|
1909
|
+
if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
|
|
1910
|
+
liveKeeper.seen()
|
|
1911
|
+
btn.hidden = true
|
|
1912
|
+
const c = btn.querySelector('.live-jump-n'); if (c) c.textContent = ''
|
|
1913
|
+
// land exactly at the bottom even if something grew mid-scroll, then the button follows the scroll again
|
|
1914
|
+
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)
|
|
1915
|
+
}
|
|
1916
|
+
// "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
|
|
1917
|
+
async function liveEarlierClick(btn) {
|
|
1918
|
+
const box = btn.closest('.live-box')
|
|
1919
|
+
const sid = box && box.dataset.id
|
|
1920
|
+
if (!sid) return
|
|
1921
|
+
state.liveTurns = state.liveTurns || {}
|
|
1922
|
+
state.liveTurns[sid] = ((state.liveTurns[sid]) || 20) + 20
|
|
1923
|
+
btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading…'
|
|
1924
|
+
await render()
|
|
1415
1925
|
}
|
|
1416
1926
|
// The elapsed time beside "Claude is working…", every second while something is in flight.
|
|
1927
|
+
// 0.9.8: when what the page shows is older than STALE_MS (the stream died, the
|
|
1928
|
+
// phone slept), a running bubble says "Reconnecting…" instead of a timer.
|
|
1417
1929
|
let liveTimer = null
|
|
1418
1930
|
function liveTick() {
|
|
1419
1931
|
const els = [...document.querySelectorAll('.lv-status[data-since]')]
|
|
1932
|
+
const box = document.querySelector('#live[data-id]')
|
|
1933
|
+
const stale = !!box && isStale(liveUi.okAt[box.dataset.id], Date.now())
|
|
1420
1934
|
for (const el of els) {
|
|
1421
1935
|
const since = Number(el.dataset.since) || 0
|
|
1422
1936
|
const end = Number(el.dataset.final) || 0
|
|
1423
1937
|
const t = el.querySelector('.lvs-time')
|
|
1424
1938
|
if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
|
|
1939
|
+
if (!end) {
|
|
1940
|
+
el.classList.toggle('lv-reconnecting', stale)
|
|
1941
|
+
let hint = el.querySelector('.lvs-hint')
|
|
1942
|
+
if (stale && !hint) { hint = document.createElement('span'); hint.className = 'lvs-hint'; hint.textContent = 'Reconnecting…'; (el.querySelector('.lvs-line') || el).append(hint) }
|
|
1943
|
+
if (hint) hint.hidden = !stale
|
|
1944
|
+
}
|
|
1425
1945
|
}
|
|
1426
1946
|
const running = els.some((el) => !el.dataset.final)
|
|
1427
1947
|
if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
|
|
1428
1948
|
}
|
|
1949
|
+
|
|
1950
|
+
// 0.9.8: Live keeps up after the phone sleeps (ui/live-wake.js). Every Live
|
|
1951
|
+
// fetch notes when it answered and whether a message is still going; while
|
|
1952
|
+
// one is, on a visible page, the thread is fetched again every POLL_MS
|
|
1953
|
+
// whatever the event stream does.
|
|
1954
|
+
const liveUi = { okAt: {}, sig: {}, pending: {}, polling: false }
|
|
1955
|
+
function noteLive(id, v) {
|
|
1956
|
+
if (!v || v.error) return
|
|
1957
|
+
liveUi.okAt[id] = Date.now()
|
|
1958
|
+
liveUi.pending[id] = anyPending(v.messages)
|
|
1959
|
+
}
|
|
1960
|
+
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 }
|
|
1961
|
+
// Fetch the thread and diff it into the page, even while you type (busy()
|
|
1962
|
+
// holds full redraws back, never the thread). A turn that ended also redraws
|
|
1963
|
+
// the rest of the page (the pill stops pulsing) once you're not busy.
|
|
1964
|
+
async function refreshLive(reason) {
|
|
1965
|
+
const id = liveOnScreen()
|
|
1966
|
+
if (!id || liveUi.polling) return false
|
|
1967
|
+
liveUi.polling = true; liveUi.triedAt = Date.now()
|
|
1968
|
+
let v
|
|
1969
|
+
try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
|
|
1970
|
+
if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
|
|
1971
|
+
const s = state.sessions.find((x) => x.id === id)
|
|
1972
|
+
const sig = liveSignature(v.messages)
|
|
1973
|
+
const before = liveUi.sig[id]
|
|
1974
|
+
liveUi.sig[id] = sig
|
|
1975
|
+
if (s && before !== sig) {
|
|
1976
|
+
const box = document.querySelector('#live')
|
|
1977
|
+
const cur = box && box.querySelector('.live-thread')
|
|
1978
|
+
if (cur) {
|
|
1979
|
+
const tmp = document.createElement('div')
|
|
1980
|
+
tmp.innerHTML = liveBoxHtml(s, v)
|
|
1981
|
+
const fresh = tmp.querySelector('.live-thread')
|
|
1982
|
+
if (fresh) {
|
|
1983
|
+
const next = cur.nextSibling
|
|
1984
|
+
liveKeeper.before(box)
|
|
1985
|
+
box.insertBefore(fresh, next)
|
|
1986
|
+
const r = liveKeeper.after(box)
|
|
1987
|
+
hydrateAttThumbs()
|
|
1988
|
+
if (r) liveJumpButton(r.thread, liveKeeper.unseen)
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
const c = sessionCache.get(id); if (c) c.live = v
|
|
1992
|
+
if (!liveUi.pending[id] && !busy()) soon() // settled: the pill, the tab, the card
|
|
1993
|
+
}
|
|
1994
|
+
liveTick()
|
|
1995
|
+
return true
|
|
1996
|
+
}
|
|
1997
|
+
// The safety net, and the stream's watchdog, on one slow timer.
|
|
1998
|
+
const streamDog = createWatchdog()
|
|
1999
|
+
const wakeGate = createWake()
|
|
2000
|
+
setInterval(() => {
|
|
2001
|
+
if (document.visibilityState === 'hidden') return
|
|
2002
|
+
const id = liveOnScreen()
|
|
2003
|
+
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')
|
|
2004
|
+
if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
|
|
2005
|
+
}, 1000)
|
|
2006
|
+
// Coming back (iOS: the Home Screen app returns, the screen unlocks, a
|
|
2007
|
+
// bfcache restore, the network returns): fetch what's on screen at once, and
|
|
2008
|
+
// open a new stream if the old one missed its pings.
|
|
2009
|
+
function refreshView(reason) {
|
|
2010
|
+
if (liveOnScreen()) refreshLive(reason)
|
|
2011
|
+
if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
|
|
2012
|
+
refreshHealth()
|
|
2013
|
+
}
|
|
2014
|
+
let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
|
|
2015
|
+
function onWake(ev) {
|
|
2016
|
+
if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
|
|
2017
|
+
const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
|
|
2018
|
+
if (!why) return
|
|
2019
|
+
liveTick() // a stale timer turns into "Reconnecting…" before anything else
|
|
2020
|
+
const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
|
|
2021
|
+
const away = awayAt ? Date.now() - awayAt : 0
|
|
2022
|
+
awayAt = 0
|
|
2023
|
+
// 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
|
|
2024
|
+
if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
|
|
2025
|
+
if (!wakeGate.due()) return
|
|
2026
|
+
if (why === 'pageshow-bfcache' || dead) connect()
|
|
2027
|
+
refreshView(why)
|
|
2028
|
+
}
|
|
2029
|
+
for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
|
|
2030
|
+
document.addEventListener('visibilitychange', onWake)
|
|
1429
2031
|
async function liveSendClick(btn) {
|
|
1430
2032
|
const box = btn.closest('.live-box')
|
|
1431
2033
|
const ta = box && box.querySelector('.live-text')
|
|
2034
|
+
const sid = btn.dataset.id
|
|
1432
2035
|
const text = ta ? ta.value.trim() : ''
|
|
1433
|
-
if (!text
|
|
1434
|
-
if (btn.
|
|
1435
|
-
|
|
2036
|
+
if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
|
|
2037
|
+
if (btn.disabled || btn.classList.contains('sending')) return
|
|
2038
|
+
const hadFocus = !!(ta && document.activeElement === ta)
|
|
2039
|
+
btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
|
|
1436
2040
|
try {
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
2041
|
+
// 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
|
|
2042
|
+
await uploadsSettled(sid)
|
|
2043
|
+
const atts = attachList(sid)
|
|
2044
|
+
const failed = atts.filter((a) => a.status === 'failed').length
|
|
2045
|
+
if (failed) { toast(failed === 1 ? 'A file didn’t upload: retry it or remove it.' : failed + ' files didn’t upload: retry or remove them.', 4200); return }
|
|
2046
|
+
const ids = atts.filter((a) => a.status === 'done' && a.id).map((a) => a.id)
|
|
2047
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live', { text, ...(ids.length ? { attachments: ids } : {}) })
|
|
2048
|
+
// what you typed while it went stays; what was sent goes
|
|
2049
|
+
if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
|
|
2050
|
+
if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
|
|
1440
2051
|
if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
|
|
1441
2052
|
const ol = box && box.querySelector('.live-log')
|
|
1442
2053
|
if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
|
|
1443
2054
|
setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
|
|
1444
|
-
} catch (e) { toast(e.message, 4200) } finally {
|
|
2055
|
+
} catch (e) { toast(e.message, 4200) } finally {
|
|
2056
|
+
await render()
|
|
2057
|
+
const nb = document.querySelector('.live-box [data-action="live-send"]')
|
|
2058
|
+
if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
|
|
2059
|
+
const nt = document.querySelector('.live-box .composer-input')
|
|
2060
|
+
if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
|
|
2061
|
+
}
|
|
1445
2062
|
}
|
|
1446
2063
|
|
|
1447
2064
|
// ------------------------------------------------------------ search inside transcripts (0.9.6)
|
|
@@ -1604,6 +2221,11 @@ async function togglePr(url, { retry = false } = {}) {
|
|
|
1604
2221
|
const was = prsUi.open.has(url)
|
|
1605
2222
|
if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
|
|
1606
2223
|
const repaint = () => {
|
|
2224
|
+
if (state.route && state.route.name === 's') { // 0.9.8: the session page's PRs open in place too
|
|
2225
|
+
const s = state.sessions.find((x) => x.id === state.route.arg), sec = document.getElementById('prs')
|
|
2226
|
+
if (s && sec) sec.innerHTML = sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details })
|
|
2227
|
+
return
|
|
2228
|
+
}
|
|
1607
2229
|
const d = prsUi.data[prsUi.view]
|
|
1608
2230
|
const r = prRows(d).find((x) => x.pr.url === url)
|
|
1609
2231
|
const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
|
|
@@ -1617,7 +2239,7 @@ async function togglePr(url, { retry = false } = {}) {
|
|
|
1617
2239
|
try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
1618
2240
|
catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
|
|
1619
2241
|
prsUi.details[url] = { ...res, at: Date.now() }
|
|
1620
|
-
if (state.route && state.route.name === 'prs') repaint()
|
|
2242
|
+
if (state.route && (state.route.name === 'prs' || state.route.name === 's')) repaint()
|
|
1621
2243
|
}
|
|
1622
2244
|
function setPrsFilter(f) {
|
|
1623
2245
|
const h = prsHash(prsUi.view, f)
|
|
@@ -1678,29 +2300,67 @@ async function viewTimeline() {
|
|
|
1678
2300
|
$app.innerHTML = html
|
|
1679
2301
|
}
|
|
1680
2302
|
|
|
1681
|
-
|
|
2303
|
+
// 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
|
|
2304
|
+
// links there and Start here gets that session's Go / Resume.
|
|
2305
|
+
const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
|
|
2306
|
+
function briefAction(id, sessions, viewer) {
|
|
2307
|
+
const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
|
|
2308
|
+
const s = sessions && sessions.get(id)
|
|
2309
|
+
if (!s) return open
|
|
2310
|
+
let b = ''
|
|
2311
|
+
// 0.9.8: closed: Resume opens it in a new tab where it can, else (on the Mac) copies the resume command
|
|
2312
|
+
if (s.live === 'closed' || s.status === 'ended') b = resumeButton(s, viewer || {}) || (s.agent || !s.cwd || (viewer && viewer.local === false) || state.viewerLocal === false ? '' : '<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy the resume command">' + icon('copy') + 'Resume</button>')
|
|
2313
|
+
else { const g = goButton(s); b = g && !/disabled/.test(g) ? g : '' }
|
|
2314
|
+
return b || open
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
1682
2318
|
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
1683
2319
|
const inline = (t) => esc(t).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|[^\w*])_([^_\s][^_]*?)_(?!\w)/g, '$1<em>$2</em>') // not inside snake_case names (clutch_hockey)
|
|
1684
2320
|
const out = []
|
|
1685
|
-
let inList = false, inSec = false
|
|
2321
|
+
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
2322
|
+
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
2323
|
+
const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
|
|
2324
|
+
const href = (id) => '#/s/' + encodeURIComponent(id)
|
|
1686
2325
|
// v0.5.8: each "## <space>" opens a section with its own Copy for standup;
|
|
1687
2326
|
// other "## " headings (Lists, Top of mind) are whole-folder sections.
|
|
2327
|
+
// 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
|
|
1688
2328
|
const openSec = (h) => {
|
|
1689
2329
|
if (inSec) out.push('</section>')
|
|
1690
2330
|
inSec = true
|
|
1691
|
-
if (
|
|
2331
|
+
if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
|
|
2332
|
+
else if (spaces.includes(h)) {
|
|
1692
2333
|
out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
|
|
1693
2334
|
'<button type="button" class="btn standup-btn' + (h === 'Work' ? ' primary' : '') + '" data-action="standup-copy" data-space="' + esc(h) + '">' + icon('clipboard') + 'Copy for standup</button></div>')
|
|
1694
2335
|
} else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
|
|
1695
2336
|
}
|
|
1696
|
-
for (const
|
|
2337
|
+
for (const raw of body.split('\n')) {
|
|
2338
|
+
const mid = raw.match(MD_SID)
|
|
2339
|
+
const id = mid ? mid[1] : null
|
|
2340
|
+
const line = mid ? raw.slice(0, mid.index) : raw
|
|
2341
|
+
// 0.9.8: Start here: "1. <step> — **Title** (repo)"
|
|
2342
|
+
const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
|
|
2343
|
+
if (st) {
|
|
2344
|
+
openList('ol', 'start')
|
|
2345
|
+
const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
|
|
2346
|
+
out.push('<li' + (id ? ' data-sid="' + esc(id) + '"' : '') + '><div class="sh-main"><span class="sh-step">' + inline(st[1]) + '</span><span class="sh-sess">' + t + (st[3] ? ' <span class="sh-repo">' + esc(st[3]) + '</span>' : '') + '</span></div>' +
|
|
2347
|
+
(id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
|
|
2348
|
+
continue
|
|
2349
|
+
}
|
|
1697
2350
|
if (/^- /.test(line)) {
|
|
1698
|
-
|
|
2351
|
+
openList('ul')
|
|
1699
2352
|
// Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
|
|
1700
2353
|
const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
|
|
2354
|
+
const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
|
|
2355
|
+
// 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
|
|
2356
|
+
// session: the title links to it, the sentence under it, the tags quiet.
|
|
2357
|
+
const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
|
|
2358
|
+
// 0.9.8: an Unfinished one is closed, so it has its Resume beside it
|
|
2359
|
+
if (bi && inUnf) { out.push('<li class="bi unf"' + attrs + '><div class="bi-body"><div class="bi-head"><a class="bi-title" href="' + href(id) + '">' + inline(bi[1]) + '</a><span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span></div><div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>'); continue }
|
|
2360
|
+
if (bi) { out.push('<li class="bi"' + attrs + '><div class="bi-head"><a class="bi-title" href="' + href(id) + '">' + inline(bi[1]) + '</a><span class="bi-tag">' + esc(bi[2]) + '</span></div><span class="bi-say">' + inline(bi[3]) + '</span>'); continue }
|
|
1701
2361
|
// v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
|
|
1702
2362
|
const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
|
|
1703
|
-
out.push('<li' +
|
|
2363
|
+
out.push('<li' + attrs + '>' + li); continue
|
|
1704
2364
|
}
|
|
1705
2365
|
if (/^\s{2,}\S/.test(line) && inList) {
|
|
1706
2366
|
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
@@ -1709,13 +2369,17 @@ function renderMd(md, { spaces = [] } = {}) {
|
|
|
1709
2369
|
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
1710
2370
|
continue
|
|
1711
2371
|
}
|
|
1712
|
-
|
|
2372
|
+
closeList()
|
|
2373
|
+
if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
|
|
1713
2374
|
if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
|
|
1714
|
-
else if (/^## /.test(line)) { if (spaces.length) openSec(
|
|
2375
|
+
else if (/^## /.test(line)) { seenH2 = true; const h = line.slice(3).trim(); if (spaces.length || h === 'Start here') openSec(h); else { if (inSec) { out.push('</section>'); inSec = false } out.push('<h2>' + inline(h) + '</h2>') } }
|
|
2376
|
+
// 0.9.8: Finished folds away (older digests' "Finished or closed" too)
|
|
2377
|
+
else if (/^### Finished(?: or closed)? \(\d+\)\s*$/.test(line)) { out.push('<details class="fin"><summary>' + inline(line.slice(4).replace(/ or closed/, '')) + '</summary>'); inFin = true }
|
|
1715
2378
|
else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
|
|
1716
|
-
|
|
2379
|
+
// 0.9.8: the Yesterday / Today opener, above the first section
|
|
2380
|
+
else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
|
|
1717
2381
|
}
|
|
1718
|
-
|
|
2382
|
+
closeList()
|
|
1719
2383
|
if (inSec) out.push('</section>')
|
|
1720
2384
|
return out.join('\n')
|
|
1721
2385
|
}
|
|
@@ -1726,6 +2390,81 @@ function shortDay(iso) {
|
|
|
1726
2390
|
return isNaN(d) ? iso : d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '')
|
|
1727
2391
|
}
|
|
1728
2392
|
|
|
2393
|
+
// The past-digests calendar (0.9.8): one month at a time, Sunday first. A day
|
|
2394
|
+
// with a digest is a link (the shared one, else a Mac's own from before one
|
|
2395
|
+
// digest a day); the rest are plain numbers. Opens on the picked digest's
|
|
2396
|
+
// month; next stops at this month, prev at the first month with a digest.
|
|
2397
|
+
const monthOf = (iso) => String(iso).slice(0, 7)
|
|
2398
|
+
const localDay = (t = new Date()) => t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0')
|
|
2399
|
+
function shiftMonth(ym, by) {
|
|
2400
|
+
const [y, m] = ym.split('-').map(Number)
|
|
2401
|
+
const d = new Date(y, m - 1 + by, 1)
|
|
2402
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
|
2403
|
+
}
|
|
2404
|
+
function digestCalendar() {
|
|
2405
|
+
const { days = [], pick = null } = digestUi.cal || {}
|
|
2406
|
+
const today = localDay()
|
|
2407
|
+
const max = monthOf(today)
|
|
2408
|
+
const min = days.length ? days.map((x) => monthOf(x.day)).sort()[0] : max
|
|
2409
|
+
let ym = digestUi.month || max
|
|
2410
|
+
if (ym > max) ym = max
|
|
2411
|
+
if (ym < min) ym = min
|
|
2412
|
+
digestUi.month = ym
|
|
2413
|
+
const [y, m] = ym.split('-').map(Number)
|
|
2414
|
+
const first = new Date(y, m - 1, 1)
|
|
2415
|
+
const label = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
|
2416
|
+
const by = new Map(days.map((x) => [x.day, x.d]))
|
|
2417
|
+
const prev = shiftMonth(ym, -1), next = shiftMonth(ym, 1)
|
|
2418
|
+
const name = (x) => new Date(x + '-01T12:00:00').toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
|
2419
|
+
const navBtn = (to, ok, dir) => '<button type="button" class="cal-nav" data-action="digest-month" data-to="' + to + '"' + (ok ? '' : ' disabled') + ' aria-label="' + (dir < 0 ? 'Previous month, ' : 'Next month, ') + esc(name(to)) + '">' + icon(dir < 0 ? 'chevron-left' : 'chevron-right') + '</button>'
|
|
2420
|
+
// phones fold it to one week (the picked day's) until you open the month
|
|
2421
|
+
const open = !!digestUi.calOpen
|
|
2422
|
+
let h = '<div class="cal-head">' + navBtn(prev, prev >= min, -1) + '<span class="cal-month" aria-live="polite">' + esc(label) + '</span>' +
|
|
2423
|
+
'<button type="button" class="cal-month cal-fold" data-action="digest-cal-fold" aria-expanded="' + open + '" aria-label="' + esc(label) + (open ? ', show one week' : ', show the whole month') + '">' + esc(label) + icon('chevron-down') + '</button>' + navBtn(next, next <= max, 1) + '</div>'
|
|
2424
|
+
h += '<div class="cal-grid' + (open ? ' open' : '') + '">' + ['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((w) => '<span class="cal-wd" aria-hidden="true">' + w + '</span>').join('')
|
|
2425
|
+
const n = new Date(y, m, 0).getDate()
|
|
2426
|
+
const lead0 = first.getDay()
|
|
2427
|
+
const week = (dn) => Math.floor((lead0 + dn - 1) / 7)
|
|
2428
|
+
const inMonth = (x) => x && x.slice(0, 7) === ym
|
|
2429
|
+
const firstHas = days.map((x) => x.day).filter(inMonth).sort()[0]
|
|
2430
|
+
const wk = week(inMonth(pick) ? Number(pick.slice(8)) : inMonth(today) ? Number(today.slice(8)) : firstHas ? Number(firstHas.slice(8)) : 1)
|
|
2431
|
+
for (let i = 0; i < lead0; i++) h += '<span class="cal-day pad' + (wk === 0 ? ' wk' : '') + '" aria-hidden="true"></span>'
|
|
2432
|
+
let count = 0
|
|
2433
|
+
for (let dn = 1; dn <= n; dn++) {
|
|
2434
|
+
const day = ym + '-' + String(dn).padStart(2, '0')
|
|
2435
|
+
const d = by.get(day)
|
|
2436
|
+
const cls = ['cal-day', d ? 'has' : 'none', day === pick ? 'on' : '', day === today ? 'today' : '', week(dn) === wk ? 'wk' : ''].filter(Boolean).join(' ')
|
|
2437
|
+
const long = shortDay(day)
|
|
2438
|
+
if (d) {
|
|
2439
|
+
count++
|
|
2440
|
+
const whose = d.shared ? '' : ', ' + d.machine + '’s own digest'
|
|
2441
|
+
h += '<a class="' + cls + '" href="#/digest/' + day + (d.shared ? '' : '?machine=' + encodeURIComponent(d.machine)) + '" data-day="' + day + '" aria-label="' + esc(long + whose + (day === today ? ', today' : '')) + '"' + (day === pick ? ' aria-current="date"' : '') + '><span class="n">' + dn + '</span><span class="dot" aria-hidden="true"></span></a>'
|
|
2442
|
+
} else h += '<span class="' + cls + '" data-day="' + day + '"><span class="n">' + dn + '</span></span>'
|
|
2443
|
+
}
|
|
2444
|
+
h += '</div>'
|
|
2445
|
+
if (!count) h += '<div class="cal-none">' + (days.length ? 'No digests in ' + esc(first.toLocaleDateString('en-US', { month: 'long' })) + '.' : 'None yet') + '</div>'
|
|
2446
|
+
return h
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// Month or fold changed: patch the calendar in place, so the arrows (and the
|
|
2450
|
+
// focus on them, with its keyboard ring) stay put.
|
|
2451
|
+
function repaintCalendar() {
|
|
2452
|
+
const nav = document.querySelector('nav.digest-cal')
|
|
2453
|
+
if (!nav) return null
|
|
2454
|
+
const t = document.createElement('div')
|
|
2455
|
+
t.innerHTML = digestCalendar()
|
|
2456
|
+
const now = [...nav.querySelectorAll('.cal-head > *')], fresh = [...t.querySelectorAll('.cal-head > *')]
|
|
2457
|
+
now.forEach((el, i) => {
|
|
2458
|
+
const f = fresh[i]
|
|
2459
|
+
for (const a of ['data-to', 'aria-label', 'aria-expanded']) if (f.hasAttribute(a)) el.setAttribute(a, f.getAttribute(a))
|
|
2460
|
+
el.disabled = !!f.disabled
|
|
2461
|
+
if (!el.classList.contains('cal-nav')) el.innerHTML = f.innerHTML
|
|
2462
|
+
})
|
|
2463
|
+
for (const el of nav.querySelectorAll('.cal-grid, .cal-none')) el.remove()
|
|
2464
|
+
nav.append(...t.querySelectorAll('.cal-grid, .cal-none'))
|
|
2465
|
+
return nav
|
|
2466
|
+
}
|
|
2467
|
+
|
|
1729
2468
|
// Digest (v0.5.8): one digest a day for the whole folder. The strip has one
|
|
1730
2469
|
// pill per day: the shared digest, else (days from before 0.5.8) a Mac's own,
|
|
1731
2470
|
// labelled with that Mac. Space tabs (All / Work / Personal …) sit above the
|
|
@@ -1750,16 +2489,16 @@ async function viewDigest(date, machine) {
|
|
|
1750
2489
|
const who = dg.order && dg.order.length > 1 ? ' Made by ' + esc(dg.order[0]) + ' (first in line), or the next Mac awake ' + esc(dg.graceMin) + ' min later.' : ''
|
|
1751
2490
|
const nextLine = todayLine + (dg.time ? 'Next automatic digest: ' + esc(when(dg.nextAt) || 'at ' + dg.time) + ' · ' + esc(dg.daysLabel || 'Every day') + ', ' + esc(dg.label || dg.time) + '.' + who : 'Automatic digests are off (<a href="#/settings/digest">Settings → Digest</a>).')
|
|
1752
2491
|
if (dg.lastError) html += '<div class="banner warn" role="status"><span class="grow"><b>The last digest failed</b> (' + esc(ago(dg.lastError.at)) + '): ' + esc(dg.lastError.message) + '. Generate now tries again; <code>leftoff doctor</code> shows it too.</span></div>'
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
return '<a href="#/digest/' + day + (d.shared ? '' : '?machine=' + encodeURIComponent(d.machine)) + '" class="' + (on ? 'on' : '') + '" title="' + esc(day + (d.shared ? '' : ' · ' + d.machine + '’s own digest (from before one digest a day)')) + '"><span class="d-iso">' + day + '</span><span class="d-short">' + esc(shortDay(day)) + '</span>' + (label ? ' <span class="m">' + esc(label) + '</span>' : '') + '</a>'
|
|
1758
|
-
}).join('') : '<span class="nocp">None yet</span>') + '</nav>'
|
|
2492
|
+
// 0.9.8: a month calendar, not a long list: days with a digest are links
|
|
2493
|
+
if (digestUi.date !== pick || !digestUi.month) { digestUi.month = monthOf(pick || localDay()); digestUi.calOpen = false }
|
|
2494
|
+
digestUi.cal = { days: days.map((day) => ({ day, d: lead(day) })), pick }
|
|
2495
|
+
html += '<div class="digest-wrap"><nav class="digest-cal" aria-label="Past digests">' + digestCalendar() + '</nav>'
|
|
1759
2496
|
let spaces = []
|
|
1760
2497
|
if (pick) {
|
|
1761
2498
|
try {
|
|
1762
|
-
|
|
2499
|
+
// 0.9.8: the sessions too, for Start here's Go / Resume
|
|
2500
|
+
const [d, sv] = await Promise.all([api('GET', '/v1/digests/' + pick + (pickD.machine ? '?machine=' + encodeURIComponent(pickD.machine) : '')), api('GET', '/v1/sessions?limit=500&archived=1').catch(() => null)])
|
|
2501
|
+
if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
|
|
1763
2502
|
const fm = d.meta || {}
|
|
1764
2503
|
const madeAt = fm.generated_at
|
|
1765
2504
|
spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
|
|
@@ -1769,10 +2508,14 @@ async function viewDigest(date, machine) {
|
|
|
1769
2508
|
const also = others.length ? '<div class="digest-also">Also for this day: ' + others.map((x) => '<a href="#/digest/' + x.date + (x.shared ? '' : '?machine=' + encodeURIComponent(x.machine)) + '">' + esc(x.shared ? 'the shared digest' : x.machine + '’s') + '</a>').join(' · ') + '</div>' : ''
|
|
1770
2509
|
const unnamed = spv && spaces.some((x) => (spv.machines || []).some((m) => !m.set && m.machine === x)) ? '<div class="digest-hint">Spaces are named after your Macs until you pick one for each: <a href="#/settings/spaces">Settings → Spaces</a> (Work, Personal or your own).</div>' : ''
|
|
1771
2510
|
html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
|
|
1772
|
-
'<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces }) + '</article>'
|
|
2511
|
+
'<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces, sessions: sv ? new Map((sv.sessions || []).map((x) => [x.id, x])) : null, viewer: sv && sv.viewer }) + '</article>'
|
|
1773
2512
|
} catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
|
|
1774
2513
|
} else html += '<div class="empty"><p><strong>No digest yet.</strong></p><p>' + nextLine + '</p><p><button type="button" class="btn primary" data-action="gen-digest">Generate now</button> makes one from your sessions so far.</p></div>'
|
|
1775
2514
|
$app.innerHTML = html + '</div>'
|
|
2515
|
+
// 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
|
|
2516
|
+
const art = $app.querySelector('article.md')
|
|
2517
|
+
const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
|
|
2518
|
+
if (after) after.after(...['.digest-hint', '#digestSpaces', '#digestRepos'].map((q) => art.querySelector(q)).filter(Boolean))
|
|
1776
2519
|
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
1777
2520
|
state.digestRepos = readFilter(params, window.localStorage).repos
|
|
1778
2521
|
state.digestSpaces = spaces
|
|
@@ -1794,7 +2537,7 @@ function paintDigestFilter() {
|
|
|
1794
2537
|
const spaces = state.digestSpaces || []
|
|
1795
2538
|
const sp = state.digestSpace
|
|
1796
2539
|
const secs = [...document.querySelectorAll('.md section')]
|
|
1797
|
-
for (const sec of secs) sec.hidden = !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
2540
|
+
for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
1798
2541
|
const tabsBox = document.getElementById('digestSpaces')
|
|
1799
2542
|
if (tabsBox) {
|
|
1800
2543
|
const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
|
|
@@ -1812,8 +2555,19 @@ function paintDigestFilter() {
|
|
|
1812
2555
|
const any = [...ul.children].some((li) => !li.hidden)
|
|
1813
2556
|
ul.hidden = !any
|
|
1814
2557
|
if (h && (h.tagName === 'H2' || h.tagName === 'H3')) h.hidden = !any
|
|
2558
|
+
const fin = ul.closest('details.fin')
|
|
2559
|
+
if (fin) fin.hidden = !any
|
|
1815
2560
|
}
|
|
1816
2561
|
if (sel.length) for (const sec of secs) if (sec.classList.contains('space-sec') && !sec.hidden && ![...sec.querySelectorAll('li[data-repo]')].some((li) => !li.hidden)) sec.hidden = true
|
|
2562
|
+
// 0.9.8: a Start here step follows its session's line: hidden with it (space or repo)
|
|
2563
|
+
const startSec = document.querySelector('.md section.start-sec')
|
|
2564
|
+
if (startSec) {
|
|
2565
|
+
for (const li of startSec.querySelectorAll('li[data-sid]')) {
|
|
2566
|
+
const twin = [...document.querySelectorAll('.md li[data-repo][data-sid]')].find((x) => x.dataset.sid === li.dataset.sid)
|
|
2567
|
+
li.hidden = !!twin && (twin.hidden || !!(twin.closest('section') && twin.closest('section').hidden))
|
|
2568
|
+
}
|
|
2569
|
+
startSec.hidden = ![...startSec.querySelectorAll('ol.start > li')].some((li) => !li.hidden)
|
|
2570
|
+
}
|
|
1817
2571
|
if (counts.size < 2) { box.innerHTML = ''; return }
|
|
1818
2572
|
const on = (r) => sel.some((tok) => repoMatches({ repoKey: r, repoLabel: r }, tok))
|
|
1819
2573
|
box.innerHTML = '<div class="track"><button type="button" class="rchip all' + (sel.length ? '' : ' on') + '" data-action="digest-repo" data-repo=""><span class="rl">All</span><span class="rc">' + items.length + '</span></button>' +
|
|
@@ -2118,11 +2872,13 @@ async function tryLength() {
|
|
|
2118
2872
|
const s = list[0]
|
|
2119
2873
|
if (!s) { toast('No session yet to try it on'); return }
|
|
2120
2874
|
box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
|
|
2875
|
+
// the page can be redrawn while the summarizer writes (Save re-renders Settings): put the result in the box on screen then
|
|
2876
|
+
const now = () => document.getElementById('f-length-example') || box
|
|
2121
2877
|
try {
|
|
2122
2878
|
const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
|
|
2123
|
-
|
|
2879
|
+
now().innerHTML = '<div class="ex-head">Your session · ' + esc(s.title || 'Untitled') + ' <span class="faint">' + esc(k) + (cp.generator && cp.generator.by ? ' · by ' + esc(cp.generator.by) + (cp.generator.model ? ' / ' + esc(cp.generator.model) : '') + (cp.generator.backend === 'ollama' ? ' · Ollama' : '') : '') + ' · not saved</span></div>' +
|
|
2124
2880
|
cpBlock(cp, { showWhen: false, md: true }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
|
|
2125
|
-
} catch (e) {
|
|
2881
|
+
} catch (e) { now().innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
|
|
2126
2882
|
}
|
|
2127
2883
|
|
|
2128
2884
|
// Am I the iPhone home-screen app (or another installed app window)?
|
|
@@ -2553,6 +3309,7 @@ async function viewSettings() {
|
|
|
2553
3309
|
general: '<div class="section">Needs you</div>' +
|
|
2554
3310
|
'<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>' +
|
|
2555
3311
|
'<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
|
|
3312
|
+
'<div class="full push-slot" data-push-slot hidden></div>' +
|
|
2556
3313
|
'<div class="section">Appearance</div>' +
|
|
2557
3314
|
'<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>' +
|
|
2558
3315
|
advanced('general', '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
|
|
@@ -2585,7 +3342,7 @@ async function viewSettings() {
|
|
|
2585
3342
|
updates: '<div class="section">About</div>' +
|
|
2586
3343
|
'<label>Version</label><div class="aboutline"><span class="chip mono">v' + esc(h.version || '') + '</span><a href="#/whats-new">What’s new</a><a href="' + NPM_URL + '" target="_blank" rel="noopener">npm ↗</a></div>' +
|
|
2587
3344
|
'<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
|
|
2588
|
-
'<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a></div>' +
|
|
3345
|
+
'<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a> · <a href="#/tour">Take the tour</a></div>' +
|
|
2589
3346
|
advanced('updates', '<label for="f-updates">Check daily</label><label class="check"><input id="f-updates" type="checkbox"' + (!(c.updates && c.updates.check === false) ? ' checked' : '') + '><span>Look for a new version daily. Nothing installs on its own.</span></label>' +
|
|
2590
3347
|
'<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
|
|
2591
3348
|
'<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
|
|
@@ -3284,7 +4041,7 @@ function paintListsCount(lists) {
|
|
|
3284
4041
|
// The session page's Lists section: saved lists, the hint, and "Find lists in this session".
|
|
3285
4042
|
function sessionListsSection(s) {
|
|
3286
4043
|
const f = listUi.found[s.id]
|
|
3287
|
-
let h = '<h2>Lists <span class="count">' + ((s.lists || []).length) + '</span><button type="button" class="helpq" data-action="list-help-
|
|
4044
|
+
let h = '<h2>Lists <span class="count">' + ((s.lists || []).length) + '</span><button type="button" class="helpq" data-action="list-help-inline" aria-expanded="' + (listUi.sHelp ? 'true' : 'false') + '" aria-controls="sListsHelp" title="How lists work">?</button></h2>' + (listUi.sHelp ? listsHelp().replace('id="listsHelp"', 'id="sListsHelp"') : '')
|
|
3288
4045
|
if (s.lists && s.lists.length) h += '<div class="lists">' + s.lists.map((x) => (state.listFull && state.listFull[x.ref] ? listCard(state.listFull[x.ref], { session: false }) : '')).join('') + '</div>'
|
|
3289
4046
|
if (s.listHint) h += '<div class="lfound hint"><div><strong>Claude wrote a ' + esc(s.listHint.count) + '-step list</strong>: ' + esc(s.listHint.name) + '</div><div class="row"><button type="button" class="btn primary" data-action="list-save-hint" data-id="' + esc(s.id) + '">Save</button><button type="button" class="btn" data-action="list-dismiss" data-id="' + esc(s.id) + '">Dismiss</button></div></div>'
|
|
3290
4047
|
h += '<div class="lfind"><button type="button" class="btn" data-action="list-find" data-id="' + esc(s.id) + '"' + (listUi.finding[s.id] ? ' disabled' : '') + '>' + icon('list-checks') + (listUi.finding[s.id] ? 'Reading the transcript…' : 'Find lists in this session') + '</button>' +
|
|
@@ -3315,6 +4072,7 @@ function parseRoute() {
|
|
|
3315
4072
|
async function render() {
|
|
3316
4073
|
const r = (state.route = parseRoute())
|
|
3317
4074
|
paintNav(r)
|
|
4075
|
+
tabsOnRoute(r)
|
|
3318
4076
|
try {
|
|
3319
4077
|
if (r.name === 's' && r.arg) await viewSession(r.arg)
|
|
3320
4078
|
else if (r.name === 'waiting') await viewWaiting()
|
|
@@ -3328,7 +4086,9 @@ async function render() {
|
|
|
3328
4086
|
else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
|
|
3329
4087
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
3330
4088
|
else if (r.name === 'setup') await viewSetup()
|
|
4089
|
+
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
3331
4090
|
else await viewBoard()
|
|
4091
|
+
tabsAfterRender()
|
|
3332
4092
|
} catch (e) {
|
|
3333
4093
|
if (e.code === 'login_required') return showLogin()
|
|
3334
4094
|
$app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
|
|
@@ -3339,12 +4099,20 @@ async function render() {
|
|
|
3339
4099
|
// it keeps its own step and fetches its own state.
|
|
3340
4100
|
async function viewSetup() {
|
|
3341
4101
|
if ($app.querySelector('.wz')) return
|
|
3342
|
-
await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
|
|
4102
|
+
await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/'; autoTour() } })
|
|
3343
4103
|
}
|
|
3344
4104
|
// Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
|
|
4105
|
+
// Otherwise the product tour gets its turn.
|
|
3345
4106
|
async function firstRunSetup() {
|
|
3346
|
-
if (location.hash.replace(/^#\/?/, ''))
|
|
3347
|
-
|
|
4107
|
+
if (!location.hash.replace(/^#\/?/, '')) {
|
|
4108
|
+
try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) return location.replace('#/setup') } catch {}
|
|
4109
|
+
}
|
|
4110
|
+
autoTour()
|
|
4111
|
+
}
|
|
4112
|
+
// The product tour (0.9.8, ui/tour.js): by itself once per Mac, after setup, on
|
|
4113
|
+
// the board; #/tour (Settings → Updates & about, the palette) replays it.
|
|
4114
|
+
async function autoTour() {
|
|
4115
|
+
try { const t = await api('GET', '/v1/tour'); if (shouldAutoTour(t, parseRoute().name)) startTour({ api, auto: true }) } catch {}
|
|
3348
4116
|
}
|
|
3349
4117
|
|
|
3350
4118
|
// A remote browser (Tailscale) that hasn't signed in yet: the iPhone
|
|
@@ -3403,7 +4171,9 @@ async function refreshHealth() {
|
|
|
3403
4171
|
if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
|
|
3404
4172
|
paintMoreDots()
|
|
3405
4173
|
paintSettingsDots()
|
|
4174
|
+
if (updateBar) updateBar.check(h)
|
|
3406
4175
|
} catch (e) {
|
|
4176
|
+
if (updateBar) updateBar.check(null) // a failed check changes nothing
|
|
3407
4177
|
el.className = 'health down'
|
|
3408
4178
|
el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
|
|
3409
4179
|
el.title = el.querySelector('.label').textContent
|
|
@@ -3436,12 +4206,12 @@ function paintTopDot() {
|
|
|
3436
4206
|
}
|
|
3437
4207
|
|
|
3438
4208
|
// ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
|
|
3439
|
-
// From
|
|
4209
|
+
// From 700px up the sections sit in a rail on the left; under it, in a bottom
|
|
3440
4210
|
// bar with the rest under More. Hover the Needs you item (or long-press it on a
|
|
3441
4211
|
// phone, or press ↓/→ on it) for Quick look: the count and the five longest
|
|
3442
4212
|
// waits, with Allow / Deny on permission prompts (the Needs you page's own
|
|
3443
4213
|
// buttons, so the same checks apply and nothing is answered without a click).
|
|
3444
|
-
const railMode = () => window.matchMedia('(min-width:
|
|
4214
|
+
const railMode = () => window.matchMedia('(min-width: 700px)').matches
|
|
3445
4215
|
function paintNav(r = state.route || parseRoute()) {
|
|
3446
4216
|
const key = navKey(r.name)
|
|
3447
4217
|
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') })
|
|
@@ -3470,6 +4240,7 @@ function openMore() {
|
|
|
3470
4240
|
if (!m) return
|
|
3471
4241
|
closeQuickLook(); toggleStatus(false)
|
|
3472
4242
|
paintMoreDots()
|
|
4243
|
+
paintPush()
|
|
3473
4244
|
m.hidden = false
|
|
3474
4245
|
if (b) b.setAttribute('aria-expanded', 'true')
|
|
3475
4246
|
const first = m.querySelector('a.on') || m.querySelector('a')
|
|
@@ -3598,6 +4369,214 @@ function closeQuickLook(focusTab = false) {
|
|
|
3598
4369
|
window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
|
|
3599
4370
|
}
|
|
3600
4371
|
|
|
4372
|
+
// ------------------------------------------------------------ tabs for sessions (0.9.8, ui/tabs.js)
|
|
4373
|
+
// The board is the first tab; a session opens as the one preview tab (italic)
|
|
4374
|
+
// that the next click replaces, and stays open once you send a Live message,
|
|
4375
|
+
// type there, choose Keep open or double-click its tab (⌘/Ctrl-click or a
|
|
4376
|
+
// middle-click opens it permanent straight away). Kept per device in
|
|
4377
|
+
// localStorage. By width: under 700px the Open row on the board and ‹ N of M ›
|
|
4378
|
+
// (or a swipe) on a session page; from 700px the strip. A width change (fold,
|
|
4379
|
+
// Split View, rotation) only changes the CSS: the open set, the session and
|
|
4380
|
+
// where you were on the page stay.
|
|
4381
|
+
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: {} }
|
|
4382
|
+
window.leftoffTabs = { model: () => JSON.parse(serialize(tabsUi.m)) } // read by test:tabs-ui
|
|
4383
|
+
function setTabs(m) {
|
|
4384
|
+
if (m === tabsUi.m) return
|
|
4385
|
+
tabsUi.m = m
|
|
4386
|
+
try { localStorage.setItem(TABS_KEY, serialize(m)) } catch {}
|
|
4387
|
+
}
|
|
4388
|
+
const tabSession = (id) => state.sessions.find((s) => s.id === id)
|
|
4389
|
+
const tabViews = () => tabsUi.m.tabs.map((t) => tabView(t, tabSession(t.id), { title: sessionTitle, working: (s) => liveWorking(s) }))
|
|
4390
|
+
function tabsOnRoute(r) {
|
|
4391
|
+
const id = r.name === 's' && r.arg ? String(r.arg) : null
|
|
4392
|
+
if (tabsUi.active && tabsUi.active !== id) tabsUi.scrolls[tabsUi.active] = window.scrollY || 0
|
|
4393
|
+
if (id) {
|
|
4394
|
+
const permanent = tabsUi.permanent === id
|
|
4395
|
+
setTabs(openTab(tabsUi.m, id, { permanent, hash: keepHash(location.hash), title: (tabSession(id) && sessionTitle(tabSession(id))) || '' }))
|
|
4396
|
+
}
|
|
4397
|
+
tabsUi.permanent = null
|
|
4398
|
+
const changed = tabsUi.active !== id || tabsUi.route !== r.name
|
|
4399
|
+
tabsUi.active = id; tabsUi.route = r.name
|
|
4400
|
+
document.body.dataset.route = r.name
|
|
4401
|
+
paintTabs()
|
|
4402
|
+
if (changed) scrollActiveTab()
|
|
4403
|
+
}
|
|
4404
|
+
function tabsAfterRender() {
|
|
4405
|
+
let m = tabsUi.m
|
|
4406
|
+
for (const t of m.tabs) { const s = tabSession(t.id); if (s) m = remember(m, t.id, { title: sessionTitle(s) }) }
|
|
4407
|
+
setTabs(m)
|
|
4408
|
+
paintTabs()
|
|
4409
|
+
const back = tabsUi.active && tabsUi.restoreScroll === tabsUi.active ? tabsUi.scrolls[tabsUi.active] : null
|
|
4410
|
+
tabsUi.restoreScroll = null
|
|
4411
|
+
if (back != null) window.scrollTo(0, back)
|
|
4412
|
+
noteAnchor()
|
|
4413
|
+
// the other open tabs' pulse and dot: read the sessions again now and then while you're off the board
|
|
4414
|
+
if (tabsUi.route !== 'board' && tabsUi.m.tabs.some((t) => t.id !== tabsUi.active) && Date.now() - tabsUi.fetchedAt > 15000) refreshTabSessions()
|
|
4415
|
+
}
|
|
4416
|
+
async function refreshTabSessions() {
|
|
4417
|
+
tabsUi.fetchedAt = Date.now()
|
|
4418
|
+
try {
|
|
4419
|
+
const { sessions = [], parked = [] } = await api('GET', '/v1/sessions?limit=200')
|
|
4420
|
+
const fresh = new Map(sessions.concat(parked).map((s) => [s.id, s]))
|
|
4421
|
+
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)))
|
|
4422
|
+
paintTabs()
|
|
4423
|
+
} catch {}
|
|
4424
|
+
}
|
|
4425
|
+
function paintTabs() {
|
|
4426
|
+
const views = tabViews()
|
|
4427
|
+
const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
|
|
4428
|
+
if (!strip) return
|
|
4429
|
+
const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
|
|
4430
|
+
if (sh !== tabsUi.html.strip) {
|
|
4431
|
+
const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
|
|
4432
|
+
const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
|
|
4433
|
+
strip.innerHTML = sh; tabsUi.html.strip = sh
|
|
4434
|
+
const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
|
|
4435
|
+
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 }) }
|
|
4436
|
+
}
|
|
4437
|
+
strip.hidden = false
|
|
4438
|
+
const rh = openRowHtml(views, esc)
|
|
4439
|
+
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 }
|
|
4440
|
+
row.hidden = !rh
|
|
4441
|
+
const ph = tabsUi.active ? pagerHtml(tabsUi.m, tabsUi.active, views, esc) : ''
|
|
4442
|
+
if (ph !== tabsUi.html.pager) { pager.innerHTML = ph; tabsUi.html.pager = ph }
|
|
4443
|
+
pager.hidden = !ph
|
|
4444
|
+
}
|
|
4445
|
+
function scrollActiveTab() {
|
|
4446
|
+
const el = document.querySelector('#tabStrip .tab-cell.on'), sc = document.querySelector('#tabStrip .tab-scroll')
|
|
4447
|
+
if (!el || !sc) return
|
|
4448
|
+
const r = el.getBoundingClientRect(), b = sc.getBoundingClientRect()
|
|
4449
|
+
if (r.left < b.left) sc.scrollLeft -= b.left - r.left + 8
|
|
4450
|
+
else if (r.right > b.right) sc.scrollLeft += r.right - b.right + 8
|
|
4451
|
+
}
|
|
4452
|
+
function keepTab(id) { setTabs(promote(tabsUi.m, id)); paintTabs() }
|
|
4453
|
+
function closeTabNow(id) {
|
|
4454
|
+
const { model, next } = closeTab(tabsUi.m, id, tabsUi.active)
|
|
4455
|
+
setTabs(model)
|
|
4456
|
+
delete tabsUi.scrolls[id]
|
|
4457
|
+
if (tabsUi.active === String(id)) {
|
|
4458
|
+
const t = next && model.tabs.find((x) => x.id === next)
|
|
4459
|
+
if (t) tabsUi.restoreScroll = t.id
|
|
4460
|
+
location.hash = t ? tabView(t).href : '#/'
|
|
4461
|
+
} else paintTabs()
|
|
4462
|
+
}
|
|
4463
|
+
function goTab(href) {
|
|
4464
|
+
const id = sessionOfHash(href)
|
|
4465
|
+
if (id) tabsUi.restoreScroll = id
|
|
4466
|
+
if (location.hash === href) render(); else location.hash = href
|
|
4467
|
+
}
|
|
4468
|
+
// ⌘/Ctrl-click or a middle-click on any session link: open it as a permanent tab (here, not a new browser tab)
|
|
4469
|
+
function sessionLinkId(a) { return a && a.getAttribute && sessionOfHash(a.getAttribute('href') || '') }
|
|
4470
|
+
document.addEventListener('click', (e) => {
|
|
4471
|
+
const a = e.target.closest && e.target.closest('a[href^="#/s/"]')
|
|
4472
|
+
if (!a || !(e.metaKey || e.ctrlKey) || e.button !== 0) return
|
|
4473
|
+
const id = sessionLinkId(a); if (!id) return
|
|
4474
|
+
e.preventDefault(); e.stopPropagation()
|
|
4475
|
+
if (indexOf(tabsUi.m, id) >= 0) setTabs(promote(tabsUi.m, id)); else tabsUi.permanent = id
|
|
4476
|
+
if (location.hash === a.getAttribute('href')) render(); else location.hash = a.getAttribute('href')
|
|
4477
|
+
}, true)
|
|
4478
|
+
document.addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest && e.target.closest('a[href^="#/s/"], #tabStrip .tab-cell')) e.preventDefault() }) // no autoscroll
|
|
4479
|
+
document.addEventListener('auxclick', (e) => {
|
|
4480
|
+
if (e.button !== 1 || !e.target.closest) return
|
|
4481
|
+
const cell = e.target.closest('#tabStrip .tab-cell')
|
|
4482
|
+
if (cell) { e.preventDefault(); closeTabNow(cell.dataset.tabId); return } // middle-click a tab: close it
|
|
4483
|
+
const a = e.target.closest('a[href^="#/s/"]')
|
|
4484
|
+
const id = sessionLinkId(a); if (!id) return
|
|
4485
|
+
e.preventDefault()
|
|
4486
|
+
if (indexOf(tabsUi.m, id) >= 0) { setTabs(promote(tabsUi.m, id)); paintTabs() }
|
|
4487
|
+
else { setTabs(openTab(tabsUi.m, id, { permanent: true })); paintTabs() } // opened in the background, the way a middle-click does
|
|
4488
|
+
})
|
|
4489
|
+
document.addEventListener('click', (e) => {
|
|
4490
|
+
if (!e.target.closest) return
|
|
4491
|
+
const close = e.target.closest('[data-tab-close]')
|
|
4492
|
+
if (close) { e.preventDefault(); return closeTabNow(close.dataset.tabClose) }
|
|
4493
|
+
const keep = e.target.closest('[data-tab-keep]')
|
|
4494
|
+
if (keep) { e.preventDefault(); return keepTab(keep.dataset.tabKeep) }
|
|
4495
|
+
const go = e.target.closest('[data-tab-go]')
|
|
4496
|
+
if (go) { e.preventDefault(); return goTab(go.dataset.tabGo) }
|
|
4497
|
+
const all = e.target.closest('#tabAll')
|
|
4498
|
+
if (all) { e.preventDefault(); return openActionMenu(all, allTabsItems(tabViews(), tabsUi.active), { title: 'Open sessions' }) }
|
|
4499
|
+
const t = e.target.closest('#tabStrip a[data-tab], #openRow a[data-tab], #tabPager a[data-tab]')
|
|
4500
|
+
if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
|
|
4501
|
+
})
|
|
4502
|
+
document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
|
|
4503
|
+
// Working in a session keeps its tab: a Live message, typing there, answering Claude there
|
|
4504
|
+
const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
|
|
4505
|
+
for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
|
|
4506
|
+
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)
|
|
4507
|
+
// Drag a tab to reorder
|
|
4508
|
+
{
|
|
4509
|
+
let dragId = null
|
|
4510
|
+
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 {} })
|
|
4511
|
+
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') })
|
|
4512
|
+
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() } })
|
|
4513
|
+
document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('#tabStrip .dragging, #tabStrip .drop-before').forEach((x) => x.classList.remove('dragging', 'drop-before')) })
|
|
4514
|
+
}
|
|
4515
|
+
// Phones: swipe left / right on a session page for the next / previous open session
|
|
4516
|
+
{
|
|
4517
|
+
let sw = null
|
|
4518
|
+
const narrowQ = window.matchMedia('(max-width: 699.98px)')
|
|
4519
|
+
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 }
|
|
4520
|
+
document.addEventListener('touchstart', (e) => {
|
|
4521
|
+
sw = null
|
|
4522
|
+
if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
|
|
4523
|
+
const t = e.target
|
|
4524
|
+
if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .moremenu') || scrollsSideways(t)) return
|
|
4525
|
+
sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
|
|
4526
|
+
}, { passive: true })
|
|
4527
|
+
document.addEventListener('touchend', (e) => {
|
|
4528
|
+
if (!sw || !e.changedTouches.length) return
|
|
4529
|
+
const p = e.changedTouches[0]
|
|
4530
|
+
const dir = swipeDir(p.clientX - sw.x, p.clientY - sw.y, Date.now() - sw.at)
|
|
4531
|
+
sw = null
|
|
4532
|
+
if (!dir || (window.getSelection && String(window.getSelection()))) return
|
|
4533
|
+
const n = neighbours(tabsUi.m, tabsUi.active)
|
|
4534
|
+
const to = dir === 'next' ? n.next : n.prev
|
|
4535
|
+
if (!to) return
|
|
4536
|
+
const t = tabsUi.m.tabs.find((x) => x.id === to)
|
|
4537
|
+
document.body.classList.remove('swipe-next', 'swipe-prev'); void document.body.offsetWidth
|
|
4538
|
+
document.body.classList.add('swipe-' + dir); setTimeout(() => document.body.classList.remove('swipe-' + dir), 260)
|
|
4539
|
+
goTab(tabView(t).href)
|
|
4540
|
+
}, { passive: true })
|
|
4541
|
+
}
|
|
4542
|
+
// A width change keeps your place: the element at the top of the page stays at
|
|
4543
|
+
// the top, and a Live chat that was at its newest stays there.
|
|
4544
|
+
function anchorY() {
|
|
4545
|
+
let y = 0
|
|
4546
|
+
for (const el of [document.querySelector('.top'), document.getElementById('tabStrip'), document.getElementById('tabPager')]) {
|
|
4547
|
+
if (!el) continue
|
|
4548
|
+
const cs = getComputedStyle(el); if (cs.display === 'none' || !/(fixed|sticky)/.test(cs.position)) continue
|
|
4549
|
+
const r = el.getBoundingClientRect(); if (r.top <= 1 && r.bottom < innerHeight / 2) y = Math.max(y, r.bottom)
|
|
4550
|
+
}
|
|
4551
|
+
return y
|
|
4552
|
+
}
|
|
4553
|
+
function noteAnchor() {
|
|
4554
|
+
if (Date.now() < tabsUi.quietUntil) return
|
|
4555
|
+
const y = anchorY() + 6, m = $app.getBoundingClientRect()
|
|
4556
|
+
if (m.bottom < y) { tabsUi.anchor = null; return }
|
|
4557
|
+
let el = document.elementFromPoint(Math.max(m.left + 24, Math.min(m.left + m.width / 2, innerWidth - 24)), y)
|
|
4558
|
+
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 }
|
|
4559
|
+
tabsUi.anchor = el ? { el, top: el.getBoundingClientRect().top, scrollY: window.scrollY } : null
|
|
4560
|
+
}
|
|
4561
|
+
function keepPlace() {
|
|
4562
|
+
const a = tabsUi.anchor
|
|
4563
|
+
tabsUi.quietUntil = Date.now() + 250
|
|
4564
|
+
if (a && a.el.isConnected && a.scrollY > 0) window.scrollBy(0, a.el.getBoundingClientRect().top - a.top)
|
|
4565
|
+
document.querySelectorAll('.live-log').forEach((log) => { const st = tabsUi.logs.get(log); if (st && st.bottom) log.scrollTop = log.scrollHeight })
|
|
4566
|
+
}
|
|
4567
|
+
{
|
|
4568
|
+
let raf = 0
|
|
4569
|
+
window.addEventListener('scroll', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; noteAnchor() }) }, { passive: true })
|
|
4570
|
+
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)
|
|
4571
|
+
let lay = layoutFor(innerWidth)
|
|
4572
|
+
window.addEventListener('resize', () => {
|
|
4573
|
+
keepPlace()
|
|
4574
|
+
const now = layoutFor(innerWidth)
|
|
4575
|
+
if (now !== lay) { lay = now; document.body.dataset.layout = now; scrollActiveTab() }
|
|
4576
|
+
})
|
|
4577
|
+
document.body.dataset.layout = lay
|
|
4578
|
+
}
|
|
4579
|
+
|
|
3601
4580
|
// Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
|
|
3602
4581
|
// past the line; the page reads its data again, the same as a live update (no
|
|
3603
4582
|
// full reload unless that fails). Never from a field, the Live box or a menu.
|
|
@@ -3606,8 +4585,8 @@ function closeQuickLook(focusTab = false) {
|
|
|
3606
4585
|
ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
|
|
3607
4586
|
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
4587
|
document.body.appendChild(ptr)
|
|
3609
|
-
const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width:
|
|
3610
|
-
const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
|
|
4588
|
+
const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
|
|
4589
|
+
const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden]), .emoji-sheet-wrap')
|
|
3611
4590
|
let pull = null, refreshing = false
|
|
3612
4591
|
const paint = (step) => {
|
|
3613
4592
|
ptr.hidden = !step || (!step.pulling && !refreshing)
|
|
@@ -3657,6 +4636,43 @@ function closeQuickLook(focusTab = false) {
|
|
|
3657
4636
|
document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
|
|
3658
4637
|
}
|
|
3659
4638
|
|
|
4639
|
+
// Hide the nav on scroll (phones, 0.9.8): under 700px the top row and the bottom
|
|
4640
|
+
// bar slide away while you scroll down and come back when you scroll up, reach
|
|
4641
|
+
// the top or the very bottom (ui/navhide.js). Only your own scrolling hides
|
|
4642
|
+
// them: a route change, the ‹ › pager or a swipe shows them and holds them until
|
|
4643
|
+
// you next touch, wheel or key; typing, a menu or a sheet keeps them shown. The
|
|
4644
|
+
// page never reflows: the top row stays in the flow and the bar is an overlay
|
|
4645
|
+
// over padding that stays.
|
|
4646
|
+
{
|
|
4647
|
+
const narrowQ = window.matchMedia('(max-width: 699.98px)')
|
|
4648
|
+
const top = document.querySelector('.top')
|
|
4649
|
+
let st = { hidden: false, anchor: 0 }, raf = 0, held = true
|
|
4650
|
+
const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
|
|
4651
|
+
const overlay = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .lo-backdrop, .doc-viewer:not([hidden]), .lightbox:not([hidden]), .pdfview:not([hidden]), .emoji-sheet-wrap, .relay-screen:not([hidden])')
|
|
4652
|
+
const apply = (hidden) => {
|
|
4653
|
+
if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
|
|
4654
|
+
document.body.classList.toggle('nav-hidden', hidden)
|
|
4655
|
+
}
|
|
4656
|
+
const read = () => {
|
|
4657
|
+
raf = 0
|
|
4658
|
+
const se = document.scrollingElement || document.documentElement
|
|
4659
|
+
const y = window.scrollY || se.scrollTop || 0
|
|
4660
|
+
const next = navHideStep(st, { y, max: se.scrollHeight - window.innerHeight, blocked: held || !narrowQ.matches || typing() || overlay() })
|
|
4661
|
+
if (next.hidden !== st.hidden) apply(next.hidden)
|
|
4662
|
+
st = next
|
|
4663
|
+
}
|
|
4664
|
+
const soon = () => { if (!raf) raf = requestAnimationFrame(read) }
|
|
4665
|
+
// no layout read here: this runs on every route change, ahead of the page's own render
|
|
4666
|
+
const showNav = (hold = true) => { if (hold) held = true; const was = st.hidden; st = { hidden: false, anchor: NaN }; if (was) apply(false) }
|
|
4667
|
+
window.addEventListener('scroll', soon, { passive: true })
|
|
4668
|
+
for (const ev of ['touchstart', 'wheel', 'keydown']) window.addEventListener(ev, () => { held = false }, { passive: true, capture: true })
|
|
4669
|
+
window.addEventListener('hashchange', () => showNav(true))
|
|
4670
|
+
document.addEventListener('focusin', (e) => { if (typing() || (top && top.contains(e.target))) showNav(false) })
|
|
4671
|
+
document.addEventListener('click', soon, { passive: true, capture: true }) // a menu or sheet just opened: back they come
|
|
4672
|
+
narrowQ.addEventListener('change', () => showNav(false))
|
|
4673
|
+
window.leftoffNav = { show: () => showNav(true), hidden: () => document.body.classList.contains('nav-hidden') }
|
|
4674
|
+
}
|
|
4675
|
+
|
|
3660
4676
|
// Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
|
|
3661
4677
|
const statusOpen = () => { const p = document.getElementById('statusPop'); return p && !p.hidden }
|
|
3662
4678
|
function syncLine() {
|
|
@@ -3790,6 +4806,7 @@ function paletteActions() {
|
|
|
3790
4806
|
}
|
|
3791
4807
|
if (projectsUi.mayEdit) out.push({ action: 'project-new', title: 'New project…', sub: 'a named group of repos', icon: 'plus' })
|
|
3792
4808
|
out.push({ action: 'gen-digest-go', title: 'Today’s digest', sub: 'open it', icon: 'newspaper', href: '#/digest' })
|
|
4809
|
+
out.push({ action: 'tour', title: 'Take the tour', sub: 'a quick look around the board', icon: 'sparkles', href: '#/tour' })
|
|
3793
4810
|
return out
|
|
3794
4811
|
}
|
|
3795
4812
|
function searchGroups(q, r) {
|
|
@@ -3939,11 +4956,27 @@ document.addEventListener('keydown', (e) => {
|
|
|
3939
4956
|
|
|
3940
4957
|
// A card opens its session's page from anywhere on it, except its buttons,
|
|
3941
4958
|
// links, fields and selected text (0.4.3: the timeline was too hidden).
|
|
4959
|
+
// 0.9.8: the tap shows at once (a pressed card with a spinner); the page's
|
|
4960
|
+
// shell follows in the same frame (viewSession).
|
|
4961
|
+
function cardOpening(c) {
|
|
4962
|
+
for (const x of document.querySelectorAll('.card.opening')) if (x !== c) x.classList.remove('opening')
|
|
4963
|
+
c.classList.add('opening')
|
|
4964
|
+
setTimeout(() => { if (c.isConnected) c.classList.remove('opening') }, 4000)
|
|
4965
|
+
}
|
|
4966
|
+
document.addEventListener('pointerdown', (ev) => {
|
|
4967
|
+
const c = ev.target.closest && ev.target.closest('.card[data-href]')
|
|
4968
|
+
if (!c || ev.button > 0 || ev.target.closest('button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
|
|
4969
|
+
const a = ev.target.closest('a')
|
|
4970
|
+
if (a && !/^#\/s\//.test(a.getAttribute('href') || '')) return // a repo chip, a PR link: not opening the session
|
|
4971
|
+
cardOpening(c)
|
|
4972
|
+
}, { passive: true })
|
|
4973
|
+
document.addEventListener('pointercancel', () => { for (const x of document.querySelectorAll('.card.opening')) x.classList.remove('opening') }, true)
|
|
3942
4974
|
document.addEventListener('click', (ev) => {
|
|
3943
4975
|
const c = ev.target.closest('.card[data-href]')
|
|
3944
4976
|
if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
|
|
3945
4977
|
if (String(window.getSelection && window.getSelection()).trim()) return
|
|
3946
4978
|
if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
|
|
4979
|
+
cardOpening(c)
|
|
3947
4980
|
location.hash = c.dataset.href
|
|
3948
4981
|
})
|
|
3949
4982
|
|
|
@@ -3990,7 +5023,14 @@ document.addEventListener('click', async (ev) => {
|
|
|
3990
5023
|
const id = btn.dataset.id || (card && card.dataset.id)
|
|
3991
5024
|
const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
|
|
3992
5025
|
try {
|
|
3993
|
-
if (action === 'copy-cmd') {
|
|
5026
|
+
if (action === 'copy-cmd') {
|
|
5027
|
+
const ok = await copy(btn.dataset.cmd)
|
|
5028
|
+
if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
|
|
5029
|
+
clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
|
|
5030
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy Live resume'); btn.dataset.tip = 'Copy' }, 1500)
|
|
5031
|
+
}
|
|
5032
|
+
return
|
|
5033
|
+
}
|
|
3994
5034
|
// PRs (0.6.9)
|
|
3995
5035
|
if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = prsHash(v, prsUi.filter); return }
|
|
3996
5036
|
// 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
|
|
@@ -4093,6 +5133,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
4093
5133
|
if (action === 'list-showall') { listUi.showAll = !listUi.showAll; return render() }
|
|
4094
5134
|
if (action === 'list-help') { listUi.help = !listUi.help; return render() }
|
|
4095
5135
|
if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
|
|
5136
|
+
if (action === 'list-help-inline') { listUi.sHelp = !listUi.sHelp; render(); return }
|
|
4096
5137
|
if (action === 'remote-link' || action === 'remote-code') {
|
|
4097
5138
|
const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
|
|
4098
5139
|
if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
|
|
@@ -4280,6 +5321,19 @@ document.addEventListener('click', async (ev) => {
|
|
|
4280
5321
|
saveSpace(state.digestSpace)
|
|
4281
5322
|
return paintDigestFilter()
|
|
4282
5323
|
}
|
|
5324
|
+
if (action === 'digest-cal-fold') {
|
|
5325
|
+
digestUi.calOpen = !digestUi.calOpen
|
|
5326
|
+
repaintCalendar()
|
|
5327
|
+
return
|
|
5328
|
+
}
|
|
5329
|
+
if (action === 'digest-month') {
|
|
5330
|
+
const back = btn.dataset.to < (digestUi.month || '')
|
|
5331
|
+
digestUi.month = btn.dataset.to
|
|
5332
|
+
digestUi.calOpen = true // another month: show all of it
|
|
5333
|
+
const nav = repaintCalendar()
|
|
5334
|
+
if (nav && btn.disabled) { const [p, n] = nav.querySelectorAll('.cal-nav'); const b = back ? n : p; if (!b.disabled) b.focus() } // the end of the range: focus stays on the arrows
|
|
5335
|
+
return
|
|
5336
|
+
}
|
|
4283
5337
|
if (action === 'standup-copy') return standupCopy(btn)
|
|
4284
5338
|
if (action === 'standup-close') { const b = btn.closest('.standup-out'); if (b) b.remove(); digestUi.open = null; return }
|
|
4285
5339
|
if (action === 'space-quick' || action === 'space-repo-add' || action === 'space-unset') return spacesAction(action, btn)
|
|
@@ -4305,7 +5359,13 @@ document.addEventListener('click', async (ev) => {
|
|
|
4305
5359
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
4306
5360
|
if (action === 'reply-send') return replyClick(btn)
|
|
4307
5361
|
if (action === 'live-send') return liveSendClick(btn)
|
|
5362
|
+
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
|
|
5363
|
+
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
5364
|
+
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
5365
|
+
if (action === 'att-open') return openAttachment(btn)
|
|
5366
|
+
if (action === 'att-chip-open') return openChip(btn.dataset.lid)
|
|
4308
5367
|
if (action === 'live-jump') return liveJumpClick(btn)
|
|
5368
|
+
if (action === 'live-earlier') return liveEarlierClick(btn)
|
|
4309
5369
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
4310
5370
|
if (action === 'need-more') return needMoreClick(btn)
|
|
4311
5371
|
if (action === 'need-dismiss') return needDismissClick(btn)
|
|
@@ -4791,16 +5851,21 @@ let stream = null
|
|
|
4791
5851
|
function connect() {
|
|
4792
5852
|
if (stream) { try { stream.close() } catch {} }
|
|
4793
5853
|
const es = stream = new EventSource('/v1/stream')
|
|
5854
|
+
const again = connect.opened // a reconnect: events may have been missed while it was down
|
|
5855
|
+
streamDog.opened()
|
|
5856
|
+
// 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
|
|
5857
|
+
es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
|
|
4794
5858
|
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
4795
5859
|
es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
|
|
4796
|
-
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)
|
|
5860
|
+
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() })
|
|
4797
5861
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
4798
5862
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
4799
5863
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
|
4800
5864
|
// A daemon that restarted (an upgrade, a folder move) comes back on the same
|
|
4801
5865
|
// port; if the browser gave up on the stream, open a new one.
|
|
4802
5866
|
es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
|
|
4803
|
-
|
|
5867
|
+
let opens = 0
|
|
5868
|
+
es.onopen = () => { if (stream !== es) return; streamDog.seen(); refreshHealth(); if (again || opens++) refreshView('reconnect'); connect.opened = true }
|
|
4804
5869
|
}
|
|
4805
5870
|
|
|
4806
5871
|
document.addEventListener('change', async (ev) => {
|
|
@@ -4903,9 +5968,51 @@ document.addEventListener('pointerdown', (ev) => {
|
|
|
4903
5968
|
if (m && !m.contains(ev.target)) m.open = false
|
|
4904
5969
|
})
|
|
4905
5970
|
api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
|
|
5971
|
+
// 0.9.8: "Leftoff updated · Refresh" when the Mac serving this board has newer
|
|
5972
|
+
// files than the ones this page runs. It never reloads by itself; Refresh
|
|
5973
|
+
// carries your drafts over and says first what wouldn't come along.
|
|
5974
|
+
updateBar = mountUpdateBar({
|
|
5975
|
+
icon,
|
|
5976
|
+
health: () => refreshHealth(),
|
|
5977
|
+
risks: () => {
|
|
5978
|
+
let attachments = 0, uploading = 0
|
|
5979
|
+
for (const list of attachUi.by.values()) for (const a of list) { attachments++; if (a.status === 'prep' || a.status === 'up') uploading++ }
|
|
5980
|
+
const f = document.activeElement
|
|
5981
|
+
const typing = !!(f && /^(INPUT|TEXTAREA)$/.test(f.tagName) && !f.dataset.draft && !f.readOnly && !/^(checkbox|radio|button|submit|range|color|file|search)$/.test(f.type) && !f.closest('.cmdk, #settingsForm') && String(f.value || '').trim())
|
|
5982
|
+
let unsavedSettings = false
|
|
5983
|
+
try { unsavedSettings = settingsChanged().length > 0 } catch {}
|
|
5984
|
+
return { attachments, uploading, unsavedSettings, typing }
|
|
5985
|
+
},
|
|
5986
|
+
carry: () => {
|
|
5987
|
+
const drafts = { ...state.drafts }
|
|
5988
|
+
for (const t of document.querySelectorAll('textarea[data-draft]')) if (t.value) drafts[t.dataset.draft] = t.value
|
|
5989
|
+
const f = document.activeElement
|
|
5990
|
+
const focus = f && f.dataset && f.dataset.draft ? { key: f.dataset.draft, a: f.selectionStart || 0, b: f.selectionEnd || 0 } : lastDraftFocus
|
|
5991
|
+
return { drafts, focus }
|
|
5992
|
+
},
|
|
5993
|
+
})
|
|
5994
|
+
// The box you were typing in, if you tabbed from it to the bar.
|
|
5995
|
+
let lastDraftFocus = null
|
|
5996
|
+
document.addEventListener('focusin', (ev) => { if (!(ev.target.closest && ev.target.closest('#updateBar'))) lastDraftFocus = null })
|
|
5997
|
+
document.addEventListener('focusout', (ev) => { const t = ev.target; if (t && t.dataset && t.dataset.draft) lastDraftFocus = { key: t.dataset.draft, a: t.selectionStart || 0, b: t.selectionEnd || 0 } })
|
|
5998
|
+
// Back from Refresh: the box you were typing in has its text, and (with a
|
|
5999
|
+
// keyboard and mouse) the cursor where it was.
|
|
6000
|
+
function restoreCarriedFocus(tries = 50) {
|
|
6001
|
+
const f = carried && carried.focus
|
|
6002
|
+
if (!f || isTouchUi(window.matchMedia)) return
|
|
6003
|
+
if (document.activeElement && document.activeElement !== document.body) return // you've moved on
|
|
6004
|
+
const t = [...document.querySelectorAll('textarea[data-draft]')].find((x) => x.dataset.draft === f.key)
|
|
6005
|
+
if (!t || t.disabled) { if (tries > 0) setTimeout(() => restoreCarriedFocus(tries - 1), 100); return } // the session page draws after its data
|
|
6006
|
+
t.focus({ preventScroll: true })
|
|
6007
|
+
try { const n = t.value.length; t.setSelectionRange(Math.min(f.a, n), Math.min(f.b, n)) } catch {}
|
|
6008
|
+
}
|
|
4906
6009
|
refreshHealth().then(render).then(firstRunSetup)
|
|
6010
|
+
restoreCarriedFocus()
|
|
4907
6011
|
connect()
|
|
4908
6012
|
|
|
4909
6013
|
// The home-screen app's service worker (v0.4.6): it only serves an offline page
|
|
4910
6014
|
// when the Mac can't be reached; it caches no API data and no board pages.
|
|
4911
6015
|
if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
|
|
6016
|
+
// 0.9.8: phone notifications. The one per-device button (More on a phone,
|
|
6017
|
+
// Settings → General everywhere); the service worker shows the pushes.
|
|
6018
|
+
mountPush({ api })
|