@leftoff/cli 0.9.8-beta.2 → 0.9.8-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -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 +1165 -83
- 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,6 +834,7 @@ 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
|
}
|
|
@@ -849,6 +876,7 @@ function boardSearch(q, closedHits, n) {
|
|
|
849
876
|
// A repaint from a live update (or the 30 s refresh) while you type in the
|
|
850
877
|
// search box keeps the box focused, with what you typed and the caret.
|
|
851
878
|
function paintBoardKeepingSearch(h) {
|
|
879
|
+
if (parseRoute().name === 's') return // 0.9.8: a slow board answer never paints over the session you opened
|
|
852
880
|
const old = document.getElementById('boardQ')
|
|
853
881
|
const keep = old && document.activeElement === old ? { v: old.value, pos: old.selectionStart } : null
|
|
854
882
|
$app.innerHTML = h
|
|
@@ -928,10 +956,10 @@ function resumeButton(s, viewer = {}) {
|
|
|
928
956
|
const busy = closedUi.busy.has(s.id)
|
|
929
957
|
if (s.isLocal) {
|
|
930
958
|
if (!viewer.ghostty) return ''
|
|
931
|
-
return '<button type="button" class="btn primary" data-action="resume-open" data-id="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + ' title="
|
|
959
|
+
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
960
|
}
|
|
933
961
|
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>'
|
|
962
|
+
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
963
|
}
|
|
936
964
|
function closedCard(s, viewer) {
|
|
937
965
|
const href = '#/s/' + encodeURIComponent(s.id)
|
|
@@ -1248,8 +1276,8 @@ async function viewAgentSession(s) {
|
|
|
1248
1276
|
const name = esc(a.display || a.name)
|
|
1249
1277
|
const hint = typeof a.replyHint === 'string' && a.replyHint.trim() ? a.replyHint.trim() : ''
|
|
1250
1278
|
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>'
|
|
1279
|
+
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>' +
|
|
1280
|
+
'<div class="actions s-actions">' + agentReplyButton(a) + '</div></div>'
|
|
1253
1281
|
// v0.5.6: agent.url only links the name; the reply affordance and its
|
|
1254
1282
|
// words come from the feed (agent.reply, agent.replyHint), or nothing.
|
|
1255
1283
|
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 +1298,133 @@ async function viewAgentSession(s) {
|
|
|
1270
1298
|
$app.innerHTML = banners() + html
|
|
1271
1299
|
}
|
|
1272
1300
|
|
|
1301
|
+
// 0.9.8: another Mac's session whose Mac is offline says so plainly, once, near the top
|
|
1302
|
+
function ownerOffline(s) {
|
|
1303
|
+
if (!s || s.isLocal !== false || s.agent || !s.machine) return false
|
|
1304
|
+
const st = window.leftoffRelay && state.relayStatus
|
|
1305
|
+
const x = st && (st.macs || []).find((y) => y.machine === s.machine)
|
|
1306
|
+
if (x) return !x.online
|
|
1307
|
+
return !!(s.peer && s.peer.code === 'unreachable')
|
|
1308
|
+
}
|
|
1309
|
+
function ownerOfflineNote(s) {
|
|
1310
|
+
if (!ownerOffline(s)) return ''
|
|
1311
|
+
const m = esc(s.machine), sn = staleNote(s.machine)
|
|
1312
|
+
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>'
|
|
1313
|
+
}
|
|
1314
|
+
// 0.9.8: opening a session is instant. The tap paints the page's shell at
|
|
1315
|
+
// once (title and pills from the card we already have, or the whole page from
|
|
1316
|
+
// the last visit), then every fetch runs in parallel (the session, its Live
|
|
1317
|
+
// thread, spaces, projects, lists) instead of one after another; on a phone
|
|
1318
|
+
// through the relay each was a full round trip. A newer navigation wins: a
|
|
1319
|
+
// slow answer for a page you already left never paints over the one you're on.
|
|
1320
|
+
const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few sessions you opened
|
|
1321
|
+
const SESSION_CACHE_MAX = 8
|
|
1322
|
+
function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
|
|
1323
|
+
const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
|
|
1324
|
+
function sessionShellHtml(s) {
|
|
1325
|
+
const head = s
|
|
1326
|
+
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + statusPill(s) + liveBadge(s) + '</div>'
|
|
1327
|
+
: '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
|
|
1328
|
+
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>' +
|
|
1329
|
+
'<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>' +
|
|
1330
|
+
'<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>'
|
|
1331
|
+
}
|
|
1332
|
+
// A selector that finds the same control again after a redraw (null: it can't be told apart).
|
|
1333
|
+
function focusKey(el) {
|
|
1334
|
+
if (!el || !el.tagName) return null
|
|
1335
|
+
if (el.id) return '#' + CSS.escape(el.id)
|
|
1336
|
+
const d = el.dataset || {}
|
|
1337
|
+
const keys = ['action', 'lid', 'sid', 'att', 'id', 'tab', 'submitFor'].filter((k) => d[k] != null)
|
|
1338
|
+
if (!keys.length) return null
|
|
1339
|
+
const attr = (k) => '[data-' + k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + '="' + CSS.escape(d[k]) + '"]'
|
|
1340
|
+
return el.tagName.toLowerCase() + keys.map(attr).join('')
|
|
1341
|
+
}
|
|
1342
|
+
function paintSession(s, data) {
|
|
1343
|
+
if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
|
|
1344
|
+
// a redraw (the live poll, a settled reply) keeps the composer focused with its caret where it was
|
|
1345
|
+
const fa = document.activeElement
|
|
1346
|
+
const kf = fa && fa.classList && fa.classList.contains('composer-input') && $app.contains(fa) ? { d: fa.dataset.draft, a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
1347
|
+
// …and any other control that had focus (a chip, a thumbnail, a button: a
|
|
1348
|
+
// preview closed with Esc puts focus back on one, then a redraw replaced it)
|
|
1349
|
+
const kb = !kf && fa && fa !== document.body && $app.contains(fa) ? focusKey(fa) : null
|
|
1350
|
+
liveKeeper.before($app)
|
|
1351
|
+
$app.innerHTML = sessionPageHtml(s, data)
|
|
1352
|
+
liveAfterRender(s.id)
|
|
1353
|
+
if (kf) {
|
|
1354
|
+
const nt = $app.querySelector('.composer-input')
|
|
1355
|
+
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 {} }
|
|
1356
|
+
} else if (kb && !$app.contains(document.activeElement)) {
|
|
1357
|
+
const hits = $app.querySelectorAll(kb)
|
|
1358
|
+
if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true })
|
|
1359
|
+
}
|
|
1360
|
+
aroundWire(s.id)
|
|
1361
|
+
}
|
|
1273
1362
|
async function viewSession(id) {
|
|
1274
1363
|
if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
|
|
1275
|
-
const
|
|
1364
|
+
const showing = $app.querySelector('.s-head[data-sid="' + CSS.escape(id) + '"]') // a re-render of the page you're on: no shell
|
|
1365
|
+
const cached = sessionCache.get(id)
|
|
1366
|
+
const known = (cached && cached.s) || state.sessions.find((x) => x.id === id) || null
|
|
1367
|
+
if (!showing) {
|
|
1368
|
+
if (cached) paintSession(cached.s, cached) // the last visit, at once; fresh data replaces it below
|
|
1369
|
+
else { liveKeeper.before($app); $app.innerHTML = sessionShellHtml(known) }
|
|
1370
|
+
}
|
|
1371
|
+
// on leftoff.dev, another Mac's session loads its data from that Mac: say so before the first call
|
|
1372
|
+
const owner = (x) => { if (x && window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(x.id, x.isLocal === false && !x.agent ? x.machine : null) }
|
|
1373
|
+
owner(known)
|
|
1374
|
+
const pS = api('GET', '/v1/sessions/' + encodeURIComponent(id))
|
|
1375
|
+
const pLive = !known || liveWanted(known) ? fetchLive(id) : null // the thread doesn't wait for the session
|
|
1376
|
+
const pLists = known && known.lists && known.lists.length ? fetchLists(id) : null
|
|
1377
|
+
const pRest = Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
|
|
1378
|
+
let s
|
|
1379
|
+
try { s = await pS } catch (e) { if (!onSession(id)) return; throw e }
|
|
1276
1380
|
state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
|
|
1381
|
+
owner(s)
|
|
1382
|
+
const status = s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null
|
|
1383
|
+
const lists = s.lists && s.lists.length ? (pLists || fetchLists(id)) : null
|
|
1384
|
+
const live = liveWanted(s) ? (pLive || fetchLive(id)) : !isLive(s) && !s.endedAt && s.live !== 'closed' ? liveHow().then(() => null) : null
|
|
1385
|
+
let [[spv], v, sl] = await Promise.all([pRest, live, lists, status])
|
|
1386
|
+
if (!onSession(id)) return // you moved on while this loaded
|
|
1387
|
+
// a Live fetch that failed keeps the thread you had (its bubbles say "Reconnecting…"), not an empty one
|
|
1388
|
+
const prev = sessionCache.get(id)
|
|
1389
|
+
if (v && v.error && prev && prev.live && !prev.live.error) v = { ...prev.live, error: null }
|
|
1390
|
+
const data = { spv, live: v, lists: sl }
|
|
1391
|
+
if (v && !v.error) cacheSession(id, { s, ...data, live: v })
|
|
1392
|
+
paintSession(s, data)
|
|
1393
|
+
}
|
|
1394
|
+
const fetchLists = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists').catch(() => null)
|
|
1395
|
+
function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {}) {
|
|
1277
1396
|
const branch = s.repo && s.repo.branch
|
|
1278
|
-
const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
|
|
1279
1397
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1280
1398
|
// 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
1399
|
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
|
-
|
|
1400
|
+
html += '<div class="pagehead s-head" data-sid="' + esc(s.id) + '"><div class="s-head-main">' + (canRename
|
|
1401
|
+
? '<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>'
|
|
1402
|
+
: '<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>' +
|
|
1403
|
+
// 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
|
|
1404
|
+
'<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>'
|
|
1405
|
+
html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
|
|
1286
1406
|
html += '<div class="facts">' +
|
|
1287
1407
|
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
1408
|
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
1409
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
1290
1410
|
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) : '') +
|
|
1411
|
+
if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
|
|
1292
1412
|
html += parkRow(s)
|
|
1293
1413
|
html += spacePicker(s, spv, 'session', s.id)
|
|
1294
|
-
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
1414
|
+
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
|
|
1295
1415
|
// 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
|
|
1296
1416
|
html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
|
|
1297
1417
|
html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
|
|
1298
1418
|
// 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
|
|
1299
1419
|
if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
|
|
1300
|
-
if (!isLive(s)) html +=
|
|
1420
|
+
if (!isLive(s)) html += liveBoxHtml(s, liveV)
|
|
1301
1421
|
if (s.statusChanges && s.statusChanges.length) {
|
|
1302
1422
|
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
1423
|
}
|
|
1304
1424
|
// Lists (v0.5.1): its saved lists in full, the hint, and Find lists in this session
|
|
1305
1425
|
if (s.lists && s.lists.length) {
|
|
1306
|
-
const sl = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists').catch(() => null)
|
|
1307
1426
|
state.listFull = {}
|
|
1308
|
-
for (const l of (
|
|
1427
|
+
for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l
|
|
1309
1428
|
}
|
|
1310
1429
|
state.listsMayEdit = state.mayAnswer !== false
|
|
1311
1430
|
html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
|
|
@@ -1323,10 +1442,7 @@ async function viewSession(id) {
|
|
|
1323
1442
|
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
1324
1443
|
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
1444
|
html += answeredList(s)
|
|
1326
|
-
|
|
1327
|
-
$app.innerHTML = html
|
|
1328
|
-
liveAfterRender(s.id)
|
|
1329
|
-
aroundWire(s.id)
|
|
1445
|
+
return html
|
|
1330
1446
|
}
|
|
1331
1447
|
// ------------------------------------------------------------ Live link (0.9.8 beta)
|
|
1332
1448
|
|
|
@@ -1349,17 +1465,28 @@ async function liveHow() {
|
|
|
1349
1465
|
if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
|
|
1350
1466
|
return state.liveHow
|
|
1351
1467
|
}
|
|
1352
|
-
|
|
1468
|
+
// Does this session's page show a Live thread (and so fetch one)?
|
|
1469
|
+
const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
|
|
1470
|
+
// GET the Live thread; what it says is the newest the page knows (0.9.8: the
|
|
1471
|
+
// time of the last good answer decides "Reconnecting…" on a pending bubble).
|
|
1472
|
+
async function fetchLive(id, { timeoutMs } = {}) {
|
|
1473
|
+
const turns = (state.liveTurns && state.liveTurns[id]) || 20 // "Show earlier" adds 20 at a time
|
|
1474
|
+
try {
|
|
1475
|
+
const v = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/live' + (turns > 20 ? '?turns=' + turns : ''), null, timeoutMs ? { timeoutMs } : {})
|
|
1476
|
+
noteLive(id, v)
|
|
1477
|
+
return v
|
|
1478
|
+
} catch (e) { return { messages: [], error: e.message } }
|
|
1479
|
+
}
|
|
1480
|
+
function liveBoxHtml(s, v) {
|
|
1353
1481
|
if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
|
|
1354
1482
|
if (!isLive(s)) {
|
|
1355
1483
|
if (s.endedAt || s.live === 'closed') return ''
|
|
1356
|
-
const how =
|
|
1484
|
+
const how = state.liveHow
|
|
1357
1485
|
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
1486
|
'<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
|
|
1487
|
+
(how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p>' +
|
|
1488
|
+
(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
1489
|
}
|
|
1361
|
-
let v = null
|
|
1362
|
-
try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live') } catch (e) { v = { messages: [], error: e.message } }
|
|
1363
1490
|
const msgs = (v && v.messages) || []
|
|
1364
1491
|
const may = state.mayAnswer !== false
|
|
1365
1492
|
const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
|
|
@@ -1368,17 +1495,343 @@ async function liveSection(s) {
|
|
|
1368
1495
|
if (lastClaude) markLiveSeen(s.id, lastClaude.at)
|
|
1369
1496
|
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
1497
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1371
|
-
|
|
1498
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
1372
1499
|
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
|
|
1373
1500
|
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
|
|
1374
1501
|
(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>' +
|
|
1502
|
+
composerHtml(s, draft, may) +
|
|
1377
1503
|
(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
1504
|
}
|
|
1505
|
+
// The Live composer: one rounded box, a textarea that starts at one line and
|
|
1506
|
+
// grows, and a round ↑ in its corner (dim until there's something to send).
|
|
1507
|
+
// No interrupt reaches Claude Code from the board, so while it works the
|
|
1508
|
+
// button still sends: the message waits for its next turn, as before.
|
|
1509
|
+
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>'
|
|
1510
|
+
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>'
|
|
1511
|
+
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>'
|
|
1512
|
+
function composerHtml(s, draft, may) {
|
|
1513
|
+
const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
|
|
1514
|
+
const atts = attachList(s.id)
|
|
1515
|
+
const off = !may || (isBlank(draft) && !atts.length)
|
|
1516
|
+
return '<div class="composer live-row' + (may ? '' : ' off') + (atts.length ? ' has-atts' : '') + '" data-sid="' + esc(s.id) + '">' +
|
|
1517
|
+
'<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
|
|
1518
|
+
'<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>' +
|
|
1519
|
+
// the phone's own picker (Photo Library, Take Photo, Choose File): any file
|
|
1520
|
+
'<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
|
|
1521
|
+
'<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>' +
|
|
1522
|
+
'<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>'
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
|
|
1526
|
+
// Each file uploads to the Mac that runs the session as soon as you attach it;
|
|
1527
|
+
// the chips live here (not in the DOM), so a redraw of the page keeps them.
|
|
1528
|
+
const attachUi = { by: new Map(), urls: new Map(), blobs: new Map(), loading: new Map(), seq: 0, running: 0, queue: [] }
|
|
1529
|
+
const attachList = (sid) => attachUi.by.get(sid) || []
|
|
1530
|
+
function chipHtml(a) {
|
|
1531
|
+
const pct = a.total ? Math.min(100, Math.round((a.sent / a.total) * 100)) : 0
|
|
1532
|
+
const status = a.status === 'failed' ? 'Didn’t upload' : a.status === 'done' ? fmtSize(a.size) : a.status === 'prep' ? 'Preparing…' : fmtSize(a.size) + ' · ' + pct + '%'
|
|
1533
|
+
return '<div class="att-chip ' + esc(a.status) + '" data-lid="' + esc(a.lid) + '"' + (a.error ? ' title="' + esc(a.error) + '"' : '') + '>' +
|
|
1534
|
+
'<button type="button" class="att-open" data-action="att-chip-open" data-lid="' + esc(a.lid) + '" aria-label="Preview ' + esc(a.name) + '">' +
|
|
1535
|
+
'<span class="att-thumb">' + (a.preview ? '<img src="' + esc(a.preview) + '" alt="">' : FILE_IC) + '</span>' +
|
|
1536
|
+
'<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>' +
|
|
1537
|
+
(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>' : '') +
|
|
1538
|
+
'<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>' +
|
|
1539
|
+
(a.status === 'up' || a.status === 'prep' ? '<span class="att-bar" aria-hidden="true"><span data-pct="' + pct + '"></span></span>' : '') + '</div>'
|
|
1540
|
+
}
|
|
1541
|
+
// The board's CSP takes no inline styles: a progress bar's width is set here.
|
|
1542
|
+
function barWidths(root) { for (const b of root.querySelectorAll('.att-bar > span[data-pct]')) b.style.width = b.dataset.pct + '%' }
|
|
1543
|
+
// Redraw one session's chips in place (and the ↑'s state).
|
|
1544
|
+
function paintChips(sid) {
|
|
1545
|
+
const box = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"]')
|
|
1546
|
+
if (!box) return
|
|
1547
|
+
const atts = attachList(sid)
|
|
1548
|
+
const wrap = box.querySelector('.att-chips')
|
|
1549
|
+
const fa = wrap.contains(document.activeElement) ? focusKey(document.activeElement) : null // a progress tick keeps the chip you're on focused
|
|
1550
|
+
wrap.innerHTML = atts.map(chipHtml).join('')
|
|
1551
|
+
if (fa) { const t = wrap.querySelector(fa); if (t) t.focus({ preventScroll: true }) }
|
|
1552
|
+
barWidths(wrap)
|
|
1553
|
+
wrap.hidden = !atts.length
|
|
1554
|
+
box.classList.toggle('has-atts', !!atts.length)
|
|
1555
|
+
const ta = box.querySelector('.composer-input')
|
|
1556
|
+
if (ta) syncComposer(ta)
|
|
1557
|
+
}
|
|
1558
|
+
function addFiles(sid, files, { pasted = false } = {}) {
|
|
1559
|
+
const list = Array.from(files || []).filter(Boolean)
|
|
1560
|
+
if (!sid || !list.length) return
|
|
1561
|
+
const have = attachList(sid)
|
|
1562
|
+
const plan = attachPlan(list, have.length)
|
|
1563
|
+
if (plan.skipped.length) toast('Not attached: ' + skippedText(plan.skipped), 4200)
|
|
1564
|
+
const add = plan.take.map((f) => {
|
|
1565
|
+
const file = pasted && /^image\.\w+$|^$/.test(f.name || '') ? new File([f], pastedName(f.type), { type: f.type, lastModified: Date.now() }) : f
|
|
1566
|
+
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 }
|
|
1567
|
+
})
|
|
1568
|
+
if (!add.length) return
|
|
1569
|
+
attachUi.by.set(sid, [...have, ...add])
|
|
1570
|
+
paintChips(sid)
|
|
1571
|
+
try { promoteActive() } catch {}
|
|
1572
|
+
for (const a of add) startUpload(a)
|
|
1573
|
+
}
|
|
1574
|
+
function startUpload(a) {
|
|
1575
|
+
a.status = 'prep'; a.error = null; a.sent = 0
|
|
1576
|
+
attachUi.queue.push(a); pumpUploads()
|
|
1577
|
+
}
|
|
1578
|
+
// Three at a time: a phone on a slow line shouldn't open ten uploads at once.
|
|
1579
|
+
function pumpUploads() {
|
|
1580
|
+
while (attachUi.running < 3 && attachUi.queue.length) {
|
|
1581
|
+
const a = attachUi.queue.shift()
|
|
1582
|
+
if (!attachList(a.sid).includes(a)) continue
|
|
1583
|
+
attachUi.running++
|
|
1584
|
+
runUpload(a).finally(() => { attachUi.running--; pumpUploads() })
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
async function runUpload(a) {
|
|
1588
|
+
const gone = () => !attachList(a.sid).includes(a)
|
|
1589
|
+
try {
|
|
1590
|
+
if (!a.prepared) {
|
|
1591
|
+
const p = await prepareFile(a.file)
|
|
1592
|
+
a.prepared = p.file; a.thumb = p.thumb
|
|
1593
|
+
a.name = p.file.name || a.name; a.size = p.file.size; a.total = p.file.size; a.type = p.file.type || a.type
|
|
1594
|
+
if (attachKind(p.file) === 'image' && !a.preview && p.thumb) a.preview = 'data:image/jpeg;base64,' + p.thumb
|
|
1595
|
+
// a PDF's first page, for its chip and its bubble (a few seconds at most; without it, the file icon)
|
|
1596
|
+
if (attachKind(p.file) === 'pdf' && !a.thumb) {
|
|
1597
|
+
const t = await Promise.race([pdfThumb(p.file), new Promise((r) => setTimeout(() => r(null), 5000))]).catch(() => null)
|
|
1598
|
+
if (t) { a.thumb = t; a.preview = 'data:image/jpeg;base64,' + t }
|
|
1599
|
+
}
|
|
1600
|
+
if (p.note === 'heic') a.error = 'This browser can’t convert HEIC; it goes as is'
|
|
1601
|
+
}
|
|
1602
|
+
if (gone()) return
|
|
1603
|
+
a.status = 'up'; paintChips(a.sid)
|
|
1604
|
+
let last = 0
|
|
1605
|
+
const f = await uploadFile({ api, sessionId: a.sid, file: a.prepared, thumb: a.thumb, isCancelled: gone,
|
|
1606
|
+
onProgress: (sent, total) => { a.sent = sent; a.total = total; const t = Date.now(); if (t - last > 120 || sent >= total) { last = t; paintChips(a.sid) } } })
|
|
1607
|
+
a.id = f.id; a.size = f.size; a.kind = f.kind; a.status = 'done'
|
|
1608
|
+
if (a.preview) attachUi.urls.set(a.sid + '/' + f.id, a.preview) // the bubble shows it without asking the Mac
|
|
1609
|
+
} catch (e) {
|
|
1610
|
+
if (e.code === 'removed' || gone()) return
|
|
1611
|
+
a.status = 'failed'; a.error = e.message || 'Upload failed'
|
|
1612
|
+
}
|
|
1613
|
+
paintChips(a.sid)
|
|
1614
|
+
attachWaiters()
|
|
1615
|
+
}
|
|
1616
|
+
let attachWaitList = []
|
|
1617
|
+
function attachWaiters() { const w = attachWaitList; attachWaitList = []; for (const f of w) f() }
|
|
1618
|
+
// Wait until nothing of this session's is still uploading.
|
|
1619
|
+
async function uploadsSettled(sid) {
|
|
1620
|
+
while (attachList(sid).some((a) => a.status === 'prep' || a.status === 'up')) await new Promise((r) => { attachWaitList.push(r); setTimeout(r, 1000) })
|
|
1621
|
+
}
|
|
1622
|
+
function removeAttachment(lid) {
|
|
1623
|
+
for (const [sid, list] of attachUi.by) {
|
|
1624
|
+
const a = list.find((x) => x.lid === lid)
|
|
1625
|
+
if (!a) continue
|
|
1626
|
+
attachUi.by.set(sid, list.filter((x) => x !== a))
|
|
1627
|
+
paintChips(sid)
|
|
1628
|
+
const ta = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"] .composer-input')
|
|
1629
|
+
if (ta && !isTouchUi(window.matchMedia)) ta.focus({ preventScroll: true })
|
|
1630
|
+
return
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
function retryAttachment(lid) {
|
|
1634
|
+
for (const list of attachUi.by.values()) { const a = list.find((x) => x.lid === lid); if (a) { startUpload(a); paintChips(a.sid); return } }
|
|
1635
|
+
}
|
|
1636
|
+
// The composer a drop or a paste is for: the session page's own (enabled).
|
|
1637
|
+
function composerFor(el) {
|
|
1638
|
+
const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
|
|
1639
|
+
return box && !box.classList.contains('off') ? box : null
|
|
1640
|
+
}
|
|
1641
|
+
document.addEventListener('change', (ev) => {
|
|
1642
|
+
const inp = ev.target
|
|
1643
|
+
if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
|
|
1644
|
+
addFiles(inp.dataset.sid, inp.files)
|
|
1645
|
+
inp.value = '' // the same file can be picked again
|
|
1646
|
+
})
|
|
1647
|
+
document.addEventListener('paste', (ev) => {
|
|
1648
|
+
const t = ev.target
|
|
1649
|
+
const inComposer = t && t.classList && t.classList.contains('composer-input')
|
|
1650
|
+
if (!inComposer && t && t.closest && t.closest('input, textarea, [contenteditable]')) return
|
|
1651
|
+
const cd = ev.clipboardData
|
|
1652
|
+
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) : []
|
|
1653
|
+
if (!files.length) return
|
|
1654
|
+
const box = composerFor(inComposer ? t : null)
|
|
1655
|
+
if (!box) return
|
|
1656
|
+
ev.preventDefault()
|
|
1657
|
+
addFiles(box.dataset.sid, files, { pasted: true })
|
|
1658
|
+
})
|
|
1659
|
+
{
|
|
1660
|
+
// Drag files onto the session page: the box lights up; drop attaches.
|
|
1661
|
+
const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
|
|
1662
|
+
let leaveT = 0
|
|
1663
|
+
const lit = (on) => { const b = document.querySelector('#app .composer[data-sid]'); if (b) b.classList.toggle('att-drop', on) }
|
|
1664
|
+
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) })
|
|
1665
|
+
document.addEventListener('drop', (e) => {
|
|
1666
|
+
if (!hasFiles(e)) return
|
|
1667
|
+
const box = composerFor(null)
|
|
1668
|
+
e.preventDefault() // never let a stray drop navigate away from the board
|
|
1669
|
+
lit(false)
|
|
1670
|
+
if (box) addFiles(box.dataset.sid, e.dataTransfer.files)
|
|
1671
|
+
})
|
|
1672
|
+
}
|
|
1673
|
+
// A sent message's attachments in its bubble: thumbnails (tap for full size) or file chips.
|
|
1674
|
+
function attBubbleHtml(atts, sid) {
|
|
1675
|
+
return '<div class="lv-atts">' + atts.map((a) => {
|
|
1676
|
+
const s0 = a.sessionId || sid, key = s0 + '/' + a.id
|
|
1677
|
+
const label = esc(a.name) + ' · ' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '')
|
|
1678
|
+
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 || '') + '"'
|
|
1679
|
+
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>' }
|
|
1680
|
+
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
|
|
1681
|
+
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>'
|
|
1682
|
+
}).join('') + '</div>'
|
|
1683
|
+
}
|
|
1684
|
+
// Thumbnails come from the Mac once, then from memory. data: URLs, because the
|
|
1685
|
+
// board's CSP allows images from itself and data: only (no blob:).
|
|
1686
|
+
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) })
|
|
1687
|
+
function attData(sid, id, { thumb = false } = {}) {
|
|
1688
|
+
const key = sid + '/' + id + (thumb ? '' : '#full')
|
|
1689
|
+
if (attachUi.urls.has(key)) return Promise.resolve({ url: attachUi.urls.get(key), blob: attachUi.blobs.get(key) || null })
|
|
1690
|
+
if (attachUi.loading.has(key)) return attachUi.loading.get(key)
|
|
1691
|
+
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))
|
|
1692
|
+
attachUi.loading.set(key, p)
|
|
1693
|
+
return p
|
|
1694
|
+
}
|
|
1695
|
+
function hydrateAttThumbs() {
|
|
1696
|
+
for (const img of document.querySelectorAll('#app img[data-att-thumb]:not([src])')) {
|
|
1697
|
+
const key = img.dataset.attThumb
|
|
1698
|
+
if (attachUi.urls.has(key)) { img.src = attachUi.urls.get(key); continue }
|
|
1699
|
+
const btn = img.closest('.lv-att'); if (!btn || btn.dataset.loading) continue
|
|
1700
|
+
btn.dataset.loading = '1'
|
|
1701
|
+
const thumb = btn.dataset.thumb !== '0'
|
|
1702
|
+
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 })
|
|
1703
|
+
.catch((e) => {
|
|
1704
|
+
// a PDF sent without a first-page picture (before 0.9.8, or it couldn't be drawn) keeps its icon
|
|
1705
|
+
if (btn.dataset.kind === 'pdf' && e && e.code === 'no_thumb') { for (const i of btn.querySelectorAll('img[data-att-thumb]')) i.remove(); return }
|
|
1706
|
+
btn.classList.add('gone'); btn.title = 'Not on that Mac any more'
|
|
1707
|
+
})
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
// An image opens full screen (ui/lightbox.js) with the other pictures of the
|
|
1711
|
+
// same message beside it; the thumbnail shows (blurred) while the full one
|
|
1712
|
+
// comes from the Mac. Text and other files keep the docs viewer.
|
|
1713
|
+
const attSel = (sid, id) => '.lv-att[data-sid="' + CSS.escape(sid) + '"][data-att="' + CSS.escape(id) + '"]'
|
|
1714
|
+
function openImageAttachment(btn) {
|
|
1715
|
+
const group = btn.closest('.lv-atts')
|
|
1716
|
+
const btns = group ? [...group.querySelectorAll('.lv-att[data-kind="image"]')] : [btn]
|
|
1717
|
+
const items = btns.map((b) => {
|
|
1718
|
+
const im = b.querySelector('img'), sid = b.dataset.sid, id = b.dataset.att
|
|
1719
|
+
const full = attachUi.urls.get(sid + '/' + id + '#full')
|
|
1720
|
+
return { name: b.dataset.name || 'image', sizeLabel: b.dataset.size || '', thumb: (im && im.getAttribute('src')) || null, url: full || null,
|
|
1721
|
+
w: im && im.naturalWidth, h: im && im.naturalHeight, load: () => attData(sid, id).then((r) => r.url) }
|
|
1722
|
+
})
|
|
1723
|
+
const sid = btn.dataset.sid, id = btn.dataset.att
|
|
1724
|
+
openLightbox({ items, index: Math.max(0, btns.indexOf(btn)), from: btn.querySelector('img') || btn, refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
|
|
1725
|
+
}
|
|
1726
|
+
// A chip in the box before it's sent: the picture from the file you picked
|
|
1727
|
+
// (a data: URL; the CSP takes no blob:), the other pictures in the box beside it.
|
|
1728
|
+
// Any other file: its name and size in the docs viewer (nothing to download yet).
|
|
1729
|
+
function openChip(lid) {
|
|
1730
|
+
let a = null
|
|
1731
|
+
for (const list of attachUi.by.values()) { a = list.find((x) => x.lid === lid); if (a) break }
|
|
1732
|
+
if (!a) return
|
|
1733
|
+
const chip = document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
|
|
1734
|
+
const isImg = (x) => x.kind === 'image' && (x.preview || /^image\/(png|jpeg|gif|webp|bmp|svg\+xml|avif)$/i.test((x.prepared || x.file || {}).type || ''))
|
|
1735
|
+
const refocus = () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
|
|
1736
|
+
if (a.kind === 'pdf') {
|
|
1737
|
+
const f = a.prepared || a.file
|
|
1738
|
+
openPdfView({ name: a.name, sizeLabel: fmtSize(a.size), download: false, from: chip, refocus, load: async () => ({ bytes: new Uint8Array(await f.arrayBuffer()) }) })
|
|
1739
|
+
return
|
|
1740
|
+
}
|
|
1741
|
+
if (isImg(a)) {
|
|
1742
|
+
const list = attachList(a.sid).filter(isImg)
|
|
1743
|
+
const items = list.map((x) => ({ name: x.name, sizeLabel: fmtSize(x.size), thumb: x.preview || null, download: false,
|
|
1744
|
+
load: () => { if (!x.fullUrl) x.fullUrl = blobToDataUrl(x.prepared || x.file); return x.fullUrl } }))
|
|
1745
|
+
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) + '"]') })
|
|
1746
|
+
return
|
|
1747
|
+
}
|
|
1748
|
+
const el = docViewerEl()
|
|
1749
|
+
el.querySelector('.dv-title').textContent = a.name
|
|
1750
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
1751
|
+
el.querySelector('.dv-dl').hidden = true
|
|
1752
|
+
docUi.r = null; docUi.name = a.name
|
|
1753
|
+
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>'
|
|
1754
|
+
el.hidden = false
|
|
1755
|
+
document.documentElement.classList.add('dv-open')
|
|
1756
|
+
el.querySelector('.dv-x') && el.querySelector('.dv-x').focus({ preventScroll: true })
|
|
1757
|
+
}
|
|
1758
|
+
// A PDF opens in its own full-screen preview (ui/pdfview.js): the pages drawn
|
|
1759
|
+
// from the file the Mac sends back (the same chunked read as a picture).
|
|
1760
|
+
function openPdfAttachment(btn) {
|
|
1761
|
+
const sid = btn.dataset.sid, id = btn.dataset.att
|
|
1762
|
+
openPdfView({ name: btn.dataset.name || 'file.pdf', sizeLabel: btn.dataset.size || '', from: btn,
|
|
1763
|
+
load: async () => { const r = await attData(sid, id); return { bytes: new Uint8Array(await r.blob.arrayBuffer()), url: r.url } },
|
|
1764
|
+
refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
|
|
1765
|
+
}
|
|
1766
|
+
async function openAttachment(btn) {
|
|
1767
|
+
const sid = btn.dataset.sid, id = btn.dataset.att, name = btn.dataset.name || 'file', kind = btn.dataset.kind
|
|
1768
|
+
if (kind === 'image') return openImageAttachment(btn)
|
|
1769
|
+
if (kind === 'pdf') return openPdfAttachment(btn)
|
|
1770
|
+
const el = docViewerEl()
|
|
1771
|
+
el.querySelector('.dv-title').textContent = name
|
|
1772
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
1773
|
+
el.querySelector('.dv-dl').hidden = true
|
|
1774
|
+
const body = el.querySelector('.dv-body')
|
|
1775
|
+
body.innerHTML = '<div class="empty">Opening…</div>'
|
|
1776
|
+
docUi.r = null; docUi.name = name
|
|
1777
|
+
el.hidden = false
|
|
1778
|
+
document.documentElement.classList.add('dv-open')
|
|
1779
|
+
try {
|
|
1780
|
+
const { url: u, blob } = await attData(sid, id)
|
|
1781
|
+
if (el.hidden) return
|
|
1782
|
+
const dl = '<a class="btn" href="' + esc(u) + '" download="' + esc(name) + '">' + icon('download') + 'Download</a>'
|
|
1783
|
+
if (kind === 'image') body.innerHTML = '<div class="att-full"><img src="' + esc(u) + '" alt="' + esc(name) + '"></div><div class="att-full-actions">' + dl + '</div>'
|
|
1784
|
+
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>' }
|
|
1785
|
+
else body.innerHTML = '<div class="empty">' + esc(name) + '</div><div class="att-full-actions">' + dl + '</div>'
|
|
1786
|
+
} catch (e) { if (!el.hidden) body.innerHTML = '<div class="empty">Couldn’t open it: ' + esc(e.message) + '</div>' }
|
|
1787
|
+
}
|
|
1788
|
+
// 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.
|
|
1789
|
+
function fitComposer(ta) {
|
|
1790
|
+
if (!ta || !ta.isConnected) return
|
|
1791
|
+
const cs = getComputedStyle(ta)
|
|
1792
|
+
const lh = parseFloat(cs.lineHeight) || 22
|
|
1793
|
+
const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
|
|
1794
|
+
const keep = ta.scrollTop
|
|
1795
|
+
ta.style.height = 'auto'
|
|
1796
|
+
const r = composerHeight({ scrollHeight: ta.scrollHeight, lineHeight: lh, padY })
|
|
1797
|
+
ta.style.height = r.height + 'px'
|
|
1798
|
+
ta.style.overflowY = r.scroll ? 'auto' : 'hidden'
|
|
1799
|
+
if (r.scroll) ta.scrollTop = keep
|
|
1800
|
+
syncComposer(ta)
|
|
1801
|
+
}
|
|
1802
|
+
function syncComposer(ta) {
|
|
1803
|
+
const box = ta && ta.closest('.composer')
|
|
1804
|
+
const btn = box && box.querySelector('.composer-send')
|
|
1805
|
+
if (!btn || btn.classList.contains('sending')) return
|
|
1806
|
+
btn.disabled = ta.disabled || (isBlank(ta.value) && !attachList(box.dataset.sid).length)
|
|
1807
|
+
}
|
|
1808
|
+
document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
|
|
1809
|
+
// Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
|
|
1810
|
+
document.addEventListener('keydown', (ev) => {
|
|
1811
|
+
const ta = ev.target
|
|
1812
|
+
if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
|
|
1813
|
+
if (ev.metaKey || ev.ctrlKey) return // ⌘↵ / Ctrl+↵: the Needs-you handler presses the button
|
|
1814
|
+
if (composerKey(ev, { touch: isTouchUi(window.matchMedia) }) !== 'send') return
|
|
1815
|
+
ev.preventDefault()
|
|
1816
|
+
const cb = ta.closest('.composer')
|
|
1817
|
+
if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
|
|
1818
|
+
const box = ta.closest('.live-box')
|
|
1819
|
+
const btn = box && box.querySelector('[data-submit-for="live"]')
|
|
1820
|
+
if (!btn || btn.disabled) return
|
|
1821
|
+
const last = Number(box.dataset.kbdAt || 0)
|
|
1822
|
+
if (Date.now() - last < 1500) return
|
|
1823
|
+
box.dataset.kbdAt = String(Date.now())
|
|
1824
|
+
btn.click()
|
|
1825
|
+
})
|
|
1826
|
+
// A tap anywhere on the box (its padding) is a tap in the textarea.
|
|
1827
|
+
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() })
|
|
1828
|
+
// Pressing ↑ keeps the keyboard up (the textarea keeps focus) on a phone.
|
|
1829
|
+
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
1830
|
// After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
|
|
1380
1831
|
function liveAfterRender(sid) {
|
|
1832
|
+
for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
|
|
1381
1833
|
const r = liveKeeper.after($app)
|
|
1834
|
+
hydrateAttThumbs(); barWidths($app)
|
|
1382
1835
|
if (!r) return
|
|
1383
1836
|
const ol = r.thread.querySelector('.live-log')
|
|
1384
1837
|
const empty = r.thread.querySelector('.live-empty')
|
|
@@ -1386,8 +1839,16 @@ function liveAfterRender(sid) {
|
|
|
1386
1839
|
liveJumpButton(r.thread, liveKeeper.unseen)
|
|
1387
1840
|
if (ol && !ol.dataset.wired) {
|
|
1388
1841
|
ol.dataset.wired = '1'
|
|
1389
|
-
ol.
|
|
1842
|
+
ol._stick = atBottom(ol)
|
|
1843
|
+
// the button follows the scroll: there whenever you're away from the bottom, gone once you're back
|
|
1844
|
+
ol.addEventListener('scroll', () => { ol._stick = atBottom(ol); liveJumpButton(r.thread, liveKeeper.unseen) }, { passive: true })
|
|
1845
|
+
// at the bottom, the thread stays there while an entry grows (a streamed reply, a code block, late fonts)
|
|
1846
|
+
if (typeof ResizeObserver === 'function') {
|
|
1847
|
+
ol._ro = new ResizeObserver(() => { if (ol._stick && !atBottom(ol, 2)) ol.scrollTop = ol.scrollHeight; liveJumpButton(r.thread, liveKeeper.unseen) })
|
|
1848
|
+
}
|
|
1390
1849
|
}
|
|
1850
|
+
if (ol && ol._ro) { ol._ro.disconnect(); ol._ro.observe(ol); for (const li of ol.children) ol._ro.observe(li) }
|
|
1851
|
+
if (ol && r.follow) ol._stick = true
|
|
1391
1852
|
// #/s/<id>/live: scroll to the box once, and ready the input on a Mac (no keyboard popping up on a phone)
|
|
1392
1853
|
const want = parseLiveRoute(location.hash)
|
|
1393
1854
|
if (want === sid && state.liveFocused !== location.hash + '|' + sid) {
|
|
@@ -1400,48 +1861,179 @@ function liveAfterRender(sid) {
|
|
|
1400
1861
|
}
|
|
1401
1862
|
liveTick()
|
|
1402
1863
|
}
|
|
1864
|
+
// "Jump to latest ↓" shows whenever the thread is scrolled away from its bottom (with the
|
|
1865
|
+
// count of what came in since, when there is any), and hides once you're back at the bottom.
|
|
1403
1866
|
function liveJumpButton(thread, n) {
|
|
1404
1867
|
const b = thread && thread.querySelector('.live-jump')
|
|
1405
1868
|
if (!b) return
|
|
1406
|
-
|
|
1869
|
+
const ol = thread.querySelector('.live-log')
|
|
1870
|
+
const v = jumpView(ol, n)
|
|
1871
|
+
if (!v.show && n > 0) liveKeeper.seen()
|
|
1872
|
+
// on its way down after a tap: don't flash back up mid-scroll
|
|
1873
|
+
if (v.show && thread.dataset.jumping && Date.now() < Number(thread.dataset.jumping)) return
|
|
1874
|
+
b.hidden = !v.show
|
|
1407
1875
|
const c = b.querySelector('.live-jump-n')
|
|
1408
|
-
if (c) c.textContent =
|
|
1876
|
+
if (c) c.textContent = v.count
|
|
1877
|
+
b.setAttribute('aria-label', v.label)
|
|
1409
1878
|
}
|
|
1410
1879
|
function liveJumpClick(btn) {
|
|
1411
1880
|
const thread = btn.closest('.live-thread')
|
|
1412
1881
|
const ol = thread && thread.querySelector('.live-log')
|
|
1413
|
-
|
|
1414
|
-
|
|
1882
|
+
const smooth = !(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
1883
|
+
if (thread) thread.dataset.jumping = String(Date.now() + (smooth ? 900 : 0))
|
|
1884
|
+
if (ol) ol.scrollTo({ top: ol.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
|
|
1885
|
+
liveKeeper.seen()
|
|
1886
|
+
btn.hidden = true
|
|
1887
|
+
const c = btn.querySelector('.live-jump-n'); if (c) c.textContent = ''
|
|
1888
|
+
// land exactly at the bottom even if something grew mid-scroll, then the button follows the scroll again
|
|
1889
|
+
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)
|
|
1890
|
+
}
|
|
1891
|
+
// "Show earlier": 20 more turns, the view stays on what you were reading (the keeper anchors it).
|
|
1892
|
+
async function liveEarlierClick(btn) {
|
|
1893
|
+
const box = btn.closest('.live-box')
|
|
1894
|
+
const sid = box && box.dataset.id
|
|
1895
|
+
if (!sid) return
|
|
1896
|
+
state.liveTurns = state.liveTurns || {}
|
|
1897
|
+
state.liveTurns[sid] = ((state.liveTurns[sid]) || 20) + 20
|
|
1898
|
+
btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Loading…'
|
|
1899
|
+
await render()
|
|
1415
1900
|
}
|
|
1416
1901
|
// The elapsed time beside "Claude is working…", every second while something is in flight.
|
|
1902
|
+
// 0.9.8: when what the page shows is older than STALE_MS (the stream died, the
|
|
1903
|
+
// phone slept), a running bubble says "Reconnecting…" instead of a timer.
|
|
1417
1904
|
let liveTimer = null
|
|
1418
1905
|
function liveTick() {
|
|
1419
1906
|
const els = [...document.querySelectorAll('.lv-status[data-since]')]
|
|
1907
|
+
const box = document.querySelector('#live[data-id]')
|
|
1908
|
+
const stale = !!box && isStale(liveUi.okAt[box.dataset.id], Date.now())
|
|
1420
1909
|
for (const el of els) {
|
|
1421
1910
|
const since = Number(el.dataset.since) || 0
|
|
1422
1911
|
const end = Number(el.dataset.final) || 0
|
|
1423
1912
|
const t = el.querySelector('.lvs-time')
|
|
1424
1913
|
if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
|
|
1914
|
+
if (!end) {
|
|
1915
|
+
el.classList.toggle('lv-reconnecting', stale)
|
|
1916
|
+
let hint = el.querySelector('.lvs-hint')
|
|
1917
|
+
if (stale && !hint) { hint = document.createElement('span'); hint.className = 'lvs-hint'; hint.textContent = 'Reconnecting…'; (el.querySelector('.lvs-line') || el).append(hint) }
|
|
1918
|
+
if (hint) hint.hidden = !stale
|
|
1919
|
+
}
|
|
1425
1920
|
}
|
|
1426
1921
|
const running = els.some((el) => !el.dataset.final)
|
|
1427
1922
|
if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
|
|
1428
1923
|
}
|
|
1924
|
+
|
|
1925
|
+
// 0.9.8: Live keeps up after the phone sleeps (ui/live-wake.js). Every Live
|
|
1926
|
+
// fetch notes when it answered and whether a message is still going; while
|
|
1927
|
+
// one is, on a visible page, the thread is fetched again every POLL_MS
|
|
1928
|
+
// whatever the event stream does.
|
|
1929
|
+
const liveUi = { okAt: {}, sig: {}, pending: {}, polling: false }
|
|
1930
|
+
function noteLive(id, v) {
|
|
1931
|
+
if (!v || v.error) return
|
|
1932
|
+
liveUi.okAt[id] = Date.now()
|
|
1933
|
+
liveUi.pending[id] = anyPending(v.messages)
|
|
1934
|
+
}
|
|
1935
|
+
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 }
|
|
1936
|
+
// Fetch the thread and diff it into the page, even while you type (busy()
|
|
1937
|
+
// holds full redraws back, never the thread). A turn that ended also redraws
|
|
1938
|
+
// the rest of the page (the pill stops pulsing) once you're not busy.
|
|
1939
|
+
async function refreshLive(reason) {
|
|
1940
|
+
const id = liveOnScreen()
|
|
1941
|
+
if (!id || liveUi.polling) return false
|
|
1942
|
+
liveUi.polling = true; liveUi.triedAt = Date.now()
|
|
1943
|
+
let v
|
|
1944
|
+
try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
|
|
1945
|
+
if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
|
|
1946
|
+
const s = state.sessions.find((x) => x.id === id)
|
|
1947
|
+
const sig = liveSignature(v.messages)
|
|
1948
|
+
const before = liveUi.sig[id]
|
|
1949
|
+
liveUi.sig[id] = sig
|
|
1950
|
+
if (s && before !== sig) {
|
|
1951
|
+
const box = document.querySelector('#live')
|
|
1952
|
+
const cur = box && box.querySelector('.live-thread')
|
|
1953
|
+
if (cur) {
|
|
1954
|
+
const tmp = document.createElement('div')
|
|
1955
|
+
tmp.innerHTML = liveBoxHtml(s, v)
|
|
1956
|
+
const fresh = tmp.querySelector('.live-thread')
|
|
1957
|
+
if (fresh) {
|
|
1958
|
+
const next = cur.nextSibling
|
|
1959
|
+
liveKeeper.before(box)
|
|
1960
|
+
box.insertBefore(fresh, next)
|
|
1961
|
+
const r = liveKeeper.after(box)
|
|
1962
|
+
hydrateAttThumbs()
|
|
1963
|
+
if (r) liveJumpButton(r.thread, liveKeeper.unseen)
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
const c = sessionCache.get(id); if (c) c.live = v
|
|
1967
|
+
if (!liveUi.pending[id] && !busy()) soon() // settled: the pill, the tab, the card
|
|
1968
|
+
}
|
|
1969
|
+
liveTick()
|
|
1970
|
+
return true
|
|
1971
|
+
}
|
|
1972
|
+
// The safety net, and the stream's watchdog, on one slow timer.
|
|
1973
|
+
const streamDog = createWatchdog()
|
|
1974
|
+
const wakeGate = createWake()
|
|
1975
|
+
setInterval(() => {
|
|
1976
|
+
if (document.visibilityState === 'hidden') return
|
|
1977
|
+
const id = liveOnScreen()
|
|
1978
|
+
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')
|
|
1979
|
+
if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
|
|
1980
|
+
}, 1000)
|
|
1981
|
+
// Coming back (iOS: the Home Screen app returns, the screen unlocks, a
|
|
1982
|
+
// bfcache restore, the network returns): fetch what's on screen at once, and
|
|
1983
|
+
// open a new stream if the old one missed its pings.
|
|
1984
|
+
function refreshView(reason) {
|
|
1985
|
+
if (liveOnScreen()) refreshLive(reason)
|
|
1986
|
+
if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
|
|
1987
|
+
refreshHealth()
|
|
1988
|
+
}
|
|
1989
|
+
let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
|
|
1990
|
+
function onWake(ev) {
|
|
1991
|
+
if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
|
|
1992
|
+
const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
|
|
1993
|
+
if (!why) return
|
|
1994
|
+
liveTick() // a stale timer turns into "Reconnecting…" before anything else
|
|
1995
|
+
const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
|
|
1996
|
+
const away = awayAt ? Date.now() - awayAt : 0
|
|
1997
|
+
awayAt = 0
|
|
1998
|
+
// 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
|
|
1999
|
+
if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
|
|
2000
|
+
if (!wakeGate.due()) return
|
|
2001
|
+
if (why === 'pageshow-bfcache' || dead) connect()
|
|
2002
|
+
refreshView(why)
|
|
2003
|
+
}
|
|
2004
|
+
for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
|
|
2005
|
+
document.addEventListener('visibilitychange', onWake)
|
|
1429
2006
|
async function liveSendClick(btn) {
|
|
1430
2007
|
const box = btn.closest('.live-box')
|
|
1431
2008
|
const ta = box && box.querySelector('.live-text')
|
|
2009
|
+
const sid = btn.dataset.id
|
|
1432
2010
|
const text = ta ? ta.value.trim() : ''
|
|
1433
|
-
if (!text
|
|
1434
|
-
if (btn.
|
|
1435
|
-
|
|
2011
|
+
if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
|
|
2012
|
+
if (btn.disabled || btn.classList.contains('sending')) return
|
|
2013
|
+
const hadFocus = !!(ta && document.activeElement === ta)
|
|
2014
|
+
btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
|
|
1436
2015
|
try {
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
2016
|
+
// 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
|
|
2017
|
+
await uploadsSettled(sid)
|
|
2018
|
+
const atts = attachList(sid)
|
|
2019
|
+
const failed = atts.filter((a) => a.status === 'failed').length
|
|
2020
|
+
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 }
|
|
2021
|
+
const ids = atts.filter((a) => a.status === 'done' && a.id).map((a) => a.id)
|
|
2022
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live', { text, ...(ids.length ? { attachments: ids } : {}) })
|
|
2023
|
+
// what you typed while it went stays; what was sent goes
|
|
2024
|
+
if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
|
|
2025
|
+
if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
|
|
1440
2026
|
if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
|
|
1441
2027
|
const ol = box && box.querySelector('.live-log')
|
|
1442
2028
|
if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
|
|
1443
2029
|
setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
|
|
1444
|
-
} catch (e) { toast(e.message, 4200) } finally {
|
|
2030
|
+
} catch (e) { toast(e.message, 4200) } finally {
|
|
2031
|
+
await render()
|
|
2032
|
+
const nb = document.querySelector('.live-box [data-action="live-send"]')
|
|
2033
|
+
if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
|
|
2034
|
+
const nt = document.querySelector('.live-box .composer-input')
|
|
2035
|
+
if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
|
|
2036
|
+
}
|
|
1445
2037
|
}
|
|
1446
2038
|
|
|
1447
2039
|
// ------------------------------------------------------------ search inside transcripts (0.9.6)
|
|
@@ -1604,6 +2196,11 @@ async function togglePr(url, { retry = false } = {}) {
|
|
|
1604
2196
|
const was = prsUi.open.has(url)
|
|
1605
2197
|
if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
|
|
1606
2198
|
const repaint = () => {
|
|
2199
|
+
if (state.route && state.route.name === 's') { // 0.9.8: the session page's PRs open in place too
|
|
2200
|
+
const s = state.sessions.find((x) => x.id === state.route.arg), sec = document.getElementById('prs')
|
|
2201
|
+
if (s && sec) sec.innerHTML = sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details })
|
|
2202
|
+
return
|
|
2203
|
+
}
|
|
1607
2204
|
const d = prsUi.data[prsUi.view]
|
|
1608
2205
|
const r = prRows(d).find((x) => x.pr.url === url)
|
|
1609
2206
|
const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
|
|
@@ -1617,7 +2214,7 @@ async function togglePr(url, { retry = false } = {}) {
|
|
|
1617
2214
|
try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
1618
2215
|
catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
|
|
1619
2216
|
prsUi.details[url] = { ...res, at: Date.now() }
|
|
1620
|
-
if (state.route && state.route.name === 'prs') repaint()
|
|
2217
|
+
if (state.route && (state.route.name === 'prs' || state.route.name === 's')) repaint()
|
|
1621
2218
|
}
|
|
1622
2219
|
function setPrsFilter(f) {
|
|
1623
2220
|
const h = prsHash(prsUi.view, f)
|
|
@@ -1678,29 +2275,67 @@ async function viewTimeline() {
|
|
|
1678
2275
|
$app.innerHTML = html
|
|
1679
2276
|
}
|
|
1680
2277
|
|
|
1681
|
-
|
|
2278
|
+
// 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
|
|
2279
|
+
// links there and Start here gets that session's Go / Resume.
|
|
2280
|
+
const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
|
|
2281
|
+
function briefAction(id, sessions, viewer) {
|
|
2282
|
+
const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
|
|
2283
|
+
const s = sessions && sessions.get(id)
|
|
2284
|
+
if (!s) return open
|
|
2285
|
+
let b = ''
|
|
2286
|
+
// 0.9.8: closed: Resume opens it in a new tab where it can, else (on the Mac) copies the resume command
|
|
2287
|
+
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>')
|
|
2288
|
+
else { const g = goButton(s); b = g && !/disabled/.test(g) ? g : '' }
|
|
2289
|
+
return b || open
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
1682
2293
|
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
1683
2294
|
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
2295
|
const out = []
|
|
1685
|
-
let inList = false, inSec = false
|
|
2296
|
+
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
2297
|
+
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
2298
|
+
const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
|
|
2299
|
+
const href = (id) => '#/s/' + encodeURIComponent(id)
|
|
1686
2300
|
// v0.5.8: each "## <space>" opens a section with its own Copy for standup;
|
|
1687
2301
|
// other "## " headings (Lists, Top of mind) are whole-folder sections.
|
|
2302
|
+
// 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
|
|
1688
2303
|
const openSec = (h) => {
|
|
1689
2304
|
if (inSec) out.push('</section>')
|
|
1690
2305
|
inSec = true
|
|
1691
|
-
if (
|
|
2306
|
+
if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
|
|
2307
|
+
else if (spaces.includes(h)) {
|
|
1692
2308
|
out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
|
|
1693
2309
|
'<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
2310
|
} else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
|
|
1695
2311
|
}
|
|
1696
|
-
for (const
|
|
2312
|
+
for (const raw of body.split('\n')) {
|
|
2313
|
+
const mid = raw.match(MD_SID)
|
|
2314
|
+
const id = mid ? mid[1] : null
|
|
2315
|
+
const line = mid ? raw.slice(0, mid.index) : raw
|
|
2316
|
+
// 0.9.8: Start here: "1. <step> — **Title** (repo)"
|
|
2317
|
+
const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
|
|
2318
|
+
if (st) {
|
|
2319
|
+
openList('ol', 'start')
|
|
2320
|
+
const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
|
|
2321
|
+
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>' +
|
|
2322
|
+
(id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
|
|
2323
|
+
continue
|
|
2324
|
+
}
|
|
1697
2325
|
if (/^- /.test(line)) {
|
|
1698
|
-
|
|
2326
|
+
openList('ul')
|
|
1699
2327
|
// Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
|
|
1700
2328
|
const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
|
|
2329
|
+
const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
|
|
2330
|
+
// 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
|
|
2331
|
+
// session: the title links to it, the sentence under it, the tags quiet.
|
|
2332
|
+
const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
|
|
2333
|
+
// 0.9.8: an Unfinished one is closed, so it has its Resume beside it
|
|
2334
|
+
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 }
|
|
2335
|
+
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
2336
|
// v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
|
|
1702
2337
|
const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
|
|
1703
|
-
out.push('<li' +
|
|
2338
|
+
out.push('<li' + attrs + '>' + li); continue
|
|
1704
2339
|
}
|
|
1705
2340
|
if (/^\s{2,}\S/.test(line) && inList) {
|
|
1706
2341
|
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
@@ -1709,13 +2344,17 @@ function renderMd(md, { spaces = [] } = {}) {
|
|
|
1709
2344
|
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
1710
2345
|
continue
|
|
1711
2346
|
}
|
|
1712
|
-
|
|
2347
|
+
closeList()
|
|
2348
|
+
if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
|
|
1713
2349
|
if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
|
|
1714
|
-
else if (/^## /.test(line)) { if (spaces.length) openSec(
|
|
2350
|
+
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>') } }
|
|
2351
|
+
// 0.9.8: Finished folds away (older digests' "Finished or closed" too)
|
|
2352
|
+
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
2353
|
else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
|
|
1716
|
-
|
|
2354
|
+
// 0.9.8: the Yesterday / Today opener, above the first section
|
|
2355
|
+
else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
|
|
1717
2356
|
}
|
|
1718
|
-
|
|
2357
|
+
closeList()
|
|
1719
2358
|
if (inSec) out.push('</section>')
|
|
1720
2359
|
return out.join('\n')
|
|
1721
2360
|
}
|
|
@@ -1726,6 +2365,81 @@ function shortDay(iso) {
|
|
|
1726
2365
|
return isNaN(d) ? iso : d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '')
|
|
1727
2366
|
}
|
|
1728
2367
|
|
|
2368
|
+
// The past-digests calendar (0.9.8): one month at a time, Sunday first. A day
|
|
2369
|
+
// with a digest is a link (the shared one, else a Mac's own from before one
|
|
2370
|
+
// digest a day); the rest are plain numbers. Opens on the picked digest's
|
|
2371
|
+
// month; next stops at this month, prev at the first month with a digest.
|
|
2372
|
+
const monthOf = (iso) => String(iso).slice(0, 7)
|
|
2373
|
+
const localDay = (t = new Date()) => t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0')
|
|
2374
|
+
function shiftMonth(ym, by) {
|
|
2375
|
+
const [y, m] = ym.split('-').map(Number)
|
|
2376
|
+
const d = new Date(y, m - 1 + by, 1)
|
|
2377
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
|
2378
|
+
}
|
|
2379
|
+
function digestCalendar() {
|
|
2380
|
+
const { days = [], pick = null } = digestUi.cal || {}
|
|
2381
|
+
const today = localDay()
|
|
2382
|
+
const max = monthOf(today)
|
|
2383
|
+
const min = days.length ? days.map((x) => monthOf(x.day)).sort()[0] : max
|
|
2384
|
+
let ym = digestUi.month || max
|
|
2385
|
+
if (ym > max) ym = max
|
|
2386
|
+
if (ym < min) ym = min
|
|
2387
|
+
digestUi.month = ym
|
|
2388
|
+
const [y, m] = ym.split('-').map(Number)
|
|
2389
|
+
const first = new Date(y, m - 1, 1)
|
|
2390
|
+
const label = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
|
2391
|
+
const by = new Map(days.map((x) => [x.day, x.d]))
|
|
2392
|
+
const prev = shiftMonth(ym, -1), next = shiftMonth(ym, 1)
|
|
2393
|
+
const name = (x) => new Date(x + '-01T12:00:00').toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
|
2394
|
+
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>'
|
|
2395
|
+
// phones fold it to one week (the picked day's) until you open the month
|
|
2396
|
+
const open = !!digestUi.calOpen
|
|
2397
|
+
let h = '<div class="cal-head">' + navBtn(prev, prev >= min, -1) + '<span class="cal-month" aria-live="polite">' + esc(label) + '</span>' +
|
|
2398
|
+
'<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>'
|
|
2399
|
+
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('')
|
|
2400
|
+
const n = new Date(y, m, 0).getDate()
|
|
2401
|
+
const lead0 = first.getDay()
|
|
2402
|
+
const week = (dn) => Math.floor((lead0 + dn - 1) / 7)
|
|
2403
|
+
const inMonth = (x) => x && x.slice(0, 7) === ym
|
|
2404
|
+
const firstHas = days.map((x) => x.day).filter(inMonth).sort()[0]
|
|
2405
|
+
const wk = week(inMonth(pick) ? Number(pick.slice(8)) : inMonth(today) ? Number(today.slice(8)) : firstHas ? Number(firstHas.slice(8)) : 1)
|
|
2406
|
+
for (let i = 0; i < lead0; i++) h += '<span class="cal-day pad' + (wk === 0 ? ' wk' : '') + '" aria-hidden="true"></span>'
|
|
2407
|
+
let count = 0
|
|
2408
|
+
for (let dn = 1; dn <= n; dn++) {
|
|
2409
|
+
const day = ym + '-' + String(dn).padStart(2, '0')
|
|
2410
|
+
const d = by.get(day)
|
|
2411
|
+
const cls = ['cal-day', d ? 'has' : 'none', day === pick ? 'on' : '', day === today ? 'today' : '', week(dn) === wk ? 'wk' : ''].filter(Boolean).join(' ')
|
|
2412
|
+
const long = shortDay(day)
|
|
2413
|
+
if (d) {
|
|
2414
|
+
count++
|
|
2415
|
+
const whose = d.shared ? '' : ', ' + d.machine + '’s own digest'
|
|
2416
|
+
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>'
|
|
2417
|
+
} else h += '<span class="' + cls + '" data-day="' + day + '"><span class="n">' + dn + '</span></span>'
|
|
2418
|
+
}
|
|
2419
|
+
h += '</div>'
|
|
2420
|
+
if (!count) h += '<div class="cal-none">' + (days.length ? 'No digests in ' + esc(first.toLocaleDateString('en-US', { month: 'long' })) + '.' : 'None yet') + '</div>'
|
|
2421
|
+
return h
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
// Month or fold changed: patch the calendar in place, so the arrows (and the
|
|
2425
|
+
// focus on them, with its keyboard ring) stay put.
|
|
2426
|
+
function repaintCalendar() {
|
|
2427
|
+
const nav = document.querySelector('nav.digest-cal')
|
|
2428
|
+
if (!nav) return null
|
|
2429
|
+
const t = document.createElement('div')
|
|
2430
|
+
t.innerHTML = digestCalendar()
|
|
2431
|
+
const now = [...nav.querySelectorAll('.cal-head > *')], fresh = [...t.querySelectorAll('.cal-head > *')]
|
|
2432
|
+
now.forEach((el, i) => {
|
|
2433
|
+
const f = fresh[i]
|
|
2434
|
+
for (const a of ['data-to', 'aria-label', 'aria-expanded']) if (f.hasAttribute(a)) el.setAttribute(a, f.getAttribute(a))
|
|
2435
|
+
el.disabled = !!f.disabled
|
|
2436
|
+
if (!el.classList.contains('cal-nav')) el.innerHTML = f.innerHTML
|
|
2437
|
+
})
|
|
2438
|
+
for (const el of nav.querySelectorAll('.cal-grid, .cal-none')) el.remove()
|
|
2439
|
+
nav.append(...t.querySelectorAll('.cal-grid, .cal-none'))
|
|
2440
|
+
return nav
|
|
2441
|
+
}
|
|
2442
|
+
|
|
1729
2443
|
// Digest (v0.5.8): one digest a day for the whole folder. The strip has one
|
|
1730
2444
|
// pill per day: the shared digest, else (days from before 0.5.8) a Mac's own,
|
|
1731
2445
|
// labelled with that Mac. Space tabs (All / Work / Personal …) sit above the
|
|
@@ -1750,16 +2464,16 @@ async function viewDigest(date, machine) {
|
|
|
1750
2464
|
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
2465
|
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
2466
|
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>'
|
|
2467
|
+
// 0.9.8: a month calendar, not a long list: days with a digest are links
|
|
2468
|
+
if (digestUi.date !== pick || !digestUi.month) { digestUi.month = monthOf(pick || localDay()); digestUi.calOpen = false }
|
|
2469
|
+
digestUi.cal = { days: days.map((day) => ({ day, d: lead(day) })), pick }
|
|
2470
|
+
html += '<div class="digest-wrap"><nav class="digest-cal" aria-label="Past digests">' + digestCalendar() + '</nav>'
|
|
1759
2471
|
let spaces = []
|
|
1760
2472
|
if (pick) {
|
|
1761
2473
|
try {
|
|
1762
|
-
|
|
2474
|
+
// 0.9.8: the sessions too, for Start here's Go / Resume
|
|
2475
|
+
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)])
|
|
2476
|
+
if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
|
|
1763
2477
|
const fm = d.meta || {}
|
|
1764
2478
|
const madeAt = fm.generated_at
|
|
1765
2479
|
spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
|
|
@@ -1769,10 +2483,14 @@ async function viewDigest(date, machine) {
|
|
|
1769
2483
|
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
2484
|
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
2485
|
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>'
|
|
2486
|
+
'<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
2487
|
} catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
|
|
1774
2488
|
} 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
2489
|
$app.innerHTML = html + '</div>'
|
|
2490
|
+
// 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
|
|
2491
|
+
const art = $app.querySelector('article.md')
|
|
2492
|
+
const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
|
|
2493
|
+
if (after) after.after(...['.digest-hint', '#digestSpaces', '#digestRepos'].map((q) => art.querySelector(q)).filter(Boolean))
|
|
1776
2494
|
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
1777
2495
|
state.digestRepos = readFilter(params, window.localStorage).repos
|
|
1778
2496
|
state.digestSpaces = spaces
|
|
@@ -1794,7 +2512,7 @@ function paintDigestFilter() {
|
|
|
1794
2512
|
const spaces = state.digestSpaces || []
|
|
1795
2513
|
const sp = state.digestSpace
|
|
1796
2514
|
const secs = [...document.querySelectorAll('.md section')]
|
|
1797
|
-
for (const sec of secs) sec.hidden = !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
2515
|
+
for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
1798
2516
|
const tabsBox = document.getElementById('digestSpaces')
|
|
1799
2517
|
if (tabsBox) {
|
|
1800
2518
|
const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
|
|
@@ -1812,8 +2530,19 @@ function paintDigestFilter() {
|
|
|
1812
2530
|
const any = [...ul.children].some((li) => !li.hidden)
|
|
1813
2531
|
ul.hidden = !any
|
|
1814
2532
|
if (h && (h.tagName === 'H2' || h.tagName === 'H3')) h.hidden = !any
|
|
2533
|
+
const fin = ul.closest('details.fin')
|
|
2534
|
+
if (fin) fin.hidden = !any
|
|
1815
2535
|
}
|
|
1816
2536
|
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
|
|
2537
|
+
// 0.9.8: a Start here step follows its session's line: hidden with it (space or repo)
|
|
2538
|
+
const startSec = document.querySelector('.md section.start-sec')
|
|
2539
|
+
if (startSec) {
|
|
2540
|
+
for (const li of startSec.querySelectorAll('li[data-sid]')) {
|
|
2541
|
+
const twin = [...document.querySelectorAll('.md li[data-repo][data-sid]')].find((x) => x.dataset.sid === li.dataset.sid)
|
|
2542
|
+
li.hidden = !!twin && (twin.hidden || !!(twin.closest('section') && twin.closest('section').hidden))
|
|
2543
|
+
}
|
|
2544
|
+
startSec.hidden = ![...startSec.querySelectorAll('ol.start > li')].some((li) => !li.hidden)
|
|
2545
|
+
}
|
|
1817
2546
|
if (counts.size < 2) { box.innerHTML = ''; return }
|
|
1818
2547
|
const on = (r) => sel.some((tok) => repoMatches({ repoKey: r, repoLabel: r }, tok))
|
|
1819
2548
|
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 +2847,13 @@ async function tryLength() {
|
|
|
2118
2847
|
const s = list[0]
|
|
2119
2848
|
if (!s) { toast('No session yet to try it on'); return }
|
|
2120
2849
|
box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
|
|
2850
|
+
// the page can be redrawn while the summarizer writes (Save re-renders Settings): put the result in the box on screen then
|
|
2851
|
+
const now = () => document.getElementById('f-length-example') || box
|
|
2121
2852
|
try {
|
|
2122
2853
|
const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
|
|
2123
|
-
|
|
2854
|
+
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
2855
|
cpBlock(cp, { showWhen: false, md: true }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
|
|
2125
|
-
} catch (e) {
|
|
2856
|
+
} catch (e) { now().innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
|
|
2126
2857
|
}
|
|
2127
2858
|
|
|
2128
2859
|
// Am I the iPhone home-screen app (or another installed app window)?
|
|
@@ -2553,6 +3284,7 @@ async function viewSettings() {
|
|
|
2553
3284
|
general: '<div class="section">Needs you</div>' +
|
|
2554
3285
|
'<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
3286
|
'<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
|
|
3287
|
+
'<div class="full push-slot" data-push-slot hidden></div>' +
|
|
2556
3288
|
'<div class="section">Appearance</div>' +
|
|
2557
3289
|
'<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
3290
|
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 +3317,7 @@ async function viewSettings() {
|
|
|
2585
3317
|
updates: '<div class="section">About</div>' +
|
|
2586
3318
|
'<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
3319
|
'<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>' +
|
|
3320
|
+
'<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a> · <a href="#/tour">Take the tour</a></div>' +
|
|
2589
3321
|
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
3322
|
'<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
|
|
2591
3323
|
'<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
|
|
@@ -3284,7 +4016,7 @@ function paintListsCount(lists) {
|
|
|
3284
4016
|
// The session page's Lists section: saved lists, the hint, and "Find lists in this session".
|
|
3285
4017
|
function sessionListsSection(s) {
|
|
3286
4018
|
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-
|
|
4019
|
+
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
4020
|
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
4021
|
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
4022
|
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 +4047,7 @@ function parseRoute() {
|
|
|
3315
4047
|
async function render() {
|
|
3316
4048
|
const r = (state.route = parseRoute())
|
|
3317
4049
|
paintNav(r)
|
|
4050
|
+
tabsOnRoute(r)
|
|
3318
4051
|
try {
|
|
3319
4052
|
if (r.name === 's' && r.arg) await viewSession(r.arg)
|
|
3320
4053
|
else if (r.name === 'waiting') await viewWaiting()
|
|
@@ -3328,7 +4061,9 @@ async function render() {
|
|
|
3328
4061
|
else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
|
|
3329
4062
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
3330
4063
|
else if (r.name === 'setup') await viewSetup()
|
|
4064
|
+
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
3331
4065
|
else await viewBoard()
|
|
4066
|
+
tabsAfterRender()
|
|
3332
4067
|
} catch (e) {
|
|
3333
4068
|
if (e.code === 'login_required') return showLogin()
|
|
3334
4069
|
$app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
|
|
@@ -3339,12 +4074,20 @@ async function render() {
|
|
|
3339
4074
|
// it keeps its own step and fetches its own state.
|
|
3340
4075
|
async function viewSetup() {
|
|
3341
4076
|
if ($app.querySelector('.wz')) return
|
|
3342
|
-
await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
|
|
4077
|
+
await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/'; autoTour() } })
|
|
3343
4078
|
}
|
|
3344
4079
|
// Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
|
|
4080
|
+
// Otherwise the product tour gets its turn.
|
|
3345
4081
|
async function firstRunSetup() {
|
|
3346
|
-
if (location.hash.replace(/^#\/?/, ''))
|
|
3347
|
-
|
|
4082
|
+
if (!location.hash.replace(/^#\/?/, '')) {
|
|
4083
|
+
try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) return location.replace('#/setup') } catch {}
|
|
4084
|
+
}
|
|
4085
|
+
autoTour()
|
|
4086
|
+
}
|
|
4087
|
+
// The product tour (0.9.8, ui/tour.js): by itself once per Mac, after setup, on
|
|
4088
|
+
// the board; #/tour (Settings → Updates & about, the palette) replays it.
|
|
4089
|
+
async function autoTour() {
|
|
4090
|
+
try { const t = await api('GET', '/v1/tour'); if (shouldAutoTour(t, parseRoute().name)) startTour({ api, auto: true }) } catch {}
|
|
3348
4091
|
}
|
|
3349
4092
|
|
|
3350
4093
|
// A remote browser (Tailscale) that hasn't signed in yet: the iPhone
|
|
@@ -3403,7 +4146,9 @@ async function refreshHealth() {
|
|
|
3403
4146
|
if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
|
|
3404
4147
|
paintMoreDots()
|
|
3405
4148
|
paintSettingsDots()
|
|
4149
|
+
if (updateBar) updateBar.check(h)
|
|
3406
4150
|
} catch (e) {
|
|
4151
|
+
if (updateBar) updateBar.check(null) // a failed check changes nothing
|
|
3407
4152
|
el.className = 'health down'
|
|
3408
4153
|
el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
|
|
3409
4154
|
el.title = el.querySelector('.label').textContent
|
|
@@ -3436,12 +4181,12 @@ function paintTopDot() {
|
|
|
3436
4181
|
}
|
|
3437
4182
|
|
|
3438
4183
|
// ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
|
|
3439
|
-
// From
|
|
4184
|
+
// From 700px up the sections sit in a rail on the left; under it, in a bottom
|
|
3440
4185
|
// bar with the rest under More. Hover the Needs you item (or long-press it on a
|
|
3441
4186
|
// phone, or press ↓/→ on it) for Quick look: the count and the five longest
|
|
3442
4187
|
// waits, with Allow / Deny on permission prompts (the Needs you page's own
|
|
3443
4188
|
// buttons, so the same checks apply and nothing is answered without a click).
|
|
3444
|
-
const railMode = () => window.matchMedia('(min-width:
|
|
4189
|
+
const railMode = () => window.matchMedia('(min-width: 700px)').matches
|
|
3445
4190
|
function paintNav(r = state.route || parseRoute()) {
|
|
3446
4191
|
const key = navKey(r.name)
|
|
3447
4192
|
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 +4215,7 @@ function openMore() {
|
|
|
3470
4215
|
if (!m) return
|
|
3471
4216
|
closeQuickLook(); toggleStatus(false)
|
|
3472
4217
|
paintMoreDots()
|
|
4218
|
+
paintPush()
|
|
3473
4219
|
m.hidden = false
|
|
3474
4220
|
if (b) b.setAttribute('aria-expanded', 'true')
|
|
3475
4221
|
const first = m.querySelector('a.on') || m.querySelector('a')
|
|
@@ -3598,6 +4344,214 @@ function closeQuickLook(focusTab = false) {
|
|
|
3598
4344
|
window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
|
|
3599
4345
|
}
|
|
3600
4346
|
|
|
4347
|
+
// ------------------------------------------------------------ tabs for sessions (0.9.8, ui/tabs.js)
|
|
4348
|
+
// The board is the first tab; a session opens as the one preview tab (italic)
|
|
4349
|
+
// that the next click replaces, and stays open once you send a Live message,
|
|
4350
|
+
// type there, choose Keep open or double-click its tab (⌘/Ctrl-click or a
|
|
4351
|
+
// middle-click opens it permanent straight away). Kept per device in
|
|
4352
|
+
// localStorage. By width: under 700px the Open row on the board and ‹ N of M ›
|
|
4353
|
+
// (or a swipe) on a session page; from 700px the strip. A width change (fold,
|
|
4354
|
+
// Split View, rotation) only changes the CSS: the open set, the session and
|
|
4355
|
+
// where you were on the page stay.
|
|
4356
|
+
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: {} }
|
|
4357
|
+
window.leftoffTabs = { model: () => JSON.parse(serialize(tabsUi.m)) } // read by test:tabs-ui
|
|
4358
|
+
function setTabs(m) {
|
|
4359
|
+
if (m === tabsUi.m) return
|
|
4360
|
+
tabsUi.m = m
|
|
4361
|
+
try { localStorage.setItem(TABS_KEY, serialize(m)) } catch {}
|
|
4362
|
+
}
|
|
4363
|
+
const tabSession = (id) => state.sessions.find((s) => s.id === id)
|
|
4364
|
+
const tabViews = () => tabsUi.m.tabs.map((t) => tabView(t, tabSession(t.id), { title: sessionTitle, working: (s) => liveWorking(s) }))
|
|
4365
|
+
function tabsOnRoute(r) {
|
|
4366
|
+
const id = r.name === 's' && r.arg ? String(r.arg) : null
|
|
4367
|
+
if (tabsUi.active && tabsUi.active !== id) tabsUi.scrolls[tabsUi.active] = window.scrollY || 0
|
|
4368
|
+
if (id) {
|
|
4369
|
+
const permanent = tabsUi.permanent === id
|
|
4370
|
+
setTabs(openTab(tabsUi.m, id, { permanent, hash: keepHash(location.hash), title: (tabSession(id) && sessionTitle(tabSession(id))) || '' }))
|
|
4371
|
+
}
|
|
4372
|
+
tabsUi.permanent = null
|
|
4373
|
+
const changed = tabsUi.active !== id || tabsUi.route !== r.name
|
|
4374
|
+
tabsUi.active = id; tabsUi.route = r.name
|
|
4375
|
+
document.body.dataset.route = r.name
|
|
4376
|
+
paintTabs()
|
|
4377
|
+
if (changed) scrollActiveTab()
|
|
4378
|
+
}
|
|
4379
|
+
function tabsAfterRender() {
|
|
4380
|
+
let m = tabsUi.m
|
|
4381
|
+
for (const t of m.tabs) { const s = tabSession(t.id); if (s) m = remember(m, t.id, { title: sessionTitle(s) }) }
|
|
4382
|
+
setTabs(m)
|
|
4383
|
+
paintTabs()
|
|
4384
|
+
const back = tabsUi.active && tabsUi.restoreScroll === tabsUi.active ? tabsUi.scrolls[tabsUi.active] : null
|
|
4385
|
+
tabsUi.restoreScroll = null
|
|
4386
|
+
if (back != null) window.scrollTo(0, back)
|
|
4387
|
+
noteAnchor()
|
|
4388
|
+
// the other open tabs' pulse and dot: read the sessions again now and then while you're off the board
|
|
4389
|
+
if (tabsUi.route !== 'board' && tabsUi.m.tabs.some((t) => t.id !== tabsUi.active) && Date.now() - tabsUi.fetchedAt > 15000) refreshTabSessions()
|
|
4390
|
+
}
|
|
4391
|
+
async function refreshTabSessions() {
|
|
4392
|
+
tabsUi.fetchedAt = Date.now()
|
|
4393
|
+
try {
|
|
4394
|
+
const { sessions = [], parked = [] } = await api('GET', '/v1/sessions?limit=200')
|
|
4395
|
+
const fresh = new Map(sessions.concat(parked).map((s) => [s.id, s]))
|
|
4396
|
+
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)))
|
|
4397
|
+
paintTabs()
|
|
4398
|
+
} catch {}
|
|
4399
|
+
}
|
|
4400
|
+
function paintTabs() {
|
|
4401
|
+
const views = tabViews()
|
|
4402
|
+
const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
|
|
4403
|
+
if (!strip) return
|
|
4404
|
+
const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
|
|
4405
|
+
if (sh !== tabsUi.html.strip) {
|
|
4406
|
+
const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
|
|
4407
|
+
const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
|
|
4408
|
+
strip.innerHTML = sh; tabsUi.html.strip = sh
|
|
4409
|
+
const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
|
|
4410
|
+
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 }) }
|
|
4411
|
+
}
|
|
4412
|
+
strip.hidden = false
|
|
4413
|
+
const rh = openRowHtml(views, esc)
|
|
4414
|
+
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 }
|
|
4415
|
+
row.hidden = !rh
|
|
4416
|
+
const ph = tabsUi.active ? pagerHtml(tabsUi.m, tabsUi.active, views, esc) : ''
|
|
4417
|
+
if (ph !== tabsUi.html.pager) { pager.innerHTML = ph; tabsUi.html.pager = ph }
|
|
4418
|
+
pager.hidden = !ph
|
|
4419
|
+
}
|
|
4420
|
+
function scrollActiveTab() {
|
|
4421
|
+
const el = document.querySelector('#tabStrip .tab-cell.on'), sc = document.querySelector('#tabStrip .tab-scroll')
|
|
4422
|
+
if (!el || !sc) return
|
|
4423
|
+
const r = el.getBoundingClientRect(), b = sc.getBoundingClientRect()
|
|
4424
|
+
if (r.left < b.left) sc.scrollLeft -= b.left - r.left + 8
|
|
4425
|
+
else if (r.right > b.right) sc.scrollLeft += r.right - b.right + 8
|
|
4426
|
+
}
|
|
4427
|
+
function keepTab(id) { setTabs(promote(tabsUi.m, id)); paintTabs() }
|
|
4428
|
+
function closeTabNow(id) {
|
|
4429
|
+
const { model, next } = closeTab(tabsUi.m, id, tabsUi.active)
|
|
4430
|
+
setTabs(model)
|
|
4431
|
+
delete tabsUi.scrolls[id]
|
|
4432
|
+
if (tabsUi.active === String(id)) {
|
|
4433
|
+
const t = next && model.tabs.find((x) => x.id === next)
|
|
4434
|
+
if (t) tabsUi.restoreScroll = t.id
|
|
4435
|
+
location.hash = t ? tabView(t).href : '#/'
|
|
4436
|
+
} else paintTabs()
|
|
4437
|
+
}
|
|
4438
|
+
function goTab(href) {
|
|
4439
|
+
const id = sessionOfHash(href)
|
|
4440
|
+
if (id) tabsUi.restoreScroll = id
|
|
4441
|
+
if (location.hash === href) render(); else location.hash = href
|
|
4442
|
+
}
|
|
4443
|
+
// ⌘/Ctrl-click or a middle-click on any session link: open it as a permanent tab (here, not a new browser tab)
|
|
4444
|
+
function sessionLinkId(a) { return a && a.getAttribute && sessionOfHash(a.getAttribute('href') || '') }
|
|
4445
|
+
document.addEventListener('click', (e) => {
|
|
4446
|
+
const a = e.target.closest && e.target.closest('a[href^="#/s/"]')
|
|
4447
|
+
if (!a || !(e.metaKey || e.ctrlKey) || e.button !== 0) return
|
|
4448
|
+
const id = sessionLinkId(a); if (!id) return
|
|
4449
|
+
e.preventDefault(); e.stopPropagation()
|
|
4450
|
+
if (indexOf(tabsUi.m, id) >= 0) setTabs(promote(tabsUi.m, id)); else tabsUi.permanent = id
|
|
4451
|
+
if (location.hash === a.getAttribute('href')) render(); else location.hash = a.getAttribute('href')
|
|
4452
|
+
}, true)
|
|
4453
|
+
document.addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest && e.target.closest('a[href^="#/s/"], #tabStrip .tab-cell')) e.preventDefault() }) // no autoscroll
|
|
4454
|
+
document.addEventListener('auxclick', (e) => {
|
|
4455
|
+
if (e.button !== 1 || !e.target.closest) return
|
|
4456
|
+
const cell = e.target.closest('#tabStrip .tab-cell')
|
|
4457
|
+
if (cell) { e.preventDefault(); closeTabNow(cell.dataset.tabId); return } // middle-click a tab: close it
|
|
4458
|
+
const a = e.target.closest('a[href^="#/s/"]')
|
|
4459
|
+
const id = sessionLinkId(a); if (!id) return
|
|
4460
|
+
e.preventDefault()
|
|
4461
|
+
if (indexOf(tabsUi.m, id) >= 0) { setTabs(promote(tabsUi.m, id)); paintTabs() }
|
|
4462
|
+
else { setTabs(openTab(tabsUi.m, id, { permanent: true })); paintTabs() } // opened in the background, the way a middle-click does
|
|
4463
|
+
})
|
|
4464
|
+
document.addEventListener('click', (e) => {
|
|
4465
|
+
if (!e.target.closest) return
|
|
4466
|
+
const close = e.target.closest('[data-tab-close]')
|
|
4467
|
+
if (close) { e.preventDefault(); return closeTabNow(close.dataset.tabClose) }
|
|
4468
|
+
const keep = e.target.closest('[data-tab-keep]')
|
|
4469
|
+
if (keep) { e.preventDefault(); return keepTab(keep.dataset.tabKeep) }
|
|
4470
|
+
const go = e.target.closest('[data-tab-go]')
|
|
4471
|
+
if (go) { e.preventDefault(); return goTab(go.dataset.tabGo) }
|
|
4472
|
+
const all = e.target.closest('#tabAll')
|
|
4473
|
+
if (all) { e.preventDefault(); return openActionMenu(all, allTabsItems(tabViews(), tabsUi.active), { title: 'Open sessions' }) }
|
|
4474
|
+
const t = e.target.closest('#tabStrip a[data-tab], #openRow a[data-tab], #tabPager a[data-tab]')
|
|
4475
|
+
if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
|
|
4476
|
+
})
|
|
4477
|
+
document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
|
|
4478
|
+
// Working in a session keeps its tab: a Live message, typing there, answering Claude there
|
|
4479
|
+
const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
|
|
4480
|
+
for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
|
|
4481
|
+
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)
|
|
4482
|
+
// Drag a tab to reorder
|
|
4483
|
+
{
|
|
4484
|
+
let dragId = null
|
|
4485
|
+
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 {} })
|
|
4486
|
+
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') })
|
|
4487
|
+
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() } })
|
|
4488
|
+
document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('#tabStrip .dragging, #tabStrip .drop-before').forEach((x) => x.classList.remove('dragging', 'drop-before')) })
|
|
4489
|
+
}
|
|
4490
|
+
// Phones: swipe left / right on a session page for the next / previous open session
|
|
4491
|
+
{
|
|
4492
|
+
let sw = null
|
|
4493
|
+
const narrowQ = window.matchMedia('(max-width: 699.98px)')
|
|
4494
|
+
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 }
|
|
4495
|
+
document.addEventListener('touchstart', (e) => {
|
|
4496
|
+
sw = null
|
|
4497
|
+
if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
|
|
4498
|
+
const t = e.target
|
|
4499
|
+
if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .moremenu') || scrollsSideways(t)) return
|
|
4500
|
+
sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
|
|
4501
|
+
}, { passive: true })
|
|
4502
|
+
document.addEventListener('touchend', (e) => {
|
|
4503
|
+
if (!sw || !e.changedTouches.length) return
|
|
4504
|
+
const p = e.changedTouches[0]
|
|
4505
|
+
const dir = swipeDir(p.clientX - sw.x, p.clientY - sw.y, Date.now() - sw.at)
|
|
4506
|
+
sw = null
|
|
4507
|
+
if (!dir || (window.getSelection && String(window.getSelection()))) return
|
|
4508
|
+
const n = neighbours(tabsUi.m, tabsUi.active)
|
|
4509
|
+
const to = dir === 'next' ? n.next : n.prev
|
|
4510
|
+
if (!to) return
|
|
4511
|
+
const t = tabsUi.m.tabs.find((x) => x.id === to)
|
|
4512
|
+
document.body.classList.remove('swipe-next', 'swipe-prev'); void document.body.offsetWidth
|
|
4513
|
+
document.body.classList.add('swipe-' + dir); setTimeout(() => document.body.classList.remove('swipe-' + dir), 260)
|
|
4514
|
+
goTab(tabView(t).href)
|
|
4515
|
+
}, { passive: true })
|
|
4516
|
+
}
|
|
4517
|
+
// A width change keeps your place: the element at the top of the page stays at
|
|
4518
|
+
// the top, and a Live chat that was at its newest stays there.
|
|
4519
|
+
function anchorY() {
|
|
4520
|
+
let y = 0
|
|
4521
|
+
for (const el of [document.querySelector('.top'), document.getElementById('tabStrip'), document.getElementById('tabPager')]) {
|
|
4522
|
+
if (!el) continue
|
|
4523
|
+
const cs = getComputedStyle(el); if (cs.display === 'none' || !/(fixed|sticky)/.test(cs.position)) continue
|
|
4524
|
+
const r = el.getBoundingClientRect(); if (r.top <= 1 && r.bottom < innerHeight / 2) y = Math.max(y, r.bottom)
|
|
4525
|
+
}
|
|
4526
|
+
return y
|
|
4527
|
+
}
|
|
4528
|
+
function noteAnchor() {
|
|
4529
|
+
if (Date.now() < tabsUi.quietUntil) return
|
|
4530
|
+
const y = anchorY() + 6, m = $app.getBoundingClientRect()
|
|
4531
|
+
if (m.bottom < y) { tabsUi.anchor = null; return }
|
|
4532
|
+
let el = document.elementFromPoint(Math.max(m.left + 24, Math.min(m.left + m.width / 2, innerWidth - 24)), y)
|
|
4533
|
+
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 }
|
|
4534
|
+
tabsUi.anchor = el ? { el, top: el.getBoundingClientRect().top, scrollY: window.scrollY } : null
|
|
4535
|
+
}
|
|
4536
|
+
function keepPlace() {
|
|
4537
|
+
const a = tabsUi.anchor
|
|
4538
|
+
tabsUi.quietUntil = Date.now() + 250
|
|
4539
|
+
if (a && a.el.isConnected && a.scrollY > 0) window.scrollBy(0, a.el.getBoundingClientRect().top - a.top)
|
|
4540
|
+
document.querySelectorAll('.live-log').forEach((log) => { const st = tabsUi.logs.get(log); if (st && st.bottom) log.scrollTop = log.scrollHeight })
|
|
4541
|
+
}
|
|
4542
|
+
{
|
|
4543
|
+
let raf = 0
|
|
4544
|
+
window.addEventListener('scroll', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; noteAnchor() }) }, { passive: true })
|
|
4545
|
+
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)
|
|
4546
|
+
let lay = layoutFor(innerWidth)
|
|
4547
|
+
window.addEventListener('resize', () => {
|
|
4548
|
+
keepPlace()
|
|
4549
|
+
const now = layoutFor(innerWidth)
|
|
4550
|
+
if (now !== lay) { lay = now; document.body.dataset.layout = now; scrollActiveTab() }
|
|
4551
|
+
})
|
|
4552
|
+
document.body.dataset.layout = lay
|
|
4553
|
+
}
|
|
4554
|
+
|
|
3601
4555
|
// Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
|
|
3602
4556
|
// past the line; the page reads its data again, the same as a live update (no
|
|
3603
4557
|
// full reload unless that fails). Never from a field, the Live box or a menu.
|
|
@@ -3606,8 +4560,8 @@ function closeQuickLook(focusTab = false) {
|
|
|
3606
4560
|
ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
|
|
3607
4561
|
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
4562
|
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')
|
|
4563
|
+
const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
|
|
4564
|
+
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
4565
|
let pull = null, refreshing = false
|
|
3612
4566
|
const paint = (step) => {
|
|
3613
4567
|
ptr.hidden = !step || (!step.pulling && !refreshing)
|
|
@@ -3657,6 +4611,43 @@ function closeQuickLook(focusTab = false) {
|
|
|
3657
4611
|
document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
|
|
3658
4612
|
}
|
|
3659
4613
|
|
|
4614
|
+
// Hide the nav on scroll (phones, 0.9.8): under 700px the top row and the bottom
|
|
4615
|
+
// bar slide away while you scroll down and come back when you scroll up, reach
|
|
4616
|
+
// the top or the very bottom (ui/navhide.js). Only your own scrolling hides
|
|
4617
|
+
// them: a route change, the ‹ › pager or a swipe shows them and holds them until
|
|
4618
|
+
// you next touch, wheel or key; typing, a menu or a sheet keeps them shown. The
|
|
4619
|
+
// page never reflows: the top row stays in the flow and the bar is an overlay
|
|
4620
|
+
// over padding that stays.
|
|
4621
|
+
{
|
|
4622
|
+
const narrowQ = window.matchMedia('(max-width: 699.98px)')
|
|
4623
|
+
const top = document.querySelector('.top')
|
|
4624
|
+
let st = { hidden: false, anchor: 0 }, raf = 0, held = true
|
|
4625
|
+
const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
|
|
4626
|
+
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])')
|
|
4627
|
+
const apply = (hidden) => {
|
|
4628
|
+
if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
|
|
4629
|
+
document.body.classList.toggle('nav-hidden', hidden)
|
|
4630
|
+
}
|
|
4631
|
+
const read = () => {
|
|
4632
|
+
raf = 0
|
|
4633
|
+
const se = document.scrollingElement || document.documentElement
|
|
4634
|
+
const y = window.scrollY || se.scrollTop || 0
|
|
4635
|
+
const next = navHideStep(st, { y, max: se.scrollHeight - window.innerHeight, blocked: held || !narrowQ.matches || typing() || overlay() })
|
|
4636
|
+
if (next.hidden !== st.hidden) apply(next.hidden)
|
|
4637
|
+
st = next
|
|
4638
|
+
}
|
|
4639
|
+
const soon = () => { if (!raf) raf = requestAnimationFrame(read) }
|
|
4640
|
+
// no layout read here: this runs on every route change, ahead of the page's own render
|
|
4641
|
+
const showNav = (hold = true) => { if (hold) held = true; const was = st.hidden; st = { hidden: false, anchor: NaN }; if (was) apply(false) }
|
|
4642
|
+
window.addEventListener('scroll', soon, { passive: true })
|
|
4643
|
+
for (const ev of ['touchstart', 'wheel', 'keydown']) window.addEventListener(ev, () => { held = false }, { passive: true, capture: true })
|
|
4644
|
+
window.addEventListener('hashchange', () => showNav(true))
|
|
4645
|
+
document.addEventListener('focusin', (e) => { if (typing() || (top && top.contains(e.target))) showNav(false) })
|
|
4646
|
+
document.addEventListener('click', soon, { passive: true, capture: true }) // a menu or sheet just opened: back they come
|
|
4647
|
+
narrowQ.addEventListener('change', () => showNav(false))
|
|
4648
|
+
window.leftoffNav = { show: () => showNav(true), hidden: () => document.body.classList.contains('nav-hidden') }
|
|
4649
|
+
}
|
|
4650
|
+
|
|
3660
4651
|
// Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
|
|
3661
4652
|
const statusOpen = () => { const p = document.getElementById('statusPop'); return p && !p.hidden }
|
|
3662
4653
|
function syncLine() {
|
|
@@ -3790,6 +4781,7 @@ function paletteActions() {
|
|
|
3790
4781
|
}
|
|
3791
4782
|
if (projectsUi.mayEdit) out.push({ action: 'project-new', title: 'New project…', sub: 'a named group of repos', icon: 'plus' })
|
|
3792
4783
|
out.push({ action: 'gen-digest-go', title: 'Today’s digest', sub: 'open it', icon: 'newspaper', href: '#/digest' })
|
|
4784
|
+
out.push({ action: 'tour', title: 'Take the tour', sub: 'a quick look around the board', icon: 'sparkles', href: '#/tour' })
|
|
3793
4785
|
return out
|
|
3794
4786
|
}
|
|
3795
4787
|
function searchGroups(q, r) {
|
|
@@ -3939,11 +4931,27 @@ document.addEventListener('keydown', (e) => {
|
|
|
3939
4931
|
|
|
3940
4932
|
// A card opens its session's page from anywhere on it, except its buttons,
|
|
3941
4933
|
// links, fields and selected text (0.4.3: the timeline was too hidden).
|
|
4934
|
+
// 0.9.8: the tap shows at once (a pressed card with a spinner); the page's
|
|
4935
|
+
// shell follows in the same frame (viewSession).
|
|
4936
|
+
function cardOpening(c) {
|
|
4937
|
+
for (const x of document.querySelectorAll('.card.opening')) if (x !== c) x.classList.remove('opening')
|
|
4938
|
+
c.classList.add('opening')
|
|
4939
|
+
setTimeout(() => { if (c.isConnected) c.classList.remove('opening') }, 4000)
|
|
4940
|
+
}
|
|
4941
|
+
document.addEventListener('pointerdown', (ev) => {
|
|
4942
|
+
const c = ev.target.closest && ev.target.closest('.card[data-href]')
|
|
4943
|
+
if (!c || ev.button > 0 || ev.target.closest('button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
|
|
4944
|
+
const a = ev.target.closest('a')
|
|
4945
|
+
if (a && !/^#\/s\//.test(a.getAttribute('href') || '')) return // a repo chip, a PR link: not opening the session
|
|
4946
|
+
cardOpening(c)
|
|
4947
|
+
}, { passive: true })
|
|
4948
|
+
document.addEventListener('pointercancel', () => { for (const x of document.querySelectorAll('.card.opening')) x.classList.remove('opening') }, true)
|
|
3942
4949
|
document.addEventListener('click', (ev) => {
|
|
3943
4950
|
const c = ev.target.closest('.card[data-href]')
|
|
3944
4951
|
if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
|
|
3945
4952
|
if (String(window.getSelection && window.getSelection()).trim()) return
|
|
3946
4953
|
if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
|
|
4954
|
+
cardOpening(c)
|
|
3947
4955
|
location.hash = c.dataset.href
|
|
3948
4956
|
})
|
|
3949
4957
|
|
|
@@ -3990,7 +4998,14 @@ document.addEventListener('click', async (ev) => {
|
|
|
3990
4998
|
const id = btn.dataset.id || (card && card.dataset.id)
|
|
3991
4999
|
const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
|
|
3992
5000
|
try {
|
|
3993
|
-
if (action === 'copy-cmd') {
|
|
5001
|
+
if (action === 'copy-cmd') {
|
|
5002
|
+
const ok = await copy(btn.dataset.cmd)
|
|
5003
|
+
if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
|
|
5004
|
+
clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
|
|
5005
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy Live resume'); btn.dataset.tip = 'Copy' }, 1500)
|
|
5006
|
+
}
|
|
5007
|
+
return
|
|
5008
|
+
}
|
|
3994
5009
|
// PRs (0.6.9)
|
|
3995
5010
|
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
5011
|
// 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
|
|
@@ -4093,6 +5108,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
4093
5108
|
if (action === 'list-showall') { listUi.showAll = !listUi.showAll; return render() }
|
|
4094
5109
|
if (action === 'list-help') { listUi.help = !listUi.help; return render() }
|
|
4095
5110
|
if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
|
|
5111
|
+
if (action === 'list-help-inline') { listUi.sHelp = !listUi.sHelp; render(); return }
|
|
4096
5112
|
if (action === 'remote-link' || action === 'remote-code') {
|
|
4097
5113
|
const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
|
|
4098
5114
|
if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
|
|
@@ -4280,6 +5296,19 @@ document.addEventListener('click', async (ev) => {
|
|
|
4280
5296
|
saveSpace(state.digestSpace)
|
|
4281
5297
|
return paintDigestFilter()
|
|
4282
5298
|
}
|
|
5299
|
+
if (action === 'digest-cal-fold') {
|
|
5300
|
+
digestUi.calOpen = !digestUi.calOpen
|
|
5301
|
+
repaintCalendar()
|
|
5302
|
+
return
|
|
5303
|
+
}
|
|
5304
|
+
if (action === 'digest-month') {
|
|
5305
|
+
const back = btn.dataset.to < (digestUi.month || '')
|
|
5306
|
+
digestUi.month = btn.dataset.to
|
|
5307
|
+
digestUi.calOpen = true // another month: show all of it
|
|
5308
|
+
const nav = repaintCalendar()
|
|
5309
|
+
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
|
|
5310
|
+
return
|
|
5311
|
+
}
|
|
4283
5312
|
if (action === 'standup-copy') return standupCopy(btn)
|
|
4284
5313
|
if (action === 'standup-close') { const b = btn.closest('.standup-out'); if (b) b.remove(); digestUi.open = null; return }
|
|
4285
5314
|
if (action === 'space-quick' || action === 'space-repo-add' || action === 'space-unset') return spacesAction(action, btn)
|
|
@@ -4305,7 +5334,13 @@ document.addEventListener('click', async (ev) => {
|
|
|
4305
5334
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
4306
5335
|
if (action === 'reply-send') return replyClick(btn)
|
|
4307
5336
|
if (action === 'live-send') return liveSendClick(btn)
|
|
5337
|
+
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
|
|
5338
|
+
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
5339
|
+
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
5340
|
+
if (action === 'att-open') return openAttachment(btn)
|
|
5341
|
+
if (action === 'att-chip-open') return openChip(btn.dataset.lid)
|
|
4308
5342
|
if (action === 'live-jump') return liveJumpClick(btn)
|
|
5343
|
+
if (action === 'live-earlier') return liveEarlierClick(btn)
|
|
4309
5344
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
4310
5345
|
if (action === 'need-more') return needMoreClick(btn)
|
|
4311
5346
|
if (action === 'need-dismiss') return needDismissClick(btn)
|
|
@@ -4791,16 +5826,21 @@ let stream = null
|
|
|
4791
5826
|
function connect() {
|
|
4792
5827
|
if (stream) { try { stream.close() } catch {} }
|
|
4793
5828
|
const es = stream = new EventSource('/v1/stream')
|
|
5829
|
+
const again = connect.opened // a reconnect: events may have been missed while it was down
|
|
5830
|
+
streamDog.opened()
|
|
5831
|
+
// 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
|
|
5832
|
+
es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
|
|
4794
5833
|
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
4795
5834
|
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)
|
|
5835
|
+
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
5836
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
4798
5837
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
4799
5838
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
|
4800
5839
|
// A daemon that restarted (an upgrade, a folder move) comes back on the same
|
|
4801
5840
|
// port; if the browser gave up on the stream, open a new one.
|
|
4802
5841
|
es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
|
|
4803
|
-
|
|
5842
|
+
let opens = 0
|
|
5843
|
+
es.onopen = () => { if (stream !== es) return; streamDog.seen(); refreshHealth(); if (again || opens++) refreshView('reconnect'); connect.opened = true }
|
|
4804
5844
|
}
|
|
4805
5845
|
|
|
4806
5846
|
document.addEventListener('change', async (ev) => {
|
|
@@ -4903,9 +5943,51 @@ document.addEventListener('pointerdown', (ev) => {
|
|
|
4903
5943
|
if (m && !m.contains(ev.target)) m.open = false
|
|
4904
5944
|
})
|
|
4905
5945
|
api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
|
|
5946
|
+
// 0.9.8: "Leftoff updated · Refresh" when the Mac serving this board has newer
|
|
5947
|
+
// files than the ones this page runs. It never reloads by itself; Refresh
|
|
5948
|
+
// carries your drafts over and says first what wouldn't come along.
|
|
5949
|
+
updateBar = mountUpdateBar({
|
|
5950
|
+
icon,
|
|
5951
|
+
health: () => refreshHealth(),
|
|
5952
|
+
risks: () => {
|
|
5953
|
+
let attachments = 0, uploading = 0
|
|
5954
|
+
for (const list of attachUi.by.values()) for (const a of list) { attachments++; if (a.status === 'prep' || a.status === 'up') uploading++ }
|
|
5955
|
+
const f = document.activeElement
|
|
5956
|
+
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())
|
|
5957
|
+
let unsavedSettings = false
|
|
5958
|
+
try { unsavedSettings = settingsChanged().length > 0 } catch {}
|
|
5959
|
+
return { attachments, uploading, unsavedSettings, typing }
|
|
5960
|
+
},
|
|
5961
|
+
carry: () => {
|
|
5962
|
+
const drafts = { ...state.drafts }
|
|
5963
|
+
for (const t of document.querySelectorAll('textarea[data-draft]')) if (t.value) drafts[t.dataset.draft] = t.value
|
|
5964
|
+
const f = document.activeElement
|
|
5965
|
+
const focus = f && f.dataset && f.dataset.draft ? { key: f.dataset.draft, a: f.selectionStart || 0, b: f.selectionEnd || 0 } : lastDraftFocus
|
|
5966
|
+
return { drafts, focus }
|
|
5967
|
+
},
|
|
5968
|
+
})
|
|
5969
|
+
// The box you were typing in, if you tabbed from it to the bar.
|
|
5970
|
+
let lastDraftFocus = null
|
|
5971
|
+
document.addEventListener('focusin', (ev) => { if (!(ev.target.closest && ev.target.closest('#updateBar'))) lastDraftFocus = null })
|
|
5972
|
+
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 } })
|
|
5973
|
+
// Back from Refresh: the box you were typing in has its text, and (with a
|
|
5974
|
+
// keyboard and mouse) the cursor where it was.
|
|
5975
|
+
function restoreCarriedFocus(tries = 50) {
|
|
5976
|
+
const f = carried && carried.focus
|
|
5977
|
+
if (!f || isTouchUi(window.matchMedia)) return
|
|
5978
|
+
if (document.activeElement && document.activeElement !== document.body) return // you've moved on
|
|
5979
|
+
const t = [...document.querySelectorAll('textarea[data-draft]')].find((x) => x.dataset.draft === f.key)
|
|
5980
|
+
if (!t || t.disabled) { if (tries > 0) setTimeout(() => restoreCarriedFocus(tries - 1), 100); return } // the session page draws after its data
|
|
5981
|
+
t.focus({ preventScroll: true })
|
|
5982
|
+
try { const n = t.value.length; t.setSelectionRange(Math.min(f.a, n), Math.min(f.b, n)) } catch {}
|
|
5983
|
+
}
|
|
4906
5984
|
refreshHealth().then(render).then(firstRunSetup)
|
|
5985
|
+
restoreCarriedFocus()
|
|
4907
5986
|
connect()
|
|
4908
5987
|
|
|
4909
5988
|
// The home-screen app's service worker (v0.4.6): it only serves an offline page
|
|
4910
5989
|
// when the Mac can't be reached; it caches no API data and no board pages.
|
|
4911
5990
|
if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
|
|
5991
|
+
// 0.9.8: phone notifications. The one per-device button (More on a phone,
|
|
5992
|
+
// Settings → General everywhere); the service worker shows the pushes.
|
|
5993
|
+
mountPush({ api })
|