@leftoff/cli 0.9.8-beta.9 → 0.9.9-beta.1
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 -1
- package/bin/leftoff.js +405 -361
- package/docs/SECURITY.md +14 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +10 -0
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +458 -18
- package/ui/app.js +1088 -94
- package/ui/attach.js +211 -0
- package/ui/composer.js +84 -0
- package/ui/diff-view.js +278 -0
- package/ui/lightbox.js +452 -0
- package/ui/live-status.js +2 -1
- package/ui/live-view.js +22 -5
- package/ui/live-wake.js +96 -0
- package/ui/machines.js +17 -0
- package/ui/navhide.js +35 -0
- package/ui/needs.js +16 -11
- package/ui/pdfview.js +510 -0
- package/ui/prs.js +10 -5
- package/ui/relay-client.js +14 -4
- package/ui/restore-gate.js +20 -0
- package/ui/tabs.js +9 -5
- 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/what-it-did.js +149 -0
package/ui/app.js
CHANGED
|
@@ -5,29 +5,43 @@ 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, composerBoxHtml } 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'
|
|
18
25
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
19
26
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
20
|
-
import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
|
|
21
|
-
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
27
|
+
import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
|
|
28
|
+
import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
|
|
22
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
|
|
24
32
|
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
25
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
|
|
26
34
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
|
|
35
|
+
import { widState, whatItDidSection, copyText as widCopyText, loadError as widError, hiddenText as widHidden, slotSource as widSlot } from './what-it-did.js' // 0.9.8 What it did
|
|
36
|
+
import { hydrateDiffs, renderDiff } from './diff-view.js' // 0.9.8: a file's changes drawn like GitHub's
|
|
27
37
|
const $app = document.getElementById('app')
|
|
28
38
|
// 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
|
|
29
39
|
if (typeof MutationObserver === 'function') new MutationObserver(() => lockPulses(document)).observe(document.body, { childList: true, subtree: true })
|
|
30
40
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
41
|
+
// 0.9.8: drafts the update bar's Refresh carried over (ui/update-bar.js)
|
|
42
|
+
const carried = takeCarry()
|
|
43
|
+
if (carried) Object.assign(state.drafts, carried.drafts)
|
|
44
|
+
let updateBar = null
|
|
31
45
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
32
46
|
const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
|
|
33
47
|
|
|
@@ -318,6 +332,13 @@ function staleLine(sessions) {
|
|
|
318
332
|
const ms = [...new Set(sessions.filter((x) => !x.isLocal && x.machine in st).map((x) => x.machine))]
|
|
319
333
|
return ms.length ? '<div class="stale-line faint">' + ms.map((m) => esc(m) + ': showing data as of ' + esc(st[m] ? ago(st[m]) : 'its last sync')).join(' · ') + '</div>' : ''
|
|
320
334
|
}
|
|
335
|
+
// 0.9.8: a board filtered to one Mac says so, with the way back to every Mac.
|
|
336
|
+
function machineOnlyLine() {
|
|
337
|
+
if (!state.machine) return ''
|
|
338
|
+
const known = { machines: (state.health && state.health.machines) || [], agents: agentNames() }
|
|
339
|
+
const agent = known.agents.includes(state.machine) && !known.machines.includes(state.machine)
|
|
340
|
+
return '<div class="machine-only" role="status">' + (agent ? '' : mEmoji(state.machine) + ' ') + esc(machineOnlyLabel(state.machine, known)) + ' · <button type="button" class="linkish" data-action="machine-all">Show all Macs</button></div>'
|
|
341
|
+
}
|
|
321
342
|
function staleNote(machine) {
|
|
322
343
|
const st = state.health && state.health.sync && state.health.sync.stale
|
|
323
344
|
if (!st || !machine || !(machine in st)) return ''
|
|
@@ -405,15 +426,15 @@ function goButton(s, { page = false } = {}) {
|
|
|
405
426
|
if (!j.peer) return ''
|
|
406
427
|
// 0.9.2: "Go ↗" like a local card (a phone cut "Go on <machine>" short); the machine is in its title and label
|
|
407
428
|
const label = 'Go ↗', on = 'Go on ' + s.machine
|
|
408
|
-
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '"
|
|
409
|
-
if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '"
|
|
429
|
+
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" data-tip="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
|
|
430
|
+
if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" data-tip="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
|
|
410
431
|
const st = goState.get(s.id)
|
|
411
|
-
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '"
|
|
432
|
+
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" data-tip="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
|
|
412
433
|
}
|
|
413
|
-
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled
|
|
414
|
-
if (!j.supported) return '<button type="button" class="btn go" disabled
|
|
434
|
+
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled data-tip="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
|
|
435
|
+
if (!j.supported) return '<button type="button" class="btn go" disabled data-tip="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
|
|
415
436
|
const st = goState.get(s.id)
|
|
416
|
-
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗"
|
|
437
|
+
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗" data-tip="' + esc('Bring this session’s ' + (s.terminal ? s.terminal.label + ' ' : '') + 'tab to the front') + '">' + esc(st ? st.label : 'Go ↗') + '</button>'
|
|
417
438
|
}
|
|
418
439
|
|
|
419
440
|
// The short reason a cross-machine button is grey (the full one is its tooltip).
|
|
@@ -756,9 +777,16 @@ async function projectAction(action, btn) {
|
|
|
756
777
|
}
|
|
757
778
|
}
|
|
758
779
|
let lastMenuAnchor = null
|
|
780
|
+
// The board's Machine filter, kept in this browser ('' = every Mac).
|
|
781
|
+
function setMachineFilter(m) {
|
|
782
|
+
state.machine = m || ''
|
|
783
|
+
try { if (state.machine) localStorage.setItem('cp.machine', state.machine); else localStorage.removeItem('cp.machine') } catch {}
|
|
784
|
+
}
|
|
759
785
|
|
|
760
786
|
async function viewBoard() {
|
|
761
787
|
if (!state.health) await refreshHealth()
|
|
788
|
+
// 0.9.8: a saved Machine filter for a Mac (or agent) the board no longer knows is dropped
|
|
789
|
+
if (state.machine && state.health && boardMachineFilter(state.machine, { machines: state.health.machines || [], agents: agentNames() }) !== state.machine) setMachineFilter('')
|
|
762
790
|
if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
|
|
763
791
|
const [{ sessions: all, viewer, closedCount, todoCount, parked = [] }] = await Promise.all([api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : '')), loadProjects()])
|
|
764
792
|
// 0.8.2: parked sessions have their own section (any age), not a status group
|
|
@@ -818,15 +846,18 @@ async function viewBoard() {
|
|
|
818
846
|
const todoN = f.repos.length || sp || pj ? inRepos.reduce((n, s) => n + ((s.todos && s.todos.length) || 0), 0) : (todoCount || 0)
|
|
819
847
|
html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
|
|
820
848
|
'<button type="button" class="stat' + (filter === k ? ' on' : '') + '" data-action="filter" data-filter="' + k + '" aria-pressed="' + (filter === k) + '" title="' + esc(filter === k ? 'Showing ' + fl.label.toLowerCase() + ' · tap to show all' : 'Show ' + fl.label.toLowerCase()) + '"><div class="n">' + count(k) + (k === 'needs' && todoN ? '<span class="todo-subn" title="To-dos handed back to you (not counted as waiting)">' + todoN + (todoN === 1 ? ' to-do' : ' to-dos') + '</span>' : '') + '</div><div class="k"><span class="pill ' + fl.cls + '" aria-hidden="true"></span>' + esc(fl.label) + '</div></button>').join('') + '</div>'
|
|
849
|
+
html += machineOnlyLine() // 0.9.8: in the filter rows, with the spaces and repos it narrows
|
|
821
850
|
html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
|
|
822
851
|
html += projectRow(spaced, pj, 'board-project')
|
|
823
852
|
if (filter === 'closed') {
|
|
824
853
|
html += await closedSection(f, sp)
|
|
854
|
+
if (parseRoute().name === 's') return // 0.9.8: you opened a session while the board loaded
|
|
825
855
|
$app.innerHTML = banners() + html
|
|
826
856
|
return closedWire()
|
|
827
857
|
}
|
|
828
858
|
html += repoRow(inSpace, f)
|
|
829
859
|
html += boardSearch(bq, closedHits, list.length)
|
|
860
|
+
if ((state.boardQ || '').trim() !== bq) return // you typed on while this one loaded: the newer search paints
|
|
830
861
|
if (bq) {
|
|
831
862
|
if (list.length) html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
|
|
832
863
|
paintBoardKeepingSearch(banners() + html)
|
|
@@ -837,7 +868,7 @@ async function viewBoard() {
|
|
|
837
868
|
html += '<div class="empty mt"><p><strong>No sessions yet.</strong></p><p>Start Claude Code with the mod loaded (<code>claude --plugin-dir ./mod</code>) or install the settings hooks (<code>leftoff init --hooks</code>). Sessions show up here as soon as you send a prompt.</p></div>'
|
|
838
869
|
} else if (!list.length && shown.length) {
|
|
839
870
|
const pjName = pj ? (projectsUi.list.find((p) => p.id === pj) || {}).name : null
|
|
840
|
-
const inWhat = (pjName ? ' in ' + pjName : '') + (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
|
|
871
|
+
const inWhat = (pjName ? ' in ' + pjName : '') + (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '') + (state.machine ? ' on ' + state.machine : '')
|
|
841
872
|
html += '<div class="empty mt">' + (filter ? 'Nothing ' + esc(FILTERS[filter].label.toLowerCase()) + esc(inWhat) + ' right now.' : 'No sessions' + esc(inWhat) + ' today.') +
|
|
842
873
|
' <button type="button" class="linkish" data-action="clear-filters">Show everything</button></div>'
|
|
843
874
|
} else if (list.length) {
|
|
@@ -860,38 +891,63 @@ function boardSearch(q, closedHits, n) {
|
|
|
860
891
|
(closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') +
|
|
861
892
|
' <a class="linkish tx-board-link" href="#/search?q=' + encodeURIComponent(q) + '">' + icon('message-square') + 'Search what was said →</a></div>' : '') + '</div>'
|
|
862
893
|
}
|
|
863
|
-
// A repaint from a live update (or the 30 s refresh
|
|
864
|
-
// search
|
|
894
|
+
// A repaint from a live update (or the 30 s refresh, or your own typing's
|
|
895
|
+
// search) never rebuilds the search box: everything around it is replaced and
|
|
896
|
+
// the box itself stays the same element, so the caret, a selection, a word
|
|
897
|
+
// being composed and the focus are exactly as you left them. (Rebuilding it
|
|
898
|
+
// and putting the caret back moved the caret while you typed: the typing's
|
|
899
|
+
// own search put back where the caret was when it started, after you'd typed on.)
|
|
865
900
|
function paintBoardKeepingSearch(h) {
|
|
901
|
+
if (parseRoute().name === 's') return // 0.9.8: a slow board answer never paints over the session you opened
|
|
866
902
|
const old = document.getElementById('boardQ')
|
|
867
|
-
const
|
|
903
|
+
const row = old && old.closest('.board-search-row')
|
|
904
|
+
if (row && row.parentNode === $app) {
|
|
905
|
+
const tpl = document.createElement('template')
|
|
906
|
+
tpl.innerHTML = h
|
|
907
|
+
const nrow = tpl.content.querySelector('.board-search-row')
|
|
908
|
+
const nbox = nrow && nrow.querySelector('#boardQ')
|
|
909
|
+
if (nbox && nrow.parentNode === tpl.content) {
|
|
910
|
+
const kids = [...tpl.content.childNodes], at = kids.indexOf(nrow)
|
|
911
|
+
while ($app.firstChild && $app.firstChild !== row) $app.removeChild($app.firstChild)
|
|
912
|
+
while (row.nextSibling) $app.removeChild(row.nextSibling)
|
|
913
|
+
row.before(...kids.slice(0, at))
|
|
914
|
+
row.after(...kids.slice(at + 1))
|
|
915
|
+
// the note under the box (n matches, closed matches) follows the new answer
|
|
916
|
+
for (const n of [...row.childNodes]) if (!n.contains(old)) row.removeChild(n)
|
|
917
|
+
for (const n of [...nrow.childNodes]) if (!n.contains(nbox)) row.appendChild(n)
|
|
918
|
+
// the words change only when you aren't typing (the address changed, Esc cleared it)
|
|
919
|
+
if (document.activeElement !== old && !boardQTimer && old.value !== nbox.value) old.value = nbox.value
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const keep = old && document.activeElement === old ? { v: old.value, a: old.selectionStart, b: old.selectionEnd, d: old.selectionDirection } : null
|
|
868
924
|
$app.innerHTML = h
|
|
869
925
|
if (!keep) return
|
|
870
926
|
const q = document.getElementById('boardQ')
|
|
871
927
|
if (!q) return
|
|
872
928
|
if (q.value !== keep.v) q.value = keep.v
|
|
873
|
-
q.focus(); try { q.setSelectionRange(keep.
|
|
929
|
+
q.focus({ preventScroll: true }); try { q.setSelectionRange(keep.a, keep.b, keep.d || 'none') } catch {}
|
|
874
930
|
}
|
|
875
931
|
let boardQTimer = null
|
|
876
932
|
function boardSearchWire() {
|
|
877
933
|
const q = document.getElementById('boardQ')
|
|
878
|
-
if (!q) return
|
|
934
|
+
if (!q || q.dataset.wired) return // the same box across repaints: wired once
|
|
935
|
+
q.dataset.wired = '1'
|
|
879
936
|
q.addEventListener('input', () => {
|
|
880
937
|
clearTimeout(boardQTimer)
|
|
881
938
|
boardQTimer = setTimeout(async () => {
|
|
882
|
-
|
|
939
|
+
boardQTimer = null
|
|
940
|
+
if (!q.isConnected) return
|
|
883
941
|
state.boardQ = q.value
|
|
884
942
|
try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
|
|
885
943
|
state.route = parseRoute()
|
|
886
|
-
await viewBoard()
|
|
887
|
-
const again = document.getElementById('boardQ')
|
|
888
|
-
if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
|
|
944
|
+
await viewBoard() // the box isn't rebuilt, so the caret stays wherever you've typed to since
|
|
889
945
|
}, 160)
|
|
890
946
|
})
|
|
891
947
|
q.addEventListener('keydown', (ev) => {
|
|
892
948
|
if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) }
|
|
893
949
|
// 0.9.6: Enter (Search on the phone's keyboard) opens the full results, transcripts included
|
|
894
|
-
else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
|
|
950
|
+
else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); boardQTimer = null; location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
|
|
895
951
|
})
|
|
896
952
|
}
|
|
897
953
|
|
|
@@ -1114,9 +1170,9 @@ function restoreBanner() {
|
|
|
1114
1170
|
const when = r.restartAt ? ' (' + clock(r.restartAt) + ')' : ''
|
|
1115
1171
|
const head = '<b>' + n + ' session' + (n === 1 ? ' was' : 's were') + ' open ' + esc(restoreWhen(r)) + '</b>' + esc(when)
|
|
1116
1172
|
if (!restoreUi.open) {
|
|
1117
|
-
return '<div class="banner info restore" id="restoreBanner" role="status"><span class="grow">' +
|
|
1173
|
+
return '<div class="banner info restore" id="restoreBanner" role="status"><span class="grow">' + restoreHeadline(r, { esc, clock }) + '</span>' +
|
|
1118
1174
|
'<button type="button" class="btn primary" data-action="restore-open">' + icon('rotate-ccw') + 'Restore…</button>' +
|
|
1119
|
-
'<button type="button" class="btn x" data-action="restore-dismiss" aria-label="Not now"
|
|
1175
|
+
'<button type="button" class="btn x" data-action="restore-dismiss" aria-label="Not now" data-tip="Not now">✕</button></div>'
|
|
1120
1176
|
}
|
|
1121
1177
|
const gate = restoreGate()
|
|
1122
1178
|
const picked = restorePicked().length
|
|
@@ -1124,11 +1180,11 @@ function restoreBanner() {
|
|
|
1124
1180
|
const rows = r.sessions.map((s) => '<label class="rs-row"><input type="checkbox" name="rs" value="' + esc(s.id) + '"' + (restoreUi.off.has(s.id) ? '' : ' checked') + '>' +
|
|
1125
1181
|
'<span class="rs-main"><span class="rs-t">' + esc(s.title || '(untitled)') + '</span><span class="rs-m">' + esc([s.repoLabel, s.branch, s.modelLabel, s.lastAliveAt ? 'last seen ' + ago(s.lastAliveAt) : null, 'cp:' + (s.shortId || s.id.slice(0, 8))].filter(Boolean).join(' · ')) + '</span>' + (s.blocked ? '<span class="rs-m err">' + esc(s.error) + '</span>' : '') + '</span></label>').join('')
|
|
1126
1182
|
return '<div class="banner info restore open" id="restoreBanner" role="region" aria-label="Restore sessions"><div class="rs-head"><span class="grow">' + head + '. Each opens in a new Ghostty tab, in its folder, with <code>claude --resume</code> and its name.</span>' +
|
|
1127
|
-
'<button type="button" class="btn x" data-action="restore-close" aria-label="Close"
|
|
1183
|
+
'<button type="button" class="btn x" data-action="restore-close" aria-label="Close" data-tip="Close">✕</button></div>' +
|
|
1128
1184
|
'<div class="rs-list">' + rows + '</div>' +
|
|
1129
1185
|
'<div class="rs-actions">' +
|
|
1130
1186
|
'<button type="button" class="btn primary" data-action="restore-go" id="restoreGo" data-how="' + (gate.how || 'off') + '"' + (gate.ok && picked && !asking ? '' : ' aria-disabled="true"') +
|
|
1131
|
-
(gate.ok && gate.how !== 'ask' ? '' : ' aria-describedby="restoreHint"') +
|
|
1187
|
+
(gate.ok && gate.how !== 'ask' ? '' : ' aria-describedby="restoreHint"') + '>' + icon('rotate-ccw') + 'Restore ' + picked + '</button>' +
|
|
1132
1188
|
'<button type="button" class="btn" data-action="restore-all">' + (picked === r.sessions.length ? 'Select none' : 'Select all') + '</button>' +
|
|
1133
1189
|
'<button type="button" class="btn" data-action="restore-copy">Copy commands</button>' +
|
|
1134
1190
|
(restoreUi.ask ? restoreAskNote() : gate.ok && gate.how !== 'ask' ? '' : '<span class="rs-note" id="restoreHint">' + hintHtml(gate.hint, esc) + '</span>') +
|
|
@@ -1208,7 +1264,7 @@ function paintNeedsCount(n) {
|
|
|
1208
1264
|
|
|
1209
1265
|
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
|
|
1210
1266
|
function needOpts(extra = {}) {
|
|
1211
|
-
return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', ...extra }
|
|
1267
|
+
return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', composerKeys: isTouchUi(window.matchMedia) ? 'Meta+Enter Control+Enter' : 'Enter Meta+Enter Control+Enter', ...extra }
|
|
1212
1268
|
}
|
|
1213
1269
|
|
|
1214
1270
|
// ⌘↵ (Ctrl+↵ off a Mac) in a Needs-you text box (the Deny note, a question's
|
|
@@ -1297,47 +1353,166 @@ function ownerOfflineNote(s) {
|
|
|
1297
1353
|
const m = esc(s.machine), sn = staleNote(s.machine)
|
|
1298
1354
|
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>'
|
|
1299
1355
|
}
|
|
1356
|
+
// 0.9.8: opening a session is instant. The tap paints the page's shell at
|
|
1357
|
+
// once (title and pills from the card we already have, or the whole page from
|
|
1358
|
+
// the last visit), then every fetch runs in parallel (the session, its Live
|
|
1359
|
+
// thread, spaces, projects, lists) instead of one after another; on a phone
|
|
1360
|
+
// through the relay each was a full round trip. A newer navigation wins: a
|
|
1361
|
+
// slow answer for a page you already left never paints over the one you're on.
|
|
1362
|
+
const sessionCache = new Map() // id -> { s, live, lists, spv }, the last few sessions you opened
|
|
1363
|
+
const SESSION_CACHE_MAX = 8
|
|
1364
|
+
function cacheSession(id, v) { sessionCache.delete(id); sessionCache.set(id, v); while (sessionCache.size > SESSION_CACHE_MAX) sessionCache.delete(sessionCache.keys().next().value) }
|
|
1365
|
+
const onSession = (id) => { const r = parseRoute(); return r.name === 's' && r.arg === id }
|
|
1366
|
+
function sessionShellHtml(s) {
|
|
1367
|
+
const head = s
|
|
1368
|
+
? '<h1>' + titleHtml(sessionTitle(s), esc) + '</h1><div class="sub">' + statusPill(s) + liveBadge(s) + '</div>'
|
|
1369
|
+
: '<h1 class="skel-line skel-title" aria-label="Loading"></h1>'
|
|
1370
|
+
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>' +
|
|
1371
|
+
'<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>' +
|
|
1372
|
+
'<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>'
|
|
1373
|
+
}
|
|
1374
|
+
// A selector that finds the same control again after a redraw (null: it can't be told apart).
|
|
1375
|
+
function focusKey(el) {
|
|
1376
|
+
if (!el || !el.tagName) return null
|
|
1377
|
+
if (el.id) return '#' + CSS.escape(el.id)
|
|
1378
|
+
const d = el.dataset || {}
|
|
1379
|
+
const keys = ['action', 'lid', 'sid', 'att', 'id', 'tab', 'submitFor'].filter((k) => d[k] != null)
|
|
1380
|
+
if (!keys.length) return null
|
|
1381
|
+
const attr = (k) => '[data-' + k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + '="' + CSS.escape(d[k]) + '"]'
|
|
1382
|
+
return el.tagName.toLowerCase() + keys.map(attr).join('')
|
|
1383
|
+
}
|
|
1384
|
+
function paintSession(s, data) {
|
|
1385
|
+
if (data.live && !data.live.error) liveUi.sig[s.id] = liveSignature(data.live.messages)
|
|
1386
|
+
// a redraw (the live poll, a settled reply) keeps the composer focused with its caret where it was
|
|
1387
|
+
const fa = document.activeElement
|
|
1388
|
+
const kf = fa && fa.classList && fa.classList.contains('composer-input') && $app.contains(fa) ? { d: fa.dataset.draft, a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
1389
|
+
// …and any other control that had focus (a chip, a thumbnail, a button: a
|
|
1390
|
+
// preview closed with Esc puts focus back on one, then a redraw replaced it)
|
|
1391
|
+
const kb = !kf && fa && fa !== document.body && $app.contains(fa) ? focusKey(fa) : null
|
|
1392
|
+
liveKeeper.before($app)
|
|
1393
|
+
$app.innerHTML = sessionPageHtml(s, data)
|
|
1394
|
+
liveAfterRender(s.id)
|
|
1395
|
+
if (kf) {
|
|
1396
|
+
const nt = $app.querySelector('.composer-input[data-draft="' + CSS.escape(kf.d || '') + '"]')
|
|
1397
|
+
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 {} }
|
|
1398
|
+
} else if (kb && !$app.contains(document.activeElement)) {
|
|
1399
|
+
const hits = $app.querySelectorAll(kb)
|
|
1400
|
+
if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true })
|
|
1401
|
+
}
|
|
1402
|
+
aroundWire(s.id)
|
|
1403
|
+
}
|
|
1300
1404
|
async function viewSession(id) {
|
|
1301
1405
|
if (String(id).startsWith('agent:')) return viewAgentSession(await api('GET', '/v1/sessions/' + encodeURIComponent(id)))
|
|
1302
|
-
const
|
|
1406
|
+
const showing = $app.querySelector('.s-head[data-sid="' + CSS.escape(id) + '"]') // a re-render of the page you're on: no shell
|
|
1407
|
+
const cached = sessionCache.get(id)
|
|
1408
|
+
const known = (cached && cached.s) || state.sessions.find((x) => x.id === id) || null
|
|
1409
|
+
if (!showing) {
|
|
1410
|
+
if (cached) paintSession(cached.s, cached) // the last visit, at once; fresh data replaces it below
|
|
1411
|
+
else { liveKeeper.before($app); $app.innerHTML = sessionShellHtml(known) }
|
|
1412
|
+
}
|
|
1413
|
+
// on leftoff.dev, another Mac's session loads its data from that Mac: say so before the first call
|
|
1414
|
+
const owner = (x) => { if (x && window.leftoffRelay && window.leftoffRelay.owner) window.leftoffRelay.owner(x.id, x.isLocal === false && !x.agent ? x.machine : null) }
|
|
1415
|
+
owner(known)
|
|
1416
|
+
const pS = api('GET', '/v1/sessions/' + encodeURIComponent(id))
|
|
1417
|
+
const pLive = !known || liveWanted(known) ? fetchLive(id) : null // the thread doesn't wait for the session
|
|
1418
|
+
const pLists = known && known.lists && known.lists.length ? fetchLists(id) : null
|
|
1419
|
+
const pRest = Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
|
|
1420
|
+
let s
|
|
1421
|
+
try { s = await pS } catch (e) { if (!onSession(id)) return; throw e }
|
|
1303
1422
|
state.sessions = state.sessions.filter((x) => x.id !== s.id).concat([s])
|
|
1304
|
-
|
|
1305
|
-
|
|
1423
|
+
owner(s)
|
|
1424
|
+
const status = s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null
|
|
1425
|
+
const lists = s.lists && s.lists.length ? (pLists || fetchLists(id)) : null
|
|
1426
|
+
const live = liveWanted(s) ? (pLive || fetchLive(id)) : !isLive(s) && !s.endedAt && s.live !== 'closed' ? liveHow().then(() => null) : null
|
|
1427
|
+
let [[spv], v, sl] = await Promise.all([pRest, live, lists, status])
|
|
1428
|
+
if (!onSession(id)) return // you moved on while this loaded
|
|
1429
|
+
// a Live fetch that failed keeps the thread you had (its bubbles say "Reconnecting…"), not an empty one
|
|
1430
|
+
const prev = sessionCache.get(id)
|
|
1431
|
+
if (v && v.error && prev && prev.live && !prev.live.error) v = { ...prev.live, error: null }
|
|
1432
|
+
const data = { spv, live: v, lists: sl }
|
|
1433
|
+
if (v && !v.error) cacheSession(id, { s, ...data, live: v })
|
|
1434
|
+
paintSession(s, data)
|
|
1435
|
+
}
|
|
1436
|
+
const fetchLists = (id) => api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/lists').catch(() => null)
|
|
1437
|
+
// 0.9.8 What it did: per-session state (kept while the board is open), painted
|
|
1438
|
+
// in place; loaded when you open the section or tap ↻, never polled.
|
|
1439
|
+
const widUi = new Map()
|
|
1440
|
+
function widOf(id) { if (!widUi.has(id)) widUi.set(id, widState()); return widUi.get(id) }
|
|
1441
|
+
function paintWid(id) {
|
|
1442
|
+
const el = document.getElementById('what-it-did')
|
|
1443
|
+
const s = state.sessions.find((x) => x.id === id)
|
|
1444
|
+
if (!el || !s || !onSession(id)) return
|
|
1445
|
+
const fa = document.activeElement
|
|
1446
|
+
const kb = fa && fa !== document.body && el.contains(fa) ? focusKey(fa) : null
|
|
1447
|
+
el.innerHTML = whatItDidSection(s, widOf(id))
|
|
1448
|
+
if (hydrateDiffs(el, (p) => widSlot(widOf(id), p))) widStickyTop()
|
|
1449
|
+
if (kb) { const hits = el.querySelectorAll(kb); if (hits.length === 1 && !hits[0].disabled) hits[0].focus({ preventScroll: true }) }
|
|
1450
|
+
}
|
|
1451
|
+
// An open file's header sticks just under the board's own sticky bar, whose height changes with the width.
|
|
1452
|
+
function widStickyTop() {
|
|
1453
|
+
let h = 0
|
|
1454
|
+
for (const b of document.querySelectorAll('.top, .tabstrip')) if (b.offsetParent !== null && getComputedStyle(b).position === 'sticky') h = Math.max(h, b.getBoundingClientRect().height)
|
|
1455
|
+
document.documentElement.style.setProperty('--wid-sticky', Math.round(h) + 'px')
|
|
1456
|
+
}
|
|
1457
|
+
const widPath = (id, what) => '/v1/sessions/' + encodeURIComponent(id) + '/' + what
|
|
1458
|
+
async function loadWid(id) {
|
|
1459
|
+
const ui = widOf(id)
|
|
1460
|
+
if (ui.loading) return
|
|
1461
|
+
Object.assign(ui, { loading: true, tools: null, diff: null, toolsError: null, diffError: null, toolFull: {}, toolLoading: {}, files: {}, fileLoading: {} })
|
|
1462
|
+
paintWid(id)
|
|
1463
|
+
await Promise.all([
|
|
1464
|
+
api('GET', widPath(id, 'tools')).then((r) => { ui.tools = r.tools || [] }, (e) => { ui.toolsError = widError(e) }),
|
|
1465
|
+
api('GET', widPath(id, 'diff')).then((r) => { ui.diff = r }, (e) => { ui.diffError = widError(e) }),
|
|
1466
|
+
])
|
|
1467
|
+
ui.loading = false
|
|
1468
|
+
// a file you had open shows its lines again
|
|
1469
|
+
for (const p of Object.keys(ui.fileOpen)) if (ui.fileOpen[p]) loadWidFile(id, p)
|
|
1470
|
+
paintWid(id)
|
|
1471
|
+
}
|
|
1472
|
+
async function loadWidFile(id, p) {
|
|
1473
|
+
const ui = widOf(id)
|
|
1474
|
+
const f = ui.diff && (ui.diff.files || []).find((x) => x.path === p)
|
|
1475
|
+
if (!f || f.hidden || f.binary || f.big || ui.fileLoading[p]) return
|
|
1476
|
+
ui.fileLoading[p] = true; paintWid(id)
|
|
1477
|
+
try { const r = await api('GET', widPath(id, 'diff') + '?file=' + encodeURIComponent(p)); ui.files[p] = r.file || { error: 'Not found.' } } catch (e) { ui.files[p] = { error: widError(e) } }
|
|
1478
|
+
delete ui.fileLoading[p]
|
|
1479
|
+
paintWid(id)
|
|
1480
|
+
}
|
|
1481
|
+
function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {}) {
|
|
1306
1482
|
const branch = s.repo && s.repo.branch
|
|
1307
|
-
const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects(),
|
|
1308
|
-
s.isLocal === false && window.leftoffRelay ? window.leftoffRelay.status().then((st) => { state.relayStatus = st }).catch(() => {}) : null])
|
|
1309
1483
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1310
1484
|
// 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)
|
|
1311
1485
|
const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
|
|
1312
|
-
html += '<div class="pagehead s-head"><div class="s-head-main">' + (canRename
|
|
1486
|
+
html += '<div class="pagehead s-head" data-sid="' + esc(s.id) + '"><div class="s-head-main">' + (canRename
|
|
1313
1487
|
? '<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>'
|
|
1314
1488
|
: '<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>' +
|
|
1315
1489
|
// 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
|
|
1316
|
-
'<div class="actions s-actions">' + goButton(s, { page: true }) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + 'Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
|
|
1490
|
+
'<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>'
|
|
1317
1491
|
html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
|
|
1318
1492
|
html += '<div class="facts">' +
|
|
1319
1493
|
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)) +
|
|
1320
1494
|
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) +
|
|
1321
1495
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
1322
1496
|
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>'
|
|
1323
|
-
if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') +
|
|
1497
|
+
if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
|
|
1324
1498
|
html += parkRow(s)
|
|
1325
1499
|
html += spacePicker(s, spv, 'session', s.id)
|
|
1326
|
-
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
1500
|
+
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
|
|
1327
1501
|
// 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
|
|
1328
1502
|
html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
|
|
1329
1503
|
html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
|
|
1504
|
+
// 0.9.8: the last tools it ran and its folder's changes, folded; loads when you open it
|
|
1505
|
+
html += '<section class="wid" id="what-it-did">' + whatItDidSection(s, widOf(s.id)) + '</section>'
|
|
1330
1506
|
// 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
|
|
1331
1507
|
if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
|
|
1332
|
-
if (!isLive(s)) html +=
|
|
1508
|
+
if (!isLive(s)) html += liveBoxHtml(s, liveV)
|
|
1333
1509
|
if (s.statusChanges && s.statusChanges.length) {
|
|
1334
1510
|
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>'
|
|
1335
1511
|
}
|
|
1336
1512
|
// Lists (v0.5.1): its saved lists in full, the hint, and Find lists in this session
|
|
1337
1513
|
if (s.lists && s.lists.length) {
|
|
1338
|
-
const sl = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/lists').catch(() => null)
|
|
1339
1514
|
state.listFull = {}
|
|
1340
|
-
for (const l of (
|
|
1515
|
+
for (const l of (lists && lists.lists) || []) state.listFull[l.ref] = l
|
|
1341
1516
|
}
|
|
1342
1517
|
state.listsMayEdit = state.mayAnswer !== false
|
|
1343
1518
|
html += '<section class="slists" id="lists">' + sessionListsSection(s) + '</section>'
|
|
@@ -1355,10 +1530,7 @@ async function viewSession(id) {
|
|
|
1355
1530
|
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
1356
1531
|
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>'
|
|
1357
1532
|
html += answeredList(s)
|
|
1358
|
-
|
|
1359
|
-
$app.innerHTML = html
|
|
1360
|
-
liveAfterRender(s.id)
|
|
1361
|
-
aroundWire(s.id)
|
|
1533
|
+
return html
|
|
1362
1534
|
}
|
|
1363
1535
|
// ------------------------------------------------------------ Live link (0.9.8 beta)
|
|
1364
1536
|
|
|
@@ -1381,18 +1553,28 @@ async function liveHow() {
|
|
|
1381
1553
|
if (state.liveHow === undefined) { try { state.liveHow = await api('GET', '/v1/live') } catch { state.liveHow = null } }
|
|
1382
1554
|
return state.liveHow
|
|
1383
1555
|
}
|
|
1384
|
-
|
|
1556
|
+
// Does this session's page show a Live thread (and so fetch one)?
|
|
1557
|
+
const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
|
|
1558
|
+
// GET the Live thread; what it says is the newest the page knows (0.9.8: the
|
|
1559
|
+
// time of the last good answer decides "Reconnecting…" on a pending bubble).
|
|
1560
|
+
async function fetchLive(id, { timeoutMs } = {}) {
|
|
1561
|
+
const turns = (state.liveTurns && state.liveTurns[id]) || 20 // "Show earlier" adds 20 at a time
|
|
1562
|
+
try {
|
|
1563
|
+
const v = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/live' + (turns > 20 ? '?turns=' + turns : ''), null, timeoutMs ? { timeoutMs } : {})
|
|
1564
|
+
noteLive(id, v)
|
|
1565
|
+
return v
|
|
1566
|
+
} catch (e) { return { messages: [], error: e.message } }
|
|
1567
|
+
}
|
|
1568
|
+
function liveBoxHtml(s, v) {
|
|
1385
1569
|
if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
|
|
1386
1570
|
if (!isLive(s)) {
|
|
1387
1571
|
if (s.endedAt || s.live === 'closed') return ''
|
|
1388
|
-
const how =
|
|
1572
|
+
const how = state.liveHow
|
|
1389
1573
|
return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
|
|
1390
1574
|
'<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>' +
|
|
1391
|
-
(how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p
|
|
1575
|
+
(how && how.line ? ' (or <code>' + esc(how.line) + '</code>)' : '') + '. Claude Code asks once to load the channel; channels are a research preview.</p>' +
|
|
1576
|
+
(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>'
|
|
1392
1577
|
}
|
|
1393
|
-
let v = null
|
|
1394
|
-
const turns = (state.liveTurns && state.liveTurns[s.id]) || 20 // "Show earlier" adds 20 at a time
|
|
1395
|
-
try { v = await api('GET', '/v1/sessions/' + encodeURIComponent(s.id) + '/live' + (turns > 20 ? '?turns=' + turns : '')) } catch (e) { v = { messages: [], error: e.message } }
|
|
1396
1578
|
const msgs = (v && v.messages) || []
|
|
1397
1579
|
const may = state.mayAnswer !== false
|
|
1398
1580
|
const draft = (state.drafts && state.drafts['live:' + s.id]) || ''
|
|
@@ -1401,17 +1583,337 @@ async function liveSection(s) {
|
|
|
1401
1583
|
if (lastClaude) markLiveSeen(s.id, lastClaude.at)
|
|
1402
1584
|
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>' +
|
|
1403
1585
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1404
|
-
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now }) + '</ol>' +
|
|
1586
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
1405
1587
|
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
|
|
1406
1588
|
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
|
|
1407
1589
|
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
1408
|
-
|
|
1409
|
-
'<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>' +
|
|
1590
|
+
composerHtml(s, draft, may) +
|
|
1410
1591
|
(may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.</p>') + '</section>'
|
|
1411
1592
|
}
|
|
1593
|
+
// The Live composer: one rounded box, a textarea that starts at one line and
|
|
1594
|
+
// grows, and a round ↑ in its corner (dim until there's something to send).
|
|
1595
|
+
// No interrupt reaches Claude Code from the board, so while it works the
|
|
1596
|
+
// button still sends: the message waits for its next turn, as before.
|
|
1597
|
+
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>'
|
|
1598
|
+
function composerHtml(s, draft, may) {
|
|
1599
|
+
const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
|
|
1600
|
+
return composerBoxHtml({ sid: s.id, submit: 'live', sendAction: 'live-send', draftKey: 'live:' + s.id, draft, may,
|
|
1601
|
+
placeholder: 'Message this session…', label: 'Message this session', boxClass: 'live-row', inputClass: 'live-text',
|
|
1602
|
+
attach: true, atts: attachList(s.id), chipHtml, keys: isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd })
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
|
|
1606
|
+
// Each file uploads to the Mac that runs the session as soon as you attach it;
|
|
1607
|
+
// the chips live here (not in the DOM), so a redraw of the page keeps them.
|
|
1608
|
+
const attachUi = { by: new Map(), urls: new Map(), blobs: new Map(), loading: new Map(), seq: 0, running: 0, queue: [] }
|
|
1609
|
+
const attachList = (sid) => attachUi.by.get(sid) || []
|
|
1610
|
+
function chipHtml(a) {
|
|
1611
|
+
const pct = a.total ? Math.min(100, Math.round((a.sent / a.total) * 100)) : 0
|
|
1612
|
+
const status = a.status === 'failed' ? 'Didn’t upload' : a.status === 'done' ? fmtSize(a.size) : a.status === 'prep' ? 'Preparing…' : fmtSize(a.size) + ' · ' + pct + '%'
|
|
1613
|
+
return '<div class="att-chip ' + esc(a.status) + '" data-lid="' + esc(a.lid) + '"' + (a.error ? ' title="' + esc(a.error) + '"' : '') + '>' +
|
|
1614
|
+
'<button type="button" class="att-open" data-action="att-chip-open" data-lid="' + esc(a.lid) + '" aria-label="Preview ' + esc(a.name) + '">' +
|
|
1615
|
+
'<span class="att-thumb">' + (a.preview ? '<img src="' + esc(a.preview) + '" alt="">' : FILE_IC) + '</span>' +
|
|
1616
|
+
'<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>' +
|
|
1617
|
+
(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>' : '') +
|
|
1618
|
+
'<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>' +
|
|
1619
|
+
(a.status === 'up' || a.status === 'prep' ? '<span class="att-bar" aria-hidden="true"><span data-pct="' + pct + '"></span></span>' : '') + '</div>'
|
|
1620
|
+
}
|
|
1621
|
+
// The board's CSP takes no inline styles: a progress bar's width is set here.
|
|
1622
|
+
function barWidths(root) { for (const b of root.querySelectorAll('.att-bar > span[data-pct]')) b.style.width = b.dataset.pct + '%' }
|
|
1623
|
+
// Redraw one session's chips in place (and the ↑'s state).
|
|
1624
|
+
function paintChips(sid) {
|
|
1625
|
+
const box = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"]')
|
|
1626
|
+
if (!box) return
|
|
1627
|
+
const atts = attachList(sid)
|
|
1628
|
+
const wrap = box.querySelector('.att-chips')
|
|
1629
|
+
const fa = wrap.contains(document.activeElement) ? focusKey(document.activeElement) : null // a progress tick keeps the chip you're on focused
|
|
1630
|
+
wrap.innerHTML = atts.map(chipHtml).join('')
|
|
1631
|
+
if (fa) { const t = wrap.querySelector(fa); if (t) t.focus({ preventScroll: true }) }
|
|
1632
|
+
barWidths(wrap)
|
|
1633
|
+
wrap.hidden = !atts.length
|
|
1634
|
+
box.classList.toggle('has-atts', !!atts.length)
|
|
1635
|
+
const ta = box.querySelector('.composer-input')
|
|
1636
|
+
if (ta) syncComposer(ta)
|
|
1637
|
+
}
|
|
1638
|
+
function addFiles(sid, files, { pasted = false } = {}) {
|
|
1639
|
+
const list = Array.from(files || []).filter(Boolean)
|
|
1640
|
+
if (!sid || !list.length) return
|
|
1641
|
+
const have = attachList(sid)
|
|
1642
|
+
const plan = attachPlan(list, have.length)
|
|
1643
|
+
if (plan.skipped.length) toast('Not attached: ' + skippedText(plan.skipped), 4200)
|
|
1644
|
+
const add = plan.take.map((f) => {
|
|
1645
|
+
const file = pasted && /^image\.\w+$|^$/.test(f.name || '') ? new File([f], pastedName(f.type), { type: f.type, lastModified: Date.now() }) : f
|
|
1646
|
+
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 }
|
|
1647
|
+
})
|
|
1648
|
+
if (!add.length) return
|
|
1649
|
+
attachUi.by.set(sid, [...have, ...add])
|
|
1650
|
+
paintChips(sid)
|
|
1651
|
+
try { promoteActive() } catch {}
|
|
1652
|
+
for (const a of add) startUpload(a)
|
|
1653
|
+
}
|
|
1654
|
+
function startUpload(a) {
|
|
1655
|
+
a.status = 'prep'; a.error = null; a.sent = 0
|
|
1656
|
+
attachUi.queue.push(a); pumpUploads()
|
|
1657
|
+
}
|
|
1658
|
+
// Three at a time: a phone on a slow line shouldn't open ten uploads at once.
|
|
1659
|
+
function pumpUploads() {
|
|
1660
|
+
while (attachUi.running < 3 && attachUi.queue.length) {
|
|
1661
|
+
const a = attachUi.queue.shift()
|
|
1662
|
+
if (!attachList(a.sid).includes(a)) continue
|
|
1663
|
+
attachUi.running++
|
|
1664
|
+
runUpload(a).finally(() => { attachUi.running--; pumpUploads() })
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
async function runUpload(a) {
|
|
1668
|
+
const gone = () => !attachList(a.sid).includes(a)
|
|
1669
|
+
try {
|
|
1670
|
+
if (!a.prepared) {
|
|
1671
|
+
const p = await prepareFile(a.file)
|
|
1672
|
+
a.prepared = p.file; a.thumb = p.thumb
|
|
1673
|
+
a.name = p.file.name || a.name; a.size = p.file.size; a.total = p.file.size; a.type = p.file.type || a.type
|
|
1674
|
+
if (attachKind(p.file) === 'image' && !a.preview && p.thumb) a.preview = 'data:image/jpeg;base64,' + p.thumb
|
|
1675
|
+
// a PDF's first page, for its chip and its bubble (a few seconds at most; without it, the file icon)
|
|
1676
|
+
if (attachKind(p.file) === 'pdf' && !a.thumb) {
|
|
1677
|
+
const t = await Promise.race([pdfThumb(p.file), new Promise((r) => setTimeout(() => r(null), 5000))]).catch(() => null)
|
|
1678
|
+
if (t) { a.thumb = t; a.preview = 'data:image/jpeg;base64,' + t }
|
|
1679
|
+
}
|
|
1680
|
+
if (p.note === 'heic') a.error = 'This browser can’t convert HEIC; it goes as is'
|
|
1681
|
+
}
|
|
1682
|
+
if (gone()) return
|
|
1683
|
+
a.status = 'up'; paintChips(a.sid)
|
|
1684
|
+
let last = 0
|
|
1685
|
+
const f = await uploadFile({ api, sessionId: a.sid, file: a.prepared, thumb: a.thumb, isCancelled: gone,
|
|
1686
|
+
onProgress: (sent, total) => { a.sent = sent; a.total = total; const t = Date.now(); if (t - last > 120 || sent >= total) { last = t; paintChips(a.sid) } } })
|
|
1687
|
+
a.id = f.id; a.size = f.size; a.kind = f.kind; a.status = 'done'
|
|
1688
|
+
if (a.preview) attachUi.urls.set(a.sid + '/' + f.id, a.preview) // the bubble shows it without asking the Mac
|
|
1689
|
+
} catch (e) {
|
|
1690
|
+
if (e.code === 'removed' || gone()) return
|
|
1691
|
+
a.status = 'failed'; a.error = e.message || 'Upload failed'
|
|
1692
|
+
}
|
|
1693
|
+
paintChips(a.sid)
|
|
1694
|
+
attachWaiters()
|
|
1695
|
+
}
|
|
1696
|
+
let attachWaitList = []
|
|
1697
|
+
function attachWaiters() { const w = attachWaitList; attachWaitList = []; for (const f of w) f() }
|
|
1698
|
+
// Wait until nothing of this session's is still uploading.
|
|
1699
|
+
async function uploadsSettled(sid) {
|
|
1700
|
+
while (attachList(sid).some((a) => a.status === 'prep' || a.status === 'up')) await new Promise((r) => { attachWaitList.push(r); setTimeout(r, 1000) })
|
|
1701
|
+
}
|
|
1702
|
+
function removeAttachment(lid) {
|
|
1703
|
+
for (const [sid, list] of attachUi.by) {
|
|
1704
|
+
const a = list.find((x) => x.lid === lid)
|
|
1705
|
+
if (!a) continue
|
|
1706
|
+
attachUi.by.set(sid, list.filter((x) => x !== a))
|
|
1707
|
+
paintChips(sid)
|
|
1708
|
+
const ta = document.querySelector('.composer[data-sid="' + CSS.escape(sid) + '"] .composer-input')
|
|
1709
|
+
if (ta && !isTouchUi(window.matchMedia)) ta.focus({ preventScroll: true })
|
|
1710
|
+
return
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
function retryAttachment(lid) {
|
|
1714
|
+
for (const list of attachUi.by.values()) { const a = list.find((x) => x.lid === lid); if (a) { startUpload(a); paintChips(a.sid); return } }
|
|
1715
|
+
}
|
|
1716
|
+
// The composer a drop or a paste is for: the session page's own (enabled).
|
|
1717
|
+
function composerFor(el) {
|
|
1718
|
+
const box = (el && el.closest && el.closest('.composer')) || document.querySelector('#app .composer[data-sid]')
|
|
1719
|
+
return box && !box.classList.contains('off') ? box : null
|
|
1720
|
+
}
|
|
1721
|
+
document.addEventListener('change', (ev) => {
|
|
1722
|
+
const inp = ev.target
|
|
1723
|
+
if (!inp || !inp.classList || !inp.classList.contains('att-input')) return
|
|
1724
|
+
addFiles(inp.dataset.sid, inp.files)
|
|
1725
|
+
inp.value = '' // the same file can be picked again
|
|
1726
|
+
})
|
|
1727
|
+
document.addEventListener('paste', (ev) => {
|
|
1728
|
+
const t = ev.target
|
|
1729
|
+
const inComposer = t && t.classList && t.classList.contains('composer-input')
|
|
1730
|
+
if (!inComposer && t && t.closest && t.closest('input, textarea, [contenteditable]')) return
|
|
1731
|
+
const cd = ev.clipboardData
|
|
1732
|
+
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) : []
|
|
1733
|
+
if (!files.length) return
|
|
1734
|
+
const box = composerFor(inComposer ? t : null)
|
|
1735
|
+
if (!box) return
|
|
1736
|
+
ev.preventDefault()
|
|
1737
|
+
addFiles(box.dataset.sid, files, { pasted: true })
|
|
1738
|
+
})
|
|
1739
|
+
{
|
|
1740
|
+
// Drag files onto the session page: the box lights up; drop attaches.
|
|
1741
|
+
const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
|
|
1742
|
+
let leaveT = 0
|
|
1743
|
+
const lit = (on) => { const b = document.querySelector('#app .composer[data-sid]'); if (b) b.classList.toggle('att-drop', on) }
|
|
1744
|
+
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) })
|
|
1745
|
+
document.addEventListener('drop', (e) => {
|
|
1746
|
+
if (!hasFiles(e)) return
|
|
1747
|
+
const box = composerFor(null)
|
|
1748
|
+
e.preventDefault() // never let a stray drop navigate away from the board
|
|
1749
|
+
lit(false)
|
|
1750
|
+
if (box) addFiles(box.dataset.sid, e.dataTransfer.files)
|
|
1751
|
+
})
|
|
1752
|
+
}
|
|
1753
|
+
// A sent message's attachments in its bubble: thumbnails (tap for full size) or file chips.
|
|
1754
|
+
function attBubbleHtml(atts, sid) {
|
|
1755
|
+
return '<div class="lv-atts">' + atts.map((a) => {
|
|
1756
|
+
const s0 = a.sessionId || sid, key = s0 + '/' + a.id
|
|
1757
|
+
const label = esc(a.name) + ' · ' + esc(a.size != null ? fmtSize(a.size) : a.sizeLabel || '')
|
|
1758
|
+
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 || '') + '"'
|
|
1759
|
+
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>' }
|
|
1760
|
+
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
|
|
1761
|
+
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>'
|
|
1762
|
+
}).join('') + '</div>'
|
|
1763
|
+
}
|
|
1764
|
+
// Thumbnails come from the Mac once, then from memory. data: URLs, because the
|
|
1765
|
+
// board's CSP allows images from itself and data: only (no blob:).
|
|
1766
|
+
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) })
|
|
1767
|
+
function attData(sid, id, { thumb = false } = {}) {
|
|
1768
|
+
const key = sid + '/' + id + (thumb ? '' : '#full')
|
|
1769
|
+
if (attachUi.urls.has(key)) return Promise.resolve({ url: attachUi.urls.get(key), blob: attachUi.blobs.get(key) || null })
|
|
1770
|
+
if (attachUi.loading.has(key)) return attachUi.loading.get(key)
|
|
1771
|
+
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))
|
|
1772
|
+
attachUi.loading.set(key, p)
|
|
1773
|
+
return p
|
|
1774
|
+
}
|
|
1775
|
+
function hydrateAttThumbs() {
|
|
1776
|
+
for (const img of document.querySelectorAll('#app img[data-att-thumb]:not([src])')) {
|
|
1777
|
+
const key = img.dataset.attThumb
|
|
1778
|
+
if (attachUi.urls.has(key)) { img.src = attachUi.urls.get(key); continue }
|
|
1779
|
+
const btn = img.closest('.lv-att'); if (!btn || btn.dataset.loading) continue
|
|
1780
|
+
btn.dataset.loading = '1'
|
|
1781
|
+
const thumb = btn.dataset.thumb !== '0'
|
|
1782
|
+
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 })
|
|
1783
|
+
.catch((e) => {
|
|
1784
|
+
// a PDF sent without a first-page picture (before 0.9.8, or it couldn't be drawn) keeps its icon
|
|
1785
|
+
if (btn.dataset.kind === 'pdf' && e && e.code === 'no_thumb') { for (const i of btn.querySelectorAll('img[data-att-thumb]')) i.remove(); return }
|
|
1786
|
+
btn.classList.add('gone'); btn.title = 'Not on that Mac any more'
|
|
1787
|
+
})
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
// An image opens full screen (ui/lightbox.js) with the other pictures of the
|
|
1791
|
+
// same message beside it; the thumbnail shows (blurred) while the full one
|
|
1792
|
+
// comes from the Mac. Text and other files keep the docs viewer.
|
|
1793
|
+
const attSel = (sid, id) => '.lv-att[data-sid="' + CSS.escape(sid) + '"][data-att="' + CSS.escape(id) + '"]'
|
|
1794
|
+
function openImageAttachment(btn) {
|
|
1795
|
+
const group = btn.closest('.lv-atts')
|
|
1796
|
+
const btns = group ? [...group.querySelectorAll('.lv-att[data-kind="image"]')] : [btn]
|
|
1797
|
+
const items = btns.map((b) => {
|
|
1798
|
+
const im = b.querySelector('img'), sid = b.dataset.sid, id = b.dataset.att
|
|
1799
|
+
const full = attachUi.urls.get(sid + '/' + id + '#full')
|
|
1800
|
+
return { name: b.dataset.name || 'image', sizeLabel: b.dataset.size || '', thumb: (im && im.getAttribute('src')) || null, url: full || null,
|
|
1801
|
+
w: im && im.naturalWidth, h: im && im.naturalHeight, load: () => attData(sid, id).then((r) => r.url) }
|
|
1802
|
+
})
|
|
1803
|
+
const sid = btn.dataset.sid, id = btn.dataset.att
|
|
1804
|
+
openLightbox({ items, index: Math.max(0, btns.indexOf(btn)), from: btn.querySelector('img') || btn, refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
|
|
1805
|
+
}
|
|
1806
|
+
// A chip in the box before it's sent: the picture from the file you picked
|
|
1807
|
+
// (a data: URL; the CSP takes no blob:), the other pictures in the box beside it.
|
|
1808
|
+
// Any other file: its name and size in the docs viewer (nothing to download yet).
|
|
1809
|
+
function openChip(lid) {
|
|
1810
|
+
let a = null
|
|
1811
|
+
for (const list of attachUi.by.values()) { a = list.find((x) => x.lid === lid); if (a) break }
|
|
1812
|
+
if (!a) return
|
|
1813
|
+
const chip = document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
|
|
1814
|
+
const isImg = (x) => x.kind === 'image' && (x.preview || /^image\/(png|jpeg|gif|webp|bmp|svg\+xml|avif)$/i.test((x.prepared || x.file || {}).type || ''))
|
|
1815
|
+
const refocus = () => document.querySelector('.att-open[data-lid="' + CSS.escape(lid) + '"]')
|
|
1816
|
+
if (a.kind === 'pdf') {
|
|
1817
|
+
const f = a.prepared || a.file
|
|
1818
|
+
openPdfView({ name: a.name, sizeLabel: fmtSize(a.size), download: false, from: chip, refocus, load: async () => ({ bytes: new Uint8Array(await f.arrayBuffer()) }) })
|
|
1819
|
+
return
|
|
1820
|
+
}
|
|
1821
|
+
if (isImg(a)) {
|
|
1822
|
+
const list = attachList(a.sid).filter(isImg)
|
|
1823
|
+
const items = list.map((x) => ({ name: x.name, sizeLabel: fmtSize(x.size), thumb: x.preview || null, download: false,
|
|
1824
|
+
load: () => { if (!x.fullUrl) x.fullUrl = blobToDataUrl(x.prepared || x.file); return x.fullUrl } }))
|
|
1825
|
+
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) + '"]') })
|
|
1826
|
+
return
|
|
1827
|
+
}
|
|
1828
|
+
const el = docViewerEl()
|
|
1829
|
+
el.querySelector('.dv-title').textContent = a.name
|
|
1830
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
1831
|
+
el.querySelector('.dv-dl').hidden = true
|
|
1832
|
+
docUi.r = null; docUi.name = a.name
|
|
1833
|
+
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>'
|
|
1834
|
+
el.hidden = false
|
|
1835
|
+
document.documentElement.classList.add('dv-open')
|
|
1836
|
+
el.querySelector('.dv-x') && el.querySelector('.dv-x').focus({ preventScroll: true })
|
|
1837
|
+
}
|
|
1838
|
+
// A PDF opens in its own full-screen preview (ui/pdfview.js): the pages drawn
|
|
1839
|
+
// from the file the Mac sends back (the same chunked read as a picture).
|
|
1840
|
+
function openPdfAttachment(btn) {
|
|
1841
|
+
const sid = btn.dataset.sid, id = btn.dataset.att
|
|
1842
|
+
openPdfView({ name: btn.dataset.name || 'file.pdf', sizeLabel: btn.dataset.size || '', from: btn,
|
|
1843
|
+
load: async () => { const r = await attData(sid, id); return { bytes: new Uint8Array(await r.blob.arrayBuffer()), url: r.url } },
|
|
1844
|
+
refocus: () => document.querySelector('#app ' + attSel(sid, id)) || btn })
|
|
1845
|
+
}
|
|
1846
|
+
async function openAttachment(btn) {
|
|
1847
|
+
const sid = btn.dataset.sid, id = btn.dataset.att, name = btn.dataset.name || 'file', kind = btn.dataset.kind
|
|
1848
|
+
if (kind === 'image') return openImageAttachment(btn)
|
|
1849
|
+
if (kind === 'pdf') return openPdfAttachment(btn)
|
|
1850
|
+
const el = docViewerEl()
|
|
1851
|
+
el.querySelector('.dv-title').textContent = name
|
|
1852
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
1853
|
+
el.querySelector('.dv-dl').hidden = true
|
|
1854
|
+
const body = el.querySelector('.dv-body')
|
|
1855
|
+
body.innerHTML = '<div class="empty">Opening…</div>'
|
|
1856
|
+
docUi.r = null; docUi.name = name
|
|
1857
|
+
el.hidden = false
|
|
1858
|
+
document.documentElement.classList.add('dv-open')
|
|
1859
|
+
try {
|
|
1860
|
+
const { url: u, blob } = await attData(sid, id)
|
|
1861
|
+
if (el.hidden) return
|
|
1862
|
+
const dl = '<a class="btn" href="' + esc(u) + '" download="' + esc(name) + '">' + icon('download') + 'Download</a>'
|
|
1863
|
+
if (kind === 'image') body.innerHTML = '<div class="att-full"><img src="' + esc(u) + '" alt="' + esc(name) + '"></div><div class="att-full-actions">' + dl + '</div>'
|
|
1864
|
+
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>' }
|
|
1865
|
+
else body.innerHTML = '<div class="empty">' + esc(name) + '</div><div class="att-full-actions">' + dl + '</div>'
|
|
1866
|
+
} catch (e) { if (!el.hidden) body.innerHTML = '<div class="empty">Couldn’t open it: ' + esc(e.message) + '</div>' }
|
|
1867
|
+
}
|
|
1868
|
+
// 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.
|
|
1869
|
+
function fitComposer(ta) {
|
|
1870
|
+
if (!ta || !ta.isConnected) return
|
|
1871
|
+
const cs = getComputedStyle(ta)
|
|
1872
|
+
const lh = parseFloat(cs.lineHeight) || 22
|
|
1873
|
+
const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0)
|
|
1874
|
+
const keep = ta.scrollTop
|
|
1875
|
+
ta.style.height = 'auto'
|
|
1876
|
+
const r = composerHeight({ scrollHeight: ta.scrollHeight, lineHeight: lh, padY })
|
|
1877
|
+
ta.style.height = r.height + 'px'
|
|
1878
|
+
ta.style.overflowY = r.scroll ? 'auto' : 'hidden'
|
|
1879
|
+
if (r.scroll) ta.scrollTop = keep
|
|
1880
|
+
syncComposer(ta)
|
|
1881
|
+
}
|
|
1882
|
+
function syncComposer(ta) {
|
|
1883
|
+
const box = ta && ta.closest('.composer')
|
|
1884
|
+
const btn = box && box.querySelector('.composer-send')
|
|
1885
|
+
if (!btn || btn.classList.contains('sending')) return
|
|
1886
|
+
btn.disabled = ta.disabled || (isBlank(ta.value) && !attachList(box.dataset.sid).length)
|
|
1887
|
+
}
|
|
1888
|
+
document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
|
|
1889
|
+
// Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
|
|
1890
|
+
// The same for Live and the Needs-you Reply composer: the key presses its own box's ↑.
|
|
1891
|
+
document.addEventListener('keydown', (ev) => {
|
|
1892
|
+
const ta = ev.target
|
|
1893
|
+
if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
|
|
1894
|
+
if (ev.metaKey || ev.ctrlKey) return // ⌘↵ / Ctrl+↵: the Needs-you handler presses the button
|
|
1895
|
+
if (composerKey(ev, { touch: isTouchUi(window.matchMedia) }) !== 'send') return
|
|
1896
|
+
ev.preventDefault()
|
|
1897
|
+
const cb = ta.closest('.composer')
|
|
1898
|
+
if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
|
|
1899
|
+
const box = ta.closest('.live-box') || ta.closest('.need')
|
|
1900
|
+
const btn = box && box.querySelector('[data-submit-for="' + CSS.escape(ta.dataset.submit || 'live') + '"]')
|
|
1901
|
+
if (!btn || btn.disabled) return
|
|
1902
|
+
const last = Number(box.dataset.kbdAt || 0)
|
|
1903
|
+
if (Date.now() - last < 1500) return
|
|
1904
|
+
box.dataset.kbdAt = String(Date.now())
|
|
1905
|
+
btn.click()
|
|
1906
|
+
})
|
|
1907
|
+
// A tap anywhere on the box (its padding) is a tap in the textarea.
|
|
1908
|
+
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() })
|
|
1909
|
+
// Pressing ↑ keeps the keyboard up (the textarea keeps focus) on a phone.
|
|
1910
|
+
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() })
|
|
1412
1911
|
// After the session page is drawn: keep the thread (scroll, diffed entries), show "Jump to latest".
|
|
1413
1912
|
function liveAfterRender(sid) {
|
|
1913
|
+
for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
|
|
1414
1914
|
const r = liveKeeper.after($app)
|
|
1915
|
+
hydrateAttThumbs(); barWidths($app)
|
|
1916
|
+
{ const w = document.getElementById('what-it-did'); if (w && widUi.has(sid) && hydrateDiffs(w, (p) => widSlot(widOf(sid), p))) widStickyTop() }
|
|
1415
1917
|
if (!r) return
|
|
1416
1918
|
const ol = r.thread.querySelector('.live-log')
|
|
1417
1919
|
const empty = r.thread.querySelector('.live-empty')
|
|
@@ -1479,34 +1981,141 @@ async function liveEarlierClick(btn) {
|
|
|
1479
1981
|
await render()
|
|
1480
1982
|
}
|
|
1481
1983
|
// The elapsed time beside "Claude is working…", every second while something is in flight.
|
|
1984
|
+
// 0.9.8: when what the page shows is older than STALE_MS (the stream died, the
|
|
1985
|
+
// phone slept), a running bubble says "Reconnecting…" instead of a timer.
|
|
1482
1986
|
let liveTimer = null
|
|
1483
1987
|
function liveTick() {
|
|
1484
1988
|
const els = [...document.querySelectorAll('.lv-status[data-since]')]
|
|
1989
|
+
const box = document.querySelector('#live[data-id]')
|
|
1990
|
+
const stale = !!box && isStale(liveUi.okAt[box.dataset.id], Date.now())
|
|
1485
1991
|
for (const el of els) {
|
|
1486
1992
|
const since = Number(el.dataset.since) || 0
|
|
1487
1993
|
const end = Number(el.dataset.final) || 0
|
|
1488
1994
|
const t = el.querySelector('.lvs-time')
|
|
1489
1995
|
if (t && since) t.textContent = elapsedText((end || Date.now()) - since)
|
|
1996
|
+
if (!end) {
|
|
1997
|
+
el.classList.toggle('lv-reconnecting', stale)
|
|
1998
|
+
let hint = el.querySelector('.lvs-hint')
|
|
1999
|
+
if (stale && !hint) { hint = document.createElement('span'); hint.className = 'lvs-hint'; hint.textContent = 'Reconnecting…'; (el.querySelector('.lvs-line') || el).append(hint) }
|
|
2000
|
+
if (hint) hint.hidden = !stale
|
|
2001
|
+
}
|
|
1490
2002
|
}
|
|
1491
2003
|
const running = els.some((el) => !el.dataset.final)
|
|
1492
2004
|
if (running && !liveTimer) liveTimer = setInterval(() => { if (!document.querySelector('.lv-status[data-since]:not([data-final])')) { clearInterval(liveTimer); liveTimer = null } liveTick() }, 1000)
|
|
1493
2005
|
}
|
|
2006
|
+
|
|
2007
|
+
// 0.9.8: Live keeps up after the phone sleeps (ui/live-wake.js). Every Live
|
|
2008
|
+
// fetch notes when it answered and whether a message is still going; while
|
|
2009
|
+
// one is, on a visible page, the thread is fetched again every POLL_MS
|
|
2010
|
+
// whatever the event stream does.
|
|
2011
|
+
const liveUi = { okAt: {}, sig: {}, pending: {}, polling: false }
|
|
2012
|
+
function noteLive(id, v) {
|
|
2013
|
+
if (!v || v.error) return
|
|
2014
|
+
liveUi.okAt[id] = Date.now()
|
|
2015
|
+
liveUi.pending[id] = anyPending(v.messages)
|
|
2016
|
+
}
|
|
2017
|
+
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 }
|
|
2018
|
+
// Fetch the thread and diff it into the page, even while you type (busy()
|
|
2019
|
+
// holds full redraws back, never the thread). A turn that ended also redraws
|
|
2020
|
+
// the rest of the page (the pill stops pulsing) once you're not busy.
|
|
2021
|
+
async function refreshLive(reason) {
|
|
2022
|
+
const id = liveOnScreen()
|
|
2023
|
+
if (!id || liveUi.polling) return false
|
|
2024
|
+
liveUi.polling = true; liveUi.triedAt = Date.now()
|
|
2025
|
+
let v
|
|
2026
|
+
try { v = await fetchLive(id, { timeoutMs: 8000 }) } finally { liveUi.polling = false }
|
|
2027
|
+
if (!v || v.error || liveOnScreen() !== id) { liveTick(); return false }
|
|
2028
|
+
const s = state.sessions.find((x) => x.id === id)
|
|
2029
|
+
const sig = liveSignature(v.messages)
|
|
2030
|
+
const before = liveUi.sig[id]
|
|
2031
|
+
liveUi.sig[id] = sig
|
|
2032
|
+
if (s && before !== sig) {
|
|
2033
|
+
const box = document.querySelector('#live')
|
|
2034
|
+
const cur = box && box.querySelector('.live-thread')
|
|
2035
|
+
if (cur) {
|
|
2036
|
+
const tmp = document.createElement('div')
|
|
2037
|
+
tmp.innerHTML = liveBoxHtml(s, v)
|
|
2038
|
+
const fresh = tmp.querySelector('.live-thread')
|
|
2039
|
+
if (fresh) {
|
|
2040
|
+
const next = cur.nextSibling
|
|
2041
|
+
liveKeeper.before(box)
|
|
2042
|
+
box.insertBefore(fresh, next)
|
|
2043
|
+
const r = liveKeeper.after(box)
|
|
2044
|
+
hydrateAttThumbs()
|
|
2045
|
+
if (r) liveJumpButton(r.thread, liveKeeper.unseen)
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
const c = sessionCache.get(id); if (c) c.live = v
|
|
2049
|
+
if (!liveUi.pending[id] && !busy()) soon() // settled: the pill, the tab, the card
|
|
2050
|
+
}
|
|
2051
|
+
liveTick()
|
|
2052
|
+
return true
|
|
2053
|
+
}
|
|
2054
|
+
// The safety net, and the stream's watchdog, on one slow timer.
|
|
2055
|
+
const streamDog = createWatchdog()
|
|
2056
|
+
const wakeGate = createWake()
|
|
2057
|
+
setInterval(() => {
|
|
2058
|
+
if (document.visibilityState === 'hidden') return
|
|
2059
|
+
const id = liveOnScreen()
|
|
2060
|
+
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')
|
|
2061
|
+
if (streamDog.check() === 'dead') { connect(); refreshView('stream') }
|
|
2062
|
+
}, 1000)
|
|
2063
|
+
// Coming back (iOS: the Home Screen app returns, the screen unlocks, a
|
|
2064
|
+
// bfcache restore, the network returns): fetch what's on screen at once, and
|
|
2065
|
+
// open a new stream if the old one missed its pings.
|
|
2066
|
+
function refreshView(reason) {
|
|
2067
|
+
if (liveOnScreen()) refreshLive(reason)
|
|
2068
|
+
if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name))) render()
|
|
2069
|
+
refreshHealth()
|
|
2070
|
+
}
|
|
2071
|
+
let awayAt = 0 // when the page went to the background (hidden, or the window lost focus)
|
|
2072
|
+
function onWake(ev) {
|
|
2073
|
+
if (ev.type === 'blur' || (ev.type === 'visibilitychange' && document.visibilityState === 'hidden')) { awayAt = awayAt || Date.now(); return }
|
|
2074
|
+
const why = wakeReason(ev.type, { visibilityState: document.visibilityState, persisted: !!ev.persisted })
|
|
2075
|
+
if (!why) return
|
|
2076
|
+
liveTick() // a stale timer turns into "Reconnecting…" before anything else
|
|
2077
|
+
const dead = !stream || stream.readyState === 2 || streamDead(streamDog.lastAt, Date.now(), HEARTBEAT_MS + 2000)
|
|
2078
|
+
const away = awayAt ? Date.now() - awayAt : 0
|
|
2079
|
+
awayAt = 0
|
|
2080
|
+
// 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
|
|
2081
|
+
if ((why === 'focus' || why === 'visible') && away < AWAY_MS && !dead) return
|
|
2082
|
+
if (!wakeGate.due()) return
|
|
2083
|
+
if (why === 'pageshow-bfcache' || dead) connect()
|
|
2084
|
+
refreshView(why)
|
|
2085
|
+
}
|
|
2086
|
+
for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
|
|
2087
|
+
document.addEventListener('visibilitychange', onWake)
|
|
1494
2088
|
async function liveSendClick(btn) {
|
|
1495
2089
|
const box = btn.closest('.live-box')
|
|
1496
2090
|
const ta = box && box.querySelector('.live-text')
|
|
2091
|
+
const sid = btn.dataset.id
|
|
1497
2092
|
const text = ta ? ta.value.trim() : ''
|
|
1498
|
-
if (!text
|
|
1499
|
-
if (btn.
|
|
1500
|
-
|
|
2093
|
+
if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
|
|
2094
|
+
if (btn.disabled || btn.classList.contains('sending')) return
|
|
2095
|
+
const hadFocus = !!(ta && document.activeElement === ta)
|
|
2096
|
+
btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
|
|
1501
2097
|
try {
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
2098
|
+
// 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
|
|
2099
|
+
await uploadsSettled(sid)
|
|
2100
|
+
const atts = attachList(sid)
|
|
2101
|
+
const failed = atts.filter((a) => a.status === 'failed').length
|
|
2102
|
+
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 }
|
|
2103
|
+
const ids = atts.filter((a) => a.status === 'done' && a.id).map((a) => a.id)
|
|
2104
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live', { text, ...(ids.length ? { attachments: ids } : {}) })
|
|
2105
|
+
// what you typed while it went stays; what was sent goes
|
|
2106
|
+
if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
|
|
2107
|
+
if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
|
|
1505
2108
|
if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
|
|
1506
2109
|
const ol = box && box.querySelector('.live-log')
|
|
1507
2110
|
if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
|
|
1508
2111
|
setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
|
|
1509
|
-
} catch (e) { toast(e.message, 4200) } finally {
|
|
2112
|
+
} catch (e) { toast(e.message, 4200) } finally {
|
|
2113
|
+
await render()
|
|
2114
|
+
const nb = document.querySelector('.live-box [data-action="live-send"]')
|
|
2115
|
+
if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
|
|
2116
|
+
const nt = document.querySelector('.live-box .composer-input')
|
|
2117
|
+
if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
|
|
2118
|
+
}
|
|
1510
2119
|
}
|
|
1511
2120
|
|
|
1512
2121
|
// ------------------------------------------------------------ search inside transcripts (0.9.6)
|
|
@@ -1669,6 +2278,11 @@ async function togglePr(url, { retry = false } = {}) {
|
|
|
1669
2278
|
const was = prsUi.open.has(url)
|
|
1670
2279
|
if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
|
|
1671
2280
|
const repaint = () => {
|
|
2281
|
+
if (state.route && state.route.name === 's') { // 0.9.8: the session page's PRs open in place too
|
|
2282
|
+
const s = state.sessions.find((x) => x.id === state.route.arg), sec = document.getElementById('prs')
|
|
2283
|
+
if (s && sec) sec.innerHTML = sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details })
|
|
2284
|
+
return
|
|
2285
|
+
}
|
|
1672
2286
|
const d = prsUi.data[prsUi.view]
|
|
1673
2287
|
const r = prRows(d).find((x) => x.pr.url === url)
|
|
1674
2288
|
const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
|
|
@@ -1682,7 +2296,7 @@ async function togglePr(url, { retry = false } = {}) {
|
|
|
1682
2296
|
try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
1683
2297
|
catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
|
|
1684
2298
|
prsUi.details[url] = { ...res, at: Date.now() }
|
|
1685
|
-
if (state.route && state.route.name === 'prs') repaint()
|
|
2299
|
+
if (state.route && (state.route.name === 'prs' || state.route.name === 's')) repaint()
|
|
1686
2300
|
}
|
|
1687
2301
|
function setPrsFilter(f) {
|
|
1688
2302
|
const h = prsHash(prsUi.view, f)
|
|
@@ -1743,29 +2357,67 @@ async function viewTimeline() {
|
|
|
1743
2357
|
$app.innerHTML = html
|
|
1744
2358
|
}
|
|
1745
2359
|
|
|
1746
|
-
|
|
2360
|
+
// 0.9.8: a digest line may end in "<!-- s:<id> -->", its session: the title
|
|
2361
|
+
// links there and Start here gets that session's Go / Resume.
|
|
2362
|
+
const MD_SID = /\s*<!-- s:(\S+?) -->\s*$/
|
|
2363
|
+
function briefAction(id, sessions, viewer) {
|
|
2364
|
+
const open = '<a class="btn sh-open" href="#/s/' + encodeURIComponent(id) + '" aria-label="Open the session">Open</a>'
|
|
2365
|
+
const s = sessions && sessions.get(id)
|
|
2366
|
+
if (!s) return open
|
|
2367
|
+
let b = ''
|
|
2368
|
+
// 0.9.8: closed: Resume opens it in a new tab where it can, else (on the Mac) copies the resume command
|
|
2369
|
+
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>')
|
|
2370
|
+
else { const g = goButton(s); b = g && !/disabled/.test(g) ? g : '' }
|
|
2371
|
+
return b || open
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function renderMd(md, { spaces = [], sessions = null, viewer = null } = {}) {
|
|
1747
2375
|
const body = md.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
1748
2376
|
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)
|
|
1749
2377
|
const out = []
|
|
1750
|
-
let inList = false, inSec = false
|
|
2378
|
+
let inList = false, inSec = false, inFin = false, seenH2 = false, inUnf = false
|
|
2379
|
+
const closeList = () => { if (inList) { out.push(inList === 'ol' ? '</ol>' : '</ul>'); inList = false } if (inFin) { out.push('</details>'); inFin = false } }
|
|
2380
|
+
const openList = (kind, cls) => { if (inList !== kind) { if (inList) out.push(inList === 'ol' ? '</ol>' : '</ul>'); out.push('<' + kind + (cls ? ' class="' + cls + '"' : '') + '>'); inList = kind } }
|
|
2381
|
+
const href = (id) => '#/s/' + encodeURIComponent(id)
|
|
1751
2382
|
// v0.5.8: each "## <space>" opens a section with its own Copy for standup;
|
|
1752
2383
|
// other "## " headings (Lists, Top of mind) are whole-folder sections.
|
|
2384
|
+
// 0.9.8: "## Start here" is the brief's own section, never hidden by a space.
|
|
1753
2385
|
const openSec = (h) => {
|
|
1754
2386
|
if (inSec) out.push('</section>')
|
|
1755
2387
|
inSec = true
|
|
1756
|
-
if (
|
|
2388
|
+
if (h === 'Start here') out.push('<section class="start-sec"><h2>' + inline(h) + '</h2>')
|
|
2389
|
+
else if (spaces.includes(h)) {
|
|
1757
2390
|
out.push('<section class="space-sec" data-space="' + esc(h) + '"><div class="space-head"><h2>' + esc(h) + '</h2>' +
|
|
1758
2391
|
'<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>')
|
|
1759
2392
|
} else out.push('<section class="other-sec"><h2>' + inline(h) + '</h2>')
|
|
1760
2393
|
}
|
|
1761
|
-
for (const
|
|
2394
|
+
for (const raw of body.split('\n')) {
|
|
2395
|
+
const mid = raw.match(MD_SID)
|
|
2396
|
+
const id = mid ? mid[1] : null
|
|
2397
|
+
const line = mid ? raw.slice(0, mid.index) : raw
|
|
2398
|
+
// 0.9.8: Start here: "1. <step> — **Title** (repo)"
|
|
2399
|
+
const st = /^\d+\. /.test(line) && line.match(/^\d+\. (.*?) — \*\*(.+?)\*\*(?: \(([^)]*)\))?$/)
|
|
2400
|
+
if (st) {
|
|
2401
|
+
openList('ol', 'start')
|
|
2402
|
+
const t = id ? '<a href="' + href(id) + '">' + inline(st[2]) + '</a>' : inline(st[2])
|
|
2403
|
+
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>' +
|
|
2404
|
+
(id ? '<div class="sh-act">' + briefAction(id, sessions, viewer) + '</div>' : '') + '</li>')
|
|
2405
|
+
continue
|
|
2406
|
+
}
|
|
1762
2407
|
if (/^- /.test(line)) {
|
|
1763
|
-
|
|
2408
|
+
openList('ul')
|
|
1764
2409
|
// Digest items read "- **Title** (repo · branch) — state": tag the repo so the view can filter.
|
|
1765
2410
|
const m = line.match(/^- \*\*.*?\*\* \(([^)·]+?)(?: · [^)]*)?\) — /)
|
|
2411
|
+
const attrs = (m ? ' data-repo="' + esc(m[1].trim()) + '"' : '') + (id ? ' data-sid="' + esc(id) + '"' : '')
|
|
2412
|
+
// 0.9.8: "- **Title** (repo · state) — You were …; left off …." with its
|
|
2413
|
+
// session: the title links to it, the sentence under it, the tags quiet.
|
|
2414
|
+
const bi = id && line.match(/^- \*\*(.+?)\*\* \(([^)]*)\) — (.*)$/)
|
|
2415
|
+
// 0.9.8: an Unfinished one is closed, so it has its Resume beside it
|
|
2416
|
+
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 }
|
|
2417
|
+
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 }
|
|
1766
2418
|
// v0.5.7: the "(repo · branch · machine)" part is set small so the title leads on a phone.
|
|
1767
2419
|
const li = inline(line.slice(2)).replace(/^(<strong>.*?<\/strong>) (\([^)]*\))( — )/, '$1 <span class="where">$2</span>$3')
|
|
1768
|
-
out.push('<li' +
|
|
2420
|
+
out.push('<li' + attrs + '>' + li); continue
|
|
1769
2421
|
}
|
|
1770
2422
|
if (/^\s{2,}\S/.test(line) && inList) {
|
|
1771
2423
|
// "Stopped at: … Next: …" is two lines, each with its label.
|
|
@@ -1774,13 +2426,17 @@ function renderMd(md, { spaces = [] } = {}) {
|
|
|
1774
2426
|
else out.push('<span class="sub">' + inline(t) + '</span>')
|
|
1775
2427
|
continue
|
|
1776
2428
|
}
|
|
1777
|
-
|
|
2429
|
+
closeList()
|
|
2430
|
+
if (/^#{1,3} /.test(line)) inUnf = /^### Unfinished \(\d+\)\s*$/.test(line)
|
|
1778
2431
|
if (/^# /.test(line)) out.push('<h1>' + inline(line.slice(2)) + '</h1>')
|
|
1779
|
-
else if (/^## /.test(line)) { if (spaces.length) openSec(
|
|
2432
|
+
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>') } }
|
|
2433
|
+
// 0.9.8: Finished folds away (older digests' "Finished or closed" too)
|
|
2434
|
+
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 }
|
|
1780
2435
|
else if (/^### /.test(line)) out.push('<h3>' + inline(line.slice(4)) + '</h3>')
|
|
1781
|
-
|
|
2436
|
+
// 0.9.8: the Yesterday / Today opener, above the first section
|
|
2437
|
+
else if (line.trim()) out.push(seenH2 ? '<p>' + inline(line) + '</p>' : '<p class="lede">' + inline(line).replace(/^(Yesterday|Today):/, '<b>$1:</b>') + '</p>')
|
|
1782
2438
|
}
|
|
1783
|
-
|
|
2439
|
+
closeList()
|
|
1784
2440
|
if (inSec) out.push('</section>')
|
|
1785
2441
|
return out.join('\n')
|
|
1786
2442
|
}
|
|
@@ -1791,6 +2447,81 @@ function shortDay(iso) {
|
|
|
1791
2447
|
return isNaN(d) ? iso : d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).replace(',', '')
|
|
1792
2448
|
}
|
|
1793
2449
|
|
|
2450
|
+
// The past-digests calendar (0.9.8): one month at a time, Sunday first. A day
|
|
2451
|
+
// with a digest is a link (the shared one, else a Mac's own from before one
|
|
2452
|
+
// digest a day); the rest are plain numbers. Opens on the picked digest's
|
|
2453
|
+
// month; next stops at this month, prev at the first month with a digest.
|
|
2454
|
+
const monthOf = (iso) => String(iso).slice(0, 7)
|
|
2455
|
+
const localDay = (t = new Date()) => t.getFullYear() + '-' + String(t.getMonth() + 1).padStart(2, '0') + '-' + String(t.getDate()).padStart(2, '0')
|
|
2456
|
+
function shiftMonth(ym, by) {
|
|
2457
|
+
const [y, m] = ym.split('-').map(Number)
|
|
2458
|
+
const d = new Date(y, m - 1 + by, 1)
|
|
2459
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
|
2460
|
+
}
|
|
2461
|
+
function digestCalendar() {
|
|
2462
|
+
const { days = [], pick = null } = digestUi.cal || {}
|
|
2463
|
+
const today = localDay()
|
|
2464
|
+
const max = monthOf(today)
|
|
2465
|
+
const min = days.length ? days.map((x) => monthOf(x.day)).sort()[0] : max
|
|
2466
|
+
let ym = digestUi.month || max
|
|
2467
|
+
if (ym > max) ym = max
|
|
2468
|
+
if (ym < min) ym = min
|
|
2469
|
+
digestUi.month = ym
|
|
2470
|
+
const [y, m] = ym.split('-').map(Number)
|
|
2471
|
+
const first = new Date(y, m - 1, 1)
|
|
2472
|
+
const label = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
|
2473
|
+
const by = new Map(days.map((x) => [x.day, x.d]))
|
|
2474
|
+
const prev = shiftMonth(ym, -1), next = shiftMonth(ym, 1)
|
|
2475
|
+
const name = (x) => new Date(x + '-01T12:00:00').toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
|
2476
|
+
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>'
|
|
2477
|
+
// phones fold it to one week (the picked day's) until you open the month
|
|
2478
|
+
const open = !!digestUi.calOpen
|
|
2479
|
+
let h = '<div class="cal-head">' + navBtn(prev, prev >= min, -1) + '<span class="cal-month" aria-live="polite">' + esc(label) + '</span>' +
|
|
2480
|
+
'<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>'
|
|
2481
|
+
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('')
|
|
2482
|
+
const n = new Date(y, m, 0).getDate()
|
|
2483
|
+
const lead0 = first.getDay()
|
|
2484
|
+
const week = (dn) => Math.floor((lead0 + dn - 1) / 7)
|
|
2485
|
+
const inMonth = (x) => x && x.slice(0, 7) === ym
|
|
2486
|
+
const firstHas = days.map((x) => x.day).filter(inMonth).sort()[0]
|
|
2487
|
+
const wk = week(inMonth(pick) ? Number(pick.slice(8)) : inMonth(today) ? Number(today.slice(8)) : firstHas ? Number(firstHas.slice(8)) : 1)
|
|
2488
|
+
for (let i = 0; i < lead0; i++) h += '<span class="cal-day pad' + (wk === 0 ? ' wk' : '') + '" aria-hidden="true"></span>'
|
|
2489
|
+
let count = 0
|
|
2490
|
+
for (let dn = 1; dn <= n; dn++) {
|
|
2491
|
+
const day = ym + '-' + String(dn).padStart(2, '0')
|
|
2492
|
+
const d = by.get(day)
|
|
2493
|
+
const cls = ['cal-day', d ? 'has' : 'none', day === pick ? 'on' : '', day === today ? 'today' : '', week(dn) === wk ? 'wk' : ''].filter(Boolean).join(' ')
|
|
2494
|
+
const long = shortDay(day)
|
|
2495
|
+
if (d) {
|
|
2496
|
+
count++
|
|
2497
|
+
const whose = d.shared ? '' : ', ' + d.machine + '’s own digest'
|
|
2498
|
+
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>'
|
|
2499
|
+
} else h += '<span class="' + cls + '" data-day="' + day + '"><span class="n">' + dn + '</span></span>'
|
|
2500
|
+
}
|
|
2501
|
+
h += '</div>'
|
|
2502
|
+
if (!count) h += '<div class="cal-none">' + (days.length ? 'No digests in ' + esc(first.toLocaleDateString('en-US', { month: 'long' })) + '.' : 'None yet') + '</div>'
|
|
2503
|
+
return h
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// Month or fold changed: patch the calendar in place, so the arrows (and the
|
|
2507
|
+
// focus on them, with its keyboard ring) stay put.
|
|
2508
|
+
function repaintCalendar() {
|
|
2509
|
+
const nav = document.querySelector('nav.digest-cal')
|
|
2510
|
+
if (!nav) return null
|
|
2511
|
+
const t = document.createElement('div')
|
|
2512
|
+
t.innerHTML = digestCalendar()
|
|
2513
|
+
const now = [...nav.querySelectorAll('.cal-head > *')], fresh = [...t.querySelectorAll('.cal-head > *')]
|
|
2514
|
+
now.forEach((el, i) => {
|
|
2515
|
+
const f = fresh[i]
|
|
2516
|
+
for (const a of ['data-to', 'aria-label', 'aria-expanded']) if (f.hasAttribute(a)) el.setAttribute(a, f.getAttribute(a))
|
|
2517
|
+
el.disabled = !!f.disabled
|
|
2518
|
+
if (!el.classList.contains('cal-nav')) el.innerHTML = f.innerHTML
|
|
2519
|
+
})
|
|
2520
|
+
for (const el of nav.querySelectorAll('.cal-grid, .cal-none')) el.remove()
|
|
2521
|
+
nav.append(...t.querySelectorAll('.cal-grid, .cal-none'))
|
|
2522
|
+
return nav
|
|
2523
|
+
}
|
|
2524
|
+
|
|
1794
2525
|
// Digest (v0.5.8): one digest a day for the whole folder. The strip has one
|
|
1795
2526
|
// pill per day: the shared digest, else (days from before 0.5.8) a Mac's own,
|
|
1796
2527
|
// labelled with that Mac. Space tabs (All / Work / Personal …) sit above the
|
|
@@ -1815,16 +2546,16 @@ async function viewDigest(date, machine) {
|
|
|
1815
2546
|
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.' : ''
|
|
1816
2547
|
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>).')
|
|
1817
2548
|
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>'
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
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>'
|
|
1823
|
-
}).join('') : '<span class="nocp">None yet</span>') + '</nav>'
|
|
2549
|
+
// 0.9.8: a month calendar, not a long list: days with a digest are links
|
|
2550
|
+
if (digestUi.date !== pick || !digestUi.month) { digestUi.month = monthOf(pick || localDay()); digestUi.calOpen = false }
|
|
2551
|
+
digestUi.cal = { days: days.map((day) => ({ day, d: lead(day) })), pick }
|
|
2552
|
+
html += '<div class="digest-wrap"><nav class="digest-cal" aria-label="Past digests">' + digestCalendar() + '</nav>'
|
|
1824
2553
|
let spaces = []
|
|
1825
2554
|
if (pick) {
|
|
1826
2555
|
try {
|
|
1827
|
-
|
|
2556
|
+
// 0.9.8: the sessions too, for Start here's Go / Resume
|
|
2557
|
+
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)])
|
|
2558
|
+
if (sv) { state.sessions = sv.sessions || []; if (sv.viewer) { state.viewerLocal = sv.viewer.local !== false; state.ghostty = sv.viewer.ghostty === true } }
|
|
1828
2559
|
const fm = d.meta || {}
|
|
1829
2560
|
const madeAt = fm.generated_at
|
|
1830
2561
|
spaces = Array.isArray(fm.spaces) ? fm.spaces.map(String) : []
|
|
@@ -1834,10 +2565,14 @@ async function viewDigest(date, machine) {
|
|
|
1834
2565
|
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>' : ''
|
|
1835
2566
|
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>' : ''
|
|
1836
2567
|
html += '<article class="md"><div class="digest-meta">' + legacy + (madeAt ? 'Made ' + esc(clock(madeAt)) + whose + ' · ' : '') + nextLine + '</div>' + also + unnamed +
|
|
1837
|
-
'<div id="digestSpaces"></div><div class="repos digest-repos" id="digestRepos"></div>' + renderMd(d.markdown, { spaces }) + '</article>'
|
|
2568
|
+
'<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>'
|
|
1838
2569
|
} catch (e) { html += '<div class="empty">' + esc(e.message) + '</div>' }
|
|
1839
2570
|
} 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>'
|
|
1840
2571
|
$app.innerHTML = html + '</div>'
|
|
2572
|
+
// 0.9.8: the brief comes first; the space tabs and repo chips sit under it, over the sessions they filter
|
|
2573
|
+
const art = $app.querySelector('article.md')
|
|
2574
|
+
const after = art && (art.querySelector(':scope > section.start-sec') || [...art.querySelectorAll(':scope > p.lede')].pop())
|
|
2575
|
+
if (after) after.after(...['.digest-hint', '#digestSpaces', '#digestRepos'].map((q) => art.querySelector(q)).filter(Boolean))
|
|
1841
2576
|
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
1842
2577
|
state.digestRepos = readFilter(params, window.localStorage).repos
|
|
1843
2578
|
state.digestSpaces = spaces
|
|
@@ -1859,7 +2594,7 @@ function paintDigestFilter() {
|
|
|
1859
2594
|
const spaces = state.digestSpaces || []
|
|
1860
2595
|
const sp = state.digestSpace
|
|
1861
2596
|
const secs = [...document.querySelectorAll('.md section')]
|
|
1862
|
-
for (const sec of secs) sec.hidden = !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
2597
|
+
for (const sec of secs) sec.hidden = !sec.classList.contains('start-sec') && !!sp && (sec.classList.contains('other-sec') || sec.dataset.space !== sp)
|
|
1863
2598
|
const tabsBox = document.getElementById('digestSpaces')
|
|
1864
2599
|
if (tabsBox) {
|
|
1865
2600
|
const counts = new Map(spaces.map((x) => [x, document.querySelectorAll('.md section[data-space="' + CSS.escape(x) + '"] li[data-repo]').length]))
|
|
@@ -1877,8 +2612,19 @@ function paintDigestFilter() {
|
|
|
1877
2612
|
const any = [...ul.children].some((li) => !li.hidden)
|
|
1878
2613
|
ul.hidden = !any
|
|
1879
2614
|
if (h && (h.tagName === 'H2' || h.tagName === 'H3')) h.hidden = !any
|
|
2615
|
+
const fin = ul.closest('details.fin')
|
|
2616
|
+
if (fin) fin.hidden = !any
|
|
1880
2617
|
}
|
|
1881
2618
|
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
|
|
2619
|
+
// 0.9.8: a Start here step follows its session's line: hidden with it (space or repo)
|
|
2620
|
+
const startSec = document.querySelector('.md section.start-sec')
|
|
2621
|
+
if (startSec) {
|
|
2622
|
+
for (const li of startSec.querySelectorAll('li[data-sid]')) {
|
|
2623
|
+
const twin = [...document.querySelectorAll('.md li[data-repo][data-sid]')].find((x) => x.dataset.sid === li.dataset.sid)
|
|
2624
|
+
li.hidden = !!twin && (twin.hidden || !!(twin.closest('section') && twin.closest('section').hidden))
|
|
2625
|
+
}
|
|
2626
|
+
startSec.hidden = ![...startSec.querySelectorAll('ol.start > li')].some((li) => !li.hidden)
|
|
2627
|
+
}
|
|
1882
2628
|
if (counts.size < 2) { box.innerHTML = ''; return }
|
|
1883
2629
|
const on = (r) => sel.some((tok) => repoMatches({ repoKey: r, repoLabel: r }, tok))
|
|
1884
2630
|
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>' +
|
|
@@ -2183,11 +2929,13 @@ async function tryLength() {
|
|
|
2183
2929
|
const s = list[0]
|
|
2184
2930
|
if (!s) { toast('No session yet to try it on'); return }
|
|
2185
2931
|
box.innerHTML = '<div class="ex-head">Writing a ' + esc(k) + ' checkpoint of “' + esc(s.title || 'Untitled') + '”…</div>'
|
|
2932
|
+
// the page can be redrawn while the summarizer writes (Save re-renders Settings): put the result in the box on screen then
|
|
2933
|
+
const now = () => document.getElementById('f-length-example') || box
|
|
2186
2934
|
try {
|
|
2187
2935
|
const cp = await api('POST', '/v1/checkpoints/preview', { sessionId: s.id, length: k })
|
|
2188
|
-
|
|
2936
|
+
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>' +
|
|
2189
2937
|
cpBlock(cp, { showWhen: false, md: true }) + (hasExtras(cp) ? '<div class="cpx">' + cpExtras(cp) + '</div>' : '')
|
|
2190
|
-
} catch (e) {
|
|
2938
|
+
} catch (e) { now().innerHTML = lengthExample(k); toast('Couldn’t write it: ' + e.message) }
|
|
2191
2939
|
}
|
|
2192
2940
|
|
|
2193
2941
|
// Am I the iPhone home-screen app (or another installed app window)?
|
|
@@ -2651,7 +3399,7 @@ async function viewSettings() {
|
|
|
2651
3399
|
updates: '<div class="section">About</div>' +
|
|
2652
3400
|
'<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>' +
|
|
2653
3401
|
'<div class="hint">' + updateLine() + ' <button type="button" class="linkish" data-action="check-updates">Check now</button></div>' +
|
|
2654
|
-
'<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a></div>' +
|
|
3402
|
+
'<label>Setup</label><div class="aboutline"><a href="#/setup">Run setup again</a> · <a href="#/tour">Take the tour</a></div>' +
|
|
2655
3403
|
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>' +
|
|
2656
3404
|
'<label>Machine</label><div><span class="chip machine">⌂ ' + esc(c.machineResolved) + '</span></div>' +
|
|
2657
3405
|
'<label>Folder</label><div><a class="chip mono" href="file://' + esc(c.folder) + '">' + esc(c.folder) + '</a></div>'),
|
|
@@ -3350,7 +4098,7 @@ function paintListsCount(lists) {
|
|
|
3350
4098
|
// The session page's Lists section: saved lists, the hint, and "Find lists in this session".
|
|
3351
4099
|
function sessionListsSection(s) {
|
|
3352
4100
|
const f = listUi.found[s.id]
|
|
3353
|
-
let h = '<h2>Lists <span class="count">' + ((s.lists || []).length) + '</span><button type="button" class="helpq" data-action="list-help-
|
|
4101
|
+
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"') : '')
|
|
3354
4102
|
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>'
|
|
3355
4103
|
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>'
|
|
3356
4104
|
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>' +
|
|
@@ -3395,8 +4143,11 @@ async function render() {
|
|
|
3395
4143
|
else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
|
|
3396
4144
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
3397
4145
|
else if (r.name === 'setup') await viewSetup()
|
|
4146
|
+
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
3398
4147
|
else await viewBoard()
|
|
3399
4148
|
tabsAfterRender()
|
|
4149
|
+
// a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
|
|
4150
|
+
for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
|
|
3400
4151
|
} catch (e) {
|
|
3401
4152
|
if (e.code === 'login_required') return showLogin()
|
|
3402
4153
|
$app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
|
|
@@ -3407,12 +4158,20 @@ async function render() {
|
|
|
3407
4158
|
// it keeps its own step and fetches its own state.
|
|
3408
4159
|
async function viewSetup() {
|
|
3409
4160
|
if ($app.querySelector('.wz')) return
|
|
3410
|
-
await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/' } })
|
|
4161
|
+
await mountWizard($app, { api, esc, onDone: () => { location.hash = '#/'; autoTour() } })
|
|
3411
4162
|
}
|
|
3412
4163
|
// Opens #/setup by itself once, on a Mac's own board, when the board opens plain (no deep link).
|
|
4164
|
+
// Otherwise the product tour gets its turn.
|
|
3413
4165
|
async function firstRunSetup() {
|
|
3414
|
-
if (location.hash.replace(/^#\/?/, ''))
|
|
3415
|
-
|
|
4166
|
+
if (!location.hash.replace(/^#\/?/, '')) {
|
|
4167
|
+
try { const s = await api('GET', '/v1/setup'); if (shouldAutoOpen(s, (state.route || {}).name) && !location.hash.replace(/^#\/?/, '')) return location.replace('#/setup') } catch {}
|
|
4168
|
+
}
|
|
4169
|
+
autoTour()
|
|
4170
|
+
}
|
|
4171
|
+
// The product tour (0.9.8, ui/tour.js): by itself once per Mac, after setup, on
|
|
4172
|
+
// the board; #/tour (Settings → Updates & about, the palette) replays it.
|
|
4173
|
+
async function autoTour() {
|
|
4174
|
+
try { const t = await api('GET', '/v1/tour'); if (shouldAutoTour(t, parseRoute().name)) startTour({ api, auto: true }) } catch {}
|
|
3416
4175
|
}
|
|
3417
4176
|
|
|
3418
4177
|
// A remote browser (Tailscale) that hasn't signed in yet: the iPhone
|
|
@@ -3471,7 +4230,9 @@ async function refreshHealth() {
|
|
|
3471
4230
|
if (dot) dot.hidden = !(h.whatsNew && h.whatsNew.unseen > 0)
|
|
3472
4231
|
paintMoreDots()
|
|
3473
4232
|
paintSettingsDots()
|
|
4233
|
+
if (updateBar) updateBar.check(h)
|
|
3474
4234
|
} catch (e) {
|
|
4235
|
+
if (updateBar) updateBar.check(null) // a failed check changes nothing
|
|
3475
4236
|
el.className = 'health down'
|
|
3476
4237
|
el.querySelector('.label').textContent = e && e.code === 'login_required' ? 'not signed in' : 'daemon offline'
|
|
3477
4238
|
el.title = el.querySelector('.label').textContent
|
|
@@ -3720,6 +4481,15 @@ async function refreshTabSessions() {
|
|
|
3720
4481
|
paintTabs()
|
|
3721
4482
|
} catch {}
|
|
3722
4483
|
}
|
|
4484
|
+
// What had focus in the strip, to find it again after a repaint: the same control,
|
|
4485
|
+
// else (Keep open is gone once kept) the same tab's ×, else the tab itself.
|
|
4486
|
+
function tabFocusKey(el) {
|
|
4487
|
+
const d = el.dataset, q = (a, v) => '[' + a + '="' + CSS.escape(v) + '"]'
|
|
4488
|
+
if (d.tabClose) return [q('data-tab-close', d.tabClose), q('data-tab', d.tabClose)]
|
|
4489
|
+
if (d.tabKeep) return [q('data-tab-keep', d.tabKeep), q('data-tab-close', d.tabKeep), q('data-tab', d.tabKeep)]
|
|
4490
|
+
if (d.tab) return [q('data-tab', d.tab)]
|
|
4491
|
+
return el.id ? ['#' + CSS.escape(el.id)] : null
|
|
4492
|
+
}
|
|
3723
4493
|
function paintTabs() {
|
|
3724
4494
|
const views = tabViews()
|
|
3725
4495
|
const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
|
|
@@ -3727,10 +4497,10 @@ function paintTabs() {
|
|
|
3727
4497
|
const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
|
|
3728
4498
|
if (sh !== tabsUi.html.strip) {
|
|
3729
4499
|
const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
|
|
3730
|
-
const had = strip.contains(document.activeElement) ? (document.activeElement
|
|
4500
|
+
const had = strip.contains(document.activeElement) ? tabFocusKey(document.activeElement) : null
|
|
3731
4501
|
strip.innerHTML = sh; tabsUi.html.strip = sh
|
|
3732
4502
|
const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
|
|
3733
|
-
if (had) { const el =
|
|
4503
|
+
if (had) { const el = had.map((q) => strip.querySelector(q)).find(Boolean); if (el) el.focus({ preventScroll: true }) }
|
|
3734
4504
|
}
|
|
3735
4505
|
strip.hidden = false
|
|
3736
4506
|
const rh = openRowHtml(views, esc)
|
|
@@ -3798,6 +4568,32 @@ document.addEventListener('click', (e) => {
|
|
|
3798
4568
|
if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
|
|
3799
4569
|
})
|
|
3800
4570
|
document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
|
|
4571
|
+
// The strip's buttons name themselves in a styled tip under them (hover after a
|
|
4572
|
+
// beat, keyboard focus at once), not a native title. The strip scrolls sideways,
|
|
4573
|
+
// which clips anything hanging below it, so the tip is one fixed element on the page.
|
|
4574
|
+
{
|
|
4575
|
+
const tip = document.createElement('span'); tip.className = 'strip-tip'; tip.setAttribute('aria-hidden', 'true'); document.body.appendChild(tip)
|
|
4576
|
+
let cur = null, timer = 0
|
|
4577
|
+
const hide = () => { clearTimeout(timer); cur = null; tip.classList.remove('on') }
|
|
4578
|
+
const show = (el, delay) => {
|
|
4579
|
+
clearTimeout(timer); cur = el
|
|
4580
|
+
timer = setTimeout(() => {
|
|
4581
|
+
if (cur !== el || !el.isConnected) return hide()
|
|
4582
|
+
tip.textContent = el.dataset.tip
|
|
4583
|
+
const r = el.getBoundingClientRect(), w = tip.offsetWidth
|
|
4584
|
+
tip.style.left = Math.round(Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2))) + 'px'
|
|
4585
|
+
tip.style.top = Math.round(r.bottom + 4) + 'px'
|
|
4586
|
+
tip.classList.add('on')
|
|
4587
|
+
}, delay)
|
|
4588
|
+
}
|
|
4589
|
+
const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip]')
|
|
4590
|
+
document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
|
|
4591
|
+
document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
|
|
4592
|
+
document.addEventListener('focusin', (e) => { const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
|
|
4593
|
+
document.addEventListener('focusout', (e) => { if (tipped(e) === cur) hide() })
|
|
4594
|
+
for (const ev of ['pointerdown', 'scroll', 'hashchange', 'resize']) window.addEventListener(ev, hide, true)
|
|
4595
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) hide() })
|
|
4596
|
+
}
|
|
3801
4597
|
// Working in a session keeps its tab: a Live message, typing there, answering Claude there
|
|
3802
4598
|
const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
|
|
3803
4599
|
for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
|
|
@@ -3819,7 +4615,7 @@ document.addEventListener('click', (e) => { if (e.target.closest && $app.contain
|
|
|
3819
4615
|
sw = null
|
|
3820
4616
|
if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
|
|
3821
4617
|
const t = e.target
|
|
3822
|
-
if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .tabs, .moremenu') || scrollsSideways(t)) return
|
|
4618
|
+
if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .lightbox, .pdfview, .tabs, .moremenu') || scrollsSideways(t)) return
|
|
3823
4619
|
sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
|
|
3824
4620
|
}, { passive: true })
|
|
3825
4621
|
document.addEventListener('touchend', (e) => {
|
|
@@ -3884,7 +4680,7 @@ function keepPlace() {
|
|
|
3884
4680
|
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>'
|
|
3885
4681
|
document.body.appendChild(ptr)
|
|
3886
4682
|
const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
|
|
3887
|
-
const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
|
|
4683
|
+
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')
|
|
3888
4684
|
let pull = null, refreshing = false
|
|
3889
4685
|
const paint = (step) => {
|
|
3890
4686
|
ptr.hidden = !step || (!step.pulling && !refreshing)
|
|
@@ -3934,6 +4730,43 @@ function keepPlace() {
|
|
|
3934
4730
|
document.addEventListener('touchcancel', () => { pull = null; if (!refreshing) paint(null) }, { passive: true })
|
|
3935
4731
|
}
|
|
3936
4732
|
|
|
4733
|
+
// Hide the nav on scroll (phones, 0.9.8): under 700px the top row and the bottom
|
|
4734
|
+
// bar slide away while you scroll down and come back when you scroll up, reach
|
|
4735
|
+
// the top or the very bottom (ui/navhide.js). Only your own scrolling hides
|
|
4736
|
+
// them: a route change, the ‹ › pager or a swipe shows them and holds them until
|
|
4737
|
+
// you next touch, wheel or key; typing, a menu or a sheet keeps them shown. The
|
|
4738
|
+
// page never reflows: the top row stays in the flow and the bar is an overlay
|
|
4739
|
+
// over padding that stays.
|
|
4740
|
+
{
|
|
4741
|
+
const narrowQ = window.matchMedia('(max-width: 699.98px)')
|
|
4742
|
+
const top = document.querySelector('.top')
|
|
4743
|
+
let st = { hidden: false, anchor: 0 }, raf = 0, held = true
|
|
4744
|
+
const typing = () => { const a = document.activeElement; return !!(a && a !== document.body && a.matches && a.matches(NAVHIDE_TYPING)) }
|
|
4745
|
+
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])')
|
|
4746
|
+
const apply = (hidden) => {
|
|
4747
|
+
if (hidden && top) document.documentElement.style.setProperty('--top-h', top.offsetHeight + 'px')
|
|
4748
|
+
document.body.classList.toggle('nav-hidden', hidden)
|
|
4749
|
+
}
|
|
4750
|
+
const read = () => {
|
|
4751
|
+
raf = 0
|
|
4752
|
+
const se = document.scrollingElement || document.documentElement
|
|
4753
|
+
const y = window.scrollY || se.scrollTop || 0
|
|
4754
|
+
const next = navHideStep(st, { y, max: se.scrollHeight - window.innerHeight, blocked: held || !narrowQ.matches || typing() || overlay() })
|
|
4755
|
+
if (next.hidden !== st.hidden) apply(next.hidden)
|
|
4756
|
+
st = next
|
|
4757
|
+
}
|
|
4758
|
+
const soon = () => { if (!raf) raf = requestAnimationFrame(read) }
|
|
4759
|
+
// no layout read here: this runs on every route change, ahead of the page's own render
|
|
4760
|
+
const showNav = (hold = true) => { if (hold) held = true; const was = st.hidden; st = { hidden: false, anchor: NaN }; if (was) apply(false) }
|
|
4761
|
+
window.addEventListener('scroll', soon, { passive: true })
|
|
4762
|
+
for (const ev of ['touchstart', 'wheel', 'keydown']) window.addEventListener(ev, () => { held = false }, { passive: true, capture: true })
|
|
4763
|
+
window.addEventListener('hashchange', () => showNav(true))
|
|
4764
|
+
document.addEventListener('focusin', (e) => { if (typing() || (top && top.contains(e.target))) showNav(false) })
|
|
4765
|
+
document.addEventListener('click', soon, { passive: true, capture: true }) // a menu or sheet just opened: back they come
|
|
4766
|
+
narrowQ.addEventListener('change', () => showNav(false))
|
|
4767
|
+
window.leftoffNav = { show: () => showNav(true), hidden: () => document.body.classList.contains('nav-hidden') }
|
|
4768
|
+
}
|
|
4769
|
+
|
|
3937
4770
|
// Status popover: open sessions, this Mac, the summarizer, sync, the version, the theme.
|
|
3938
4771
|
const statusOpen = () => { const p = document.getElementById('statusPop'); return p && !p.hidden }
|
|
3939
4772
|
function syncLine() {
|
|
@@ -4067,6 +4900,7 @@ function paletteActions() {
|
|
|
4067
4900
|
}
|
|
4068
4901
|
if (projectsUi.mayEdit) out.push({ action: 'project-new', title: 'New project…', sub: 'a named group of repos', icon: 'plus' })
|
|
4069
4902
|
out.push({ action: 'gen-digest-go', title: 'Today’s digest', sub: 'open it', icon: 'newspaper', href: '#/digest' })
|
|
4903
|
+
out.push({ action: 'tour', title: 'Take the tour', sub: 'a quick look around the board', icon: 'sparkles', href: '#/tour' })
|
|
4070
4904
|
return out
|
|
4071
4905
|
}
|
|
4072
4906
|
function searchGroups(q, r) {
|
|
@@ -4216,11 +5050,27 @@ document.addEventListener('keydown', (e) => {
|
|
|
4216
5050
|
|
|
4217
5051
|
// A card opens its session's page from anywhere on it, except its buttons,
|
|
4218
5052
|
// links, fields and selected text (0.4.3: the timeline was too hidden).
|
|
5053
|
+
// 0.9.8: the tap shows at once (a pressed card with a spinner); the page's
|
|
5054
|
+
// shell follows in the same frame (viewSession).
|
|
5055
|
+
function cardOpening(c) {
|
|
5056
|
+
for (const x of document.querySelectorAll('.card.opening')) if (x !== c) x.classList.remove('opening')
|
|
5057
|
+
c.classList.add('opening')
|
|
5058
|
+
setTimeout(() => { if (c.isConnected) c.classList.remove('opening') }, 4000)
|
|
5059
|
+
}
|
|
5060
|
+
document.addEventListener('pointerdown', (ev) => {
|
|
5061
|
+
const c = ev.target.closest && ev.target.closest('.card[data-href]')
|
|
5062
|
+
if (!c || ev.button > 0 || ev.target.closest('button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
|
|
5063
|
+
const a = ev.target.closest('a')
|
|
5064
|
+
if (a && !/^#\/s\//.test(a.getAttribute('href') || '')) return // a repo chip, a PR link: not opening the session
|
|
5065
|
+
cardOpening(c)
|
|
5066
|
+
}, { passive: true })
|
|
5067
|
+
document.addEventListener('pointercancel', () => { for (const x of document.querySelectorAll('.card.opening')) x.classList.remove('opening') }, true)
|
|
4219
5068
|
document.addEventListener('click', (ev) => {
|
|
4220
5069
|
const c = ev.target.closest('.card[data-href]')
|
|
4221
5070
|
if (!c || ev.defaultPrevented || ev.target.closest('a, button, input, lo-select, lo-combo, textarea, label, summary, [data-action]')) return
|
|
4222
5071
|
if (String(window.getSelection && window.getSelection()).trim()) return
|
|
4223
5072
|
if (ev.metaKey || ev.ctrlKey) { window.open(c.dataset.href, '_blank'); return }
|
|
5073
|
+
cardOpening(c)
|
|
4224
5074
|
location.hash = c.dataset.href
|
|
4225
5075
|
})
|
|
4226
5076
|
|
|
@@ -4267,7 +5117,44 @@ document.addEventListener('click', async (ev) => {
|
|
|
4267
5117
|
const id = btn.dataset.id || (card && card.dataset.id)
|
|
4268
5118
|
const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
|
|
4269
5119
|
try {
|
|
4270
|
-
if (action === 'copy-cmd') {
|
|
5120
|
+
if (action === 'copy-cmd') {
|
|
5121
|
+
const ok = await copy(btn.dataset.cmd)
|
|
5122
|
+
if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
|
|
5123
|
+
clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.dataset.tip = 'Copied'
|
|
5124
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy Live resume'); btn.dataset.tip = 'Copy' }, 1500)
|
|
5125
|
+
}
|
|
5126
|
+
return
|
|
5127
|
+
}
|
|
5128
|
+
// 0.9.8 What it did
|
|
5129
|
+
if (action === 'wid-toggle') { const ui = widOf(id); ui.open = !ui.open; paintWid(id); if (ui.open && !ui.tools && !ui.diff && !ui.loading) loadWid(id); return }
|
|
5130
|
+
if (action === 'wid-refresh') return loadWid(id)
|
|
5131
|
+
if (action === 'wid-tool') {
|
|
5132
|
+
const ui = widOf(id), t = btn.dataset.tool
|
|
5133
|
+
ui.toolOpen[t] = !ui.toolOpen[t]; paintWid(id)
|
|
5134
|
+
// a command's output ends with what matters (a failure, the summary): open it at the end
|
|
5135
|
+
const v = (ui.tools || []).find((x) => x.id === t)
|
|
5136
|
+
const pre = ui.toolOpen[t] && v && (v.tool === 'Bash' || v.error) && document.querySelector('#wid-t-' + CSS.escape(t) + ' .wid-pre')
|
|
5137
|
+
if (pre) pre.scrollTop = pre.scrollHeight
|
|
5138
|
+
return
|
|
5139
|
+
}
|
|
5140
|
+
if (action === 'wid-tool-more') {
|
|
5141
|
+
const ui = widOf(id), t = btn.dataset.tool
|
|
5142
|
+
if (ui.toolLoading[t] === true) return
|
|
5143
|
+
ui.toolLoading[t] = true; paintWid(id)
|
|
5144
|
+
try { const r = await api('GET', widPath(id, 'tools') + '?tool=' + encodeURIComponent(t)); if (r.tool) ui.toolFull[t] = r.tool; delete ui.toolLoading[t] } catch { ui.toolLoading[t] = 'error' }
|
|
5145
|
+
return paintWid(id)
|
|
5146
|
+
}
|
|
5147
|
+
if (action === 'wid-file') { const ui = widOf(id), p = btn.dataset.path; ui.fileOpen[p] = !ui.fileOpen[p]; paintWid(id); if (ui.fileOpen[p] && !ui.files[p]) loadWidFile(id, p); return }
|
|
5148
|
+
if (action === 'wid-copy') {
|
|
5149
|
+
const pageId = parseRoute().arg // the page's session (the copy button carries no id)
|
|
5150
|
+
const ok = await copy(widCopyText(widOf(pageId), btn.dataset.kind, btn.dataset.key))
|
|
5151
|
+
if (ok) {
|
|
5152
|
+
const label = btn.getAttribute('aria-label')
|
|
5153
|
+
clearTimeout(btn._copied); btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.dataset.tip = 'Copied'
|
|
5154
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', label); btn.dataset.tip = 'Copy' }, 1500)
|
|
5155
|
+
}
|
|
5156
|
+
return
|
|
5157
|
+
}
|
|
4271
5158
|
// PRs (0.6.9)
|
|
4272
5159
|
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 }
|
|
4273
5160
|
// 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
|
|
@@ -4370,6 +5257,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
4370
5257
|
if (action === 'list-showall') { listUi.showAll = !listUi.showAll; return render() }
|
|
4371
5258
|
if (action === 'list-help') { listUi.help = !listUi.help; return render() }
|
|
4372
5259
|
if (action === 'list-help-go') { listUi.help = true; location.hash = '#/lists'; return }
|
|
5260
|
+
if (action === 'list-help-inline') { listUi.sHelp = !listUi.sHelp; render(); return }
|
|
4373
5261
|
if (action === 'remote-link' || action === 'remote-code') {
|
|
4374
5262
|
const l = await api('POST', '/v1/remote/link', action === 'remote-link' ? { full: true } : undefined)
|
|
4375
5263
|
if (l.needed === false) { toast('No sign-in needed (remoteRequireToken is off)'); return }
|
|
@@ -4534,7 +5422,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
4534
5422
|
if (action === 'tl-session') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: btn.dataset.session || null }); return }
|
|
4535
5423
|
if (action === 'tl-trigger') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, trigger: btn.dataset.trigger || null }); return }
|
|
4536
5424
|
if (action === 'tl-clear') { saveRepos(window.localStorage, []); location.hash = '#/timeline?repo='; return }
|
|
4537
|
-
if (action === 'clear-filters') { state.moreOpen = false; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: null, project: null }) }
|
|
5425
|
+
if (action === 'clear-filters') { state.moreOpen = false; state.boardSpace = null; saveSpace(null); setMachineFilter(''); return setBoardFilter({ repos: [], status: null, project: null }) }
|
|
5426
|
+
if (action === 'machine-all') { setMachineFilter(''); return render() }
|
|
4538
5427
|
// Projects (0.9.0): the filter chips, the chip on a card, and the menus
|
|
4539
5428
|
if (action === 'board-project' || action === 'project-filter') {
|
|
4540
5429
|
ev.preventDefault(); ev.stopPropagation()
|
|
@@ -4557,6 +5446,19 @@ document.addEventListener('click', async (ev) => {
|
|
|
4557
5446
|
saveSpace(state.digestSpace)
|
|
4558
5447
|
return paintDigestFilter()
|
|
4559
5448
|
}
|
|
5449
|
+
if (action === 'digest-cal-fold') {
|
|
5450
|
+
digestUi.calOpen = !digestUi.calOpen
|
|
5451
|
+
repaintCalendar()
|
|
5452
|
+
return
|
|
5453
|
+
}
|
|
5454
|
+
if (action === 'digest-month') {
|
|
5455
|
+
const back = btn.dataset.to < (digestUi.month || '')
|
|
5456
|
+
digestUi.month = btn.dataset.to
|
|
5457
|
+
digestUi.calOpen = true // another month: show all of it
|
|
5458
|
+
const nav = repaintCalendar()
|
|
5459
|
+
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
|
|
5460
|
+
return
|
|
5461
|
+
}
|
|
4560
5462
|
if (action === 'standup-copy') return standupCopy(btn)
|
|
4561
5463
|
if (action === 'standup-close') { const b = btn.closest('.standup-out'); if (b) b.remove(); digestUi.open = null; return }
|
|
4562
5464
|
if (action === 'space-quick' || action === 'space-repo-add' || action === 'space-unset') return spacesAction(action, btn)
|
|
@@ -4582,6 +5484,11 @@ document.addEventListener('click', async (ev) => {
|
|
|
4582
5484
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
4583
5485
|
if (action === 'reply-send') return replyClick(btn)
|
|
4584
5486
|
if (action === 'live-send') return liveSendClick(btn)
|
|
5487
|
+
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
|
|
5488
|
+
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
5489
|
+
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
5490
|
+
if (action === 'att-open') return openAttachment(btn)
|
|
5491
|
+
if (action === 'att-chip-open') return openChip(btn.dataset.lid)
|
|
4585
5492
|
if (action === 'live-jump') return liveJumpClick(btn)
|
|
4586
5493
|
if (action === 'live-earlier') return liveEarlierClick(btn)
|
|
4587
5494
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
@@ -4595,6 +5502,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
4595
5502
|
return render()
|
|
4596
5503
|
}
|
|
4597
5504
|
if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
|
|
5505
|
+
if (action === 'lv-tool-open') return openToolOutput(id, btn.dataset.tool, btn.dataset.label) // 0.9.8: a Live tool chip opens its output
|
|
4598
5506
|
if (action === 'doc-close') return closeDoc()
|
|
4599
5507
|
if (action === 'doc-dl') { ev.preventDefault(); return openDocMenu(btn) }
|
|
4600
5508
|
if (action === 'doc-copy' || action === 'doc-download' || action === 'doc-pdf') return docAction(action.slice(4))
|
|
@@ -4754,9 +5662,11 @@ async function replyClick(btn) {
|
|
|
4754
5662
|
const need = btn.closest('.need')
|
|
4755
5663
|
const ta = need && need.querySelector('.reply-text')
|
|
4756
5664
|
const text = ta ? ta.value.trim() : ''
|
|
4757
|
-
if (!text) { toast('Type a reply first'); return }
|
|
5665
|
+
if (!text) { toast('Type a reply first'); if (ta && !ta.disabled) ta.focus(); return }
|
|
4758
5666
|
if (text.startsWith('/')) { toast('Replies can’t start with “/”'); return }
|
|
4759
|
-
|
|
5667
|
+
if (btn.classList.contains('sending')) return
|
|
5668
|
+
// the composer's ↑ (0.9.8): it pulses while the reply goes, like Live's
|
|
5669
|
+
btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
|
|
4760
5670
|
try {
|
|
4761
5671
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
|
|
4762
5672
|
delete state.drafts['reply:' + need.dataset.id]
|
|
@@ -4879,6 +5789,46 @@ async function openDoc(sessionId, { path, plan } = {}) {
|
|
|
4879
5789
|
el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(e.message) + '</div>'
|
|
4880
5790
|
}
|
|
4881
5791
|
}
|
|
5792
|
+
// 0.9.8 What it did: one tool's output (up to 64 KB, redacted on its Mac) in
|
|
5793
|
+
// the file viewer, from a Live tool chip. Copy and Download as text.
|
|
5794
|
+
async function openToolOutput(sessionId, toolId, label) {
|
|
5795
|
+
if (!sessionId || !toolId) return
|
|
5796
|
+
const el = docViewerEl()
|
|
5797
|
+
const name = label || 'Tool output'
|
|
5798
|
+
el.querySelector('.dv-title').textContent = name
|
|
5799
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
5800
|
+
el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
|
|
5801
|
+
const dl = el.querySelector('.dv-dl'); dl.hidden = true
|
|
5802
|
+
docUi.r = null; docUi.name = name
|
|
5803
|
+
el.hidden = false
|
|
5804
|
+
document.documentElement.classList.add('dv-open')
|
|
5805
|
+
try {
|
|
5806
|
+
const r = await api('GET', '/v1/sessions/' + encodeURIComponent(sessionId) + '/tools?tool=' + encodeURIComponent(toolId))
|
|
5807
|
+
if (el.hidden) return
|
|
5808
|
+
const t = r.tool || {}
|
|
5809
|
+
el.querySelector('.dv-title').textContent = t.label || name
|
|
5810
|
+
el.querySelector('.dv-sub').textContent = [t.what && t.what !== t.label ? t.what : '', t.error ? 'failed' : '', t.clipped ? 'cut to the first and last parts' : '', r.peer ? 'from ' + r.peer : ''].filter(Boolean).join(' · ')
|
|
5811
|
+
const note = t.running ? 'Still running; no output yet.' : t.hidden ? widHidden(t.hidden) : !t.output && !t.patch ? 'No output.' : ''
|
|
5812
|
+
const body = el.querySelector('.dv-body')
|
|
5813
|
+
// an edit shows what it changed, drawn like a diff (DOM, textContent only)
|
|
5814
|
+
if (!note && t.patch) { body.textContent = ''; const box = document.createElement('div'); box.className = 'gd-doc'; box.appendChild(renderDiff(document, t.patch, { label: 'Changes', numbers: t.patchAt != null })); body.appendChild(box) }
|
|
5815
|
+
else body.innerHTML = note ? '<div class="empty">' + esc(note) + '</div>' : '<pre class="doc-pre">' + esc(t.output) + '</pre>'
|
|
5816
|
+
const fname = t.patch ? 'changes.diff' : 'output.txt'
|
|
5817
|
+
el.querySelector('.dv-ptitle').textContent = fname
|
|
5818
|
+
if (!note) {
|
|
5819
|
+
const doc = { type: 'text', mime: 'text/plain', name: fname, text: t.patch || t.output }
|
|
5820
|
+
docUi.r = doc; docUi.name = fname
|
|
5821
|
+
const items = docMenuItems(doc)
|
|
5822
|
+
dl.hidden = false
|
|
5823
|
+
dl.setAttribute('aria-haspopup', items.length > 1 ? 'menu' : 'false')
|
|
5824
|
+
dl.querySelector('.chev').hidden = items.length <= 1
|
|
5825
|
+
dl.removeAttribute('title')
|
|
5826
|
+
dl.setAttribute('aria-label', 'Copy or download')
|
|
5827
|
+
}
|
|
5828
|
+
} catch (e) {
|
|
5829
|
+
el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(widError(e)) + '</div>'
|
|
5830
|
+
}
|
|
5831
|
+
}
|
|
4882
5832
|
function openDocMenu(btn) {
|
|
4883
5833
|
const r = docUi.r
|
|
4884
5834
|
const items = docMenuItems(r)
|
|
@@ -5069,16 +6019,21 @@ let stream = null
|
|
|
5069
6019
|
function connect() {
|
|
5070
6020
|
if (stream) { try { stream.close() } catch {} }
|
|
5071
6021
|
const es = stream = new EventSource('/v1/stream')
|
|
6022
|
+
const again = connect.opened // a reconnect: events may have been missed while it was down
|
|
6023
|
+
streamDog.opened()
|
|
6024
|
+
// 0.9.8: every event and the daemon's ping say the stream is alive (ui/live-wake.js)
|
|
6025
|
+
es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
|
|
5072
6026
|
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
5073
6027
|
es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
|
|
5074
|
-
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)
|
|
6028
|
+
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() })
|
|
5075
6029
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
5076
6030
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
5077
6031
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
|
5078
6032
|
// A daemon that restarted (an upgrade, a folder move) comes back on the same
|
|
5079
6033
|
// port; if the browser gave up on the stream, open a new one.
|
|
5080
6034
|
es.onerror = () => { document.getElementById('health').className = 'health down'; if (es.readyState === 2 && stream === es) setTimeout(() => { if (stream === es) connect() }, 3000) }
|
|
5081
|
-
|
|
6035
|
+
let opens = 0
|
|
6036
|
+
es.onopen = () => { if (stream !== es) return; streamDog.seen(); refreshHealth(); if (again || opens++) refreshView('reconnect'); connect.opened = true }
|
|
5082
6037
|
}
|
|
5083
6038
|
|
|
5084
6039
|
document.addEventListener('change', async (ev) => {
|
|
@@ -5139,7 +6094,7 @@ document.addEventListener('change', async (ev) => {
|
|
|
5139
6094
|
return cuLoadOptions().then(render)
|
|
5140
6095
|
}
|
|
5141
6096
|
if (ev.target.id === 'tlSession') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: ev.target.value || null }); return }
|
|
5142
|
-
if (ev.target.id === 'machineFilter') {
|
|
6097
|
+
if (ev.target.id === 'machineFilter') { setMachineFilter(ev.target.value); render() }
|
|
5143
6098
|
})
|
|
5144
6099
|
|
|
5145
6100
|
// Moving between Settings sections only swaps the section shown (v0.5.9): a
|
|
@@ -5181,7 +6136,46 @@ document.addEventListener('pointerdown', (ev) => {
|
|
|
5181
6136
|
if (m && !m.contains(ev.target)) m.open = false
|
|
5182
6137
|
})
|
|
5183
6138
|
api('GET', '/v1/config').then((c) => (state.cfg = c)).catch(() => {})
|
|
6139
|
+
// 0.9.8: "Leftoff updated · Refresh" when the Mac serving this board has newer
|
|
6140
|
+
// files than the ones this page runs. It never reloads by itself; Refresh
|
|
6141
|
+
// carries your drafts over and says first what wouldn't come along.
|
|
6142
|
+
updateBar = mountUpdateBar({
|
|
6143
|
+
icon,
|
|
6144
|
+
health: () => refreshHealth(),
|
|
6145
|
+
risks: () => {
|
|
6146
|
+
let attachments = 0, uploading = 0
|
|
6147
|
+
for (const list of attachUi.by.values()) for (const a of list) { attachments++; if (a.status === 'prep' || a.status === 'up') uploading++ }
|
|
6148
|
+
const f = document.activeElement
|
|
6149
|
+
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())
|
|
6150
|
+
let unsavedSettings = false
|
|
6151
|
+
try { unsavedSettings = settingsChanged().length > 0 } catch {}
|
|
6152
|
+
return { attachments, uploading, unsavedSettings, typing }
|
|
6153
|
+
},
|
|
6154
|
+
carry: () => {
|
|
6155
|
+
const drafts = { ...state.drafts }
|
|
6156
|
+
for (const t of document.querySelectorAll('textarea[data-draft]')) if (t.value) drafts[t.dataset.draft] = t.value
|
|
6157
|
+
const f = document.activeElement
|
|
6158
|
+
const focus = f && f.dataset && f.dataset.draft ? { key: f.dataset.draft, a: f.selectionStart || 0, b: f.selectionEnd || 0 } : lastDraftFocus
|
|
6159
|
+
return { drafts, focus }
|
|
6160
|
+
},
|
|
6161
|
+
})
|
|
6162
|
+
// The box you were typing in, if you tabbed from it to the bar.
|
|
6163
|
+
let lastDraftFocus = null
|
|
6164
|
+
document.addEventListener('focusin', (ev) => { if (!(ev.target.closest && ev.target.closest('#updateBar'))) lastDraftFocus = null })
|
|
6165
|
+
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 } })
|
|
6166
|
+
// Back from Refresh: the box you were typing in has its text, and (with a
|
|
6167
|
+
// keyboard and mouse) the cursor where it was.
|
|
6168
|
+
function restoreCarriedFocus(tries = 50) {
|
|
6169
|
+
const f = carried && carried.focus
|
|
6170
|
+
if (!f || isTouchUi(window.matchMedia)) return
|
|
6171
|
+
if (document.activeElement && document.activeElement !== document.body) return // you've moved on
|
|
6172
|
+
const t = [...document.querySelectorAll('textarea[data-draft]')].find((x) => x.dataset.draft === f.key)
|
|
6173
|
+
if (!t || t.disabled) { if (tries > 0) setTimeout(() => restoreCarriedFocus(tries - 1), 100); return } // the session page draws after its data
|
|
6174
|
+
t.focus({ preventScroll: true })
|
|
6175
|
+
try { const n = t.value.length; t.setSelectionRange(Math.min(f.a, n), Math.min(f.b, n)) } catch {}
|
|
6176
|
+
}
|
|
5184
6177
|
refreshHealth().then(render).then(firstRunSetup)
|
|
6178
|
+
restoreCarriedFocus()
|
|
5185
6179
|
connect()
|
|
5186
6180
|
|
|
5187
6181
|
// The home-screen app's service worker (v0.4.6): it only serves an offline page
|