@leftoff/cli 0.9.8 → 0.9.9-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/bin/leftoff.js +411 -345
- package/docs/SECURITY.md +15 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +48 -5
- package/mod/hooks/register.js +21 -8
- package/package.json +1 -1
- package/ui/app.css +287 -5
- package/ui/app.js +461 -53
- package/ui/composer.js +51 -1
- package/ui/facts.js +34 -0
- package/ui/icons.js +2 -0
- package/ui/index.html +1 -0
- package/ui/live-status.js +13 -1
- package/ui/live-view.js +164 -9
- package/ui/live-wake.js +1 -1
- package/ui/needs.js +181 -13
- package/ui/start.js +367 -0
package/ui/app.js
CHANGED
|
@@ -15,18 +15,20 @@ import { canStartPull, pullStep, pullRelease } from './pull.js'
|
|
|
15
15
|
import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
16
16
|
import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
|
|
17
17
|
import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
|
|
18
|
-
import { isBlank, composerKey, isTouchUi, composerHeight } from './composer.js' // 0.9.8 the Live composer
|
|
18
|
+
import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml, keepFocusAfterSend, letGoOfKeyboard } from './composer.js' // 0.9.8 the Live composer
|
|
19
|
+
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, modePillHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved } from './start.js' // 0.9.9 New session
|
|
19
20
|
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
20
21
|
import { wireTips } from './tips.js'
|
|
22
|
+
import { factHtml } from './facts.js' // 0.9.9 the session page's info cards
|
|
21
23
|
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
22
|
-
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
24
|
+
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
|
|
23
25
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
24
26
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
25
27
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
26
28
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
27
29
|
import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
|
|
28
30
|
import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
|
|
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
|
|
31
|
+
import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking, liveBoxKind, liveThreadWanted } from './live-view.js' // 0.9.8 Live link polish
|
|
30
32
|
import { statusView, elapsedText } from './live-status.js'
|
|
31
33
|
import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
|
|
32
34
|
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
@@ -426,15 +428,15 @@ function goButton(s, { page = false } = {}) {
|
|
|
426
428
|
if (!j.peer) return ''
|
|
427
429
|
// 0.9.2: "Go ↗" like a local card (a phone cut "Go on <machine>" short); the machine is in its title and label
|
|
428
430
|
const label = 'Go ↗', on = 'Go on ' + s.machine
|
|
429
|
-
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '"
|
|
430
|
-
if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '"
|
|
431
|
+
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>'
|
|
432
|
+
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>'
|
|
431
433
|
const st = goState.get(s.id)
|
|
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) + '"
|
|
434
|
+
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>'
|
|
433
435
|
}
|
|
434
|
-
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled
|
|
435
|
-
if (!j.supported) return '<button type="button" class="btn go" disabled
|
|
436
|
+
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>'
|
|
437
|
+
if (!j.supported) return '<button type="button" class="btn go" disabled data-tip="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
|
|
436
438
|
const st = goState.get(s.id)
|
|
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 ↗"
|
|
439
|
+
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>'
|
|
438
440
|
}
|
|
439
441
|
|
|
440
442
|
// The short reason a cross-machine button is grey (the full one is its tooltip).
|
|
@@ -796,6 +798,7 @@ async function viewBoard() {
|
|
|
796
798
|
state.ghostty = viewer ? viewer.ghostty === true : false
|
|
797
799
|
state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
|
|
798
800
|
try { state.restore = await api('GET', '/v1/restore') } catch { state.restore = null }
|
|
801
|
+
try { state.starts = await api('GET', '/v1/starts') } catch { state.starts = null } // 0.9.9: starts waiting for a yes here
|
|
799
802
|
const today = new Date(); today.setHours(0, 0, 0, 0)
|
|
800
803
|
const recent = sessions.filter((s) => !(s.status === 'ended' && Date.parse(s.endedAt || 0) < today.getTime()))
|
|
801
804
|
// v0.4.8: sessions with nothing in them yet (no prompt, name or checkpoint,
|
|
@@ -835,10 +838,12 @@ async function viewBoard() {
|
|
|
835
838
|
let html = '<div class="pagehead"><div><h1>Sessions</h1><div class="sub">Every Claude Code session on ' + (multi() ? 'your machines' : 'this machine') + (agentNames().length ? ', plus ' + esc(agentNames().join(' and ')) : '') + ', grouped by what needs you.</div>' + syncChip() + '</div>' +
|
|
836
839
|
'<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
|
|
837
840
|
options: [['', 'All machines'], ...state.health.machines.map((m) => [m, mEmoji(m) + ' ' + m + thisOneSuffix(m, state.health)]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
|
|
841
|
+
'<a class="btn start-new" href="#/new">' + icon('plus') + 'New session</a>' +
|
|
838
842
|
'<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
|
|
839
843
|
'<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
|
|
840
844
|
html += syncDetails() + staleLine(sessions)
|
|
841
|
-
html +=
|
|
845
|
+
html += startPairAskHtml(state.starts, { esc, local: startViewerLocal(state.starts && state.starts.viewer), busy: startUi.pairBusy }) // 0.9.9: asked once, at pairing time
|
|
846
|
+
html += restoreAsksBanner() + startAsksBanner(state.starts, { esc, icon, local: startViewerLocal(state.starts && state.starts.viewer), clock, busy: startUi.busy }) + restoreBanner()
|
|
842
847
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
843
848
|
const needs = needsItems(sessions)
|
|
844
849
|
html += needsStrip(needs, needOpts())
|
|
@@ -942,6 +947,7 @@ function boardSearchWire() {
|
|
|
942
947
|
try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
|
|
943
948
|
state.route = parseRoute()
|
|
944
949
|
await viewBoard() // the box isn't rebuilt, so the caret stays wherever you've typed to since
|
|
950
|
+
fillStartTexts($app, startTexts(state.starts, {}))
|
|
945
951
|
}, 160)
|
|
946
952
|
})
|
|
947
953
|
q.addEventListener('keydown', (ev) => {
|
|
@@ -1264,7 +1270,7 @@ function paintNeedsCount(n) {
|
|
|
1264
1270
|
|
|
1265
1271
|
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
|
|
1266
1272
|
function needOpts(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↵', ...extra }
|
|
1273
|
+
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 }
|
|
1268
1274
|
}
|
|
1269
1275
|
|
|
1270
1276
|
// ⌘↵ (Ctrl+↵ off a Mac) in a Needs-you text box (the Deny note, a question's
|
|
@@ -1392,8 +1398,9 @@ function paintSession(s, data) {
|
|
|
1392
1398
|
liveKeeper.before($app)
|
|
1393
1399
|
$app.innerHTML = sessionPageHtml(s, data)
|
|
1394
1400
|
liveAfterRender(s.id)
|
|
1401
|
+
fitAskDetail($app)
|
|
1395
1402
|
if (kf) {
|
|
1396
|
-
const nt = $app.querySelector('.composer-input')
|
|
1403
|
+
const nt = $app.querySelector('.composer-input[data-draft="' + CSS.escape(kf.d || '') + '"]')
|
|
1397
1404
|
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
1405
|
} else if (kb && !$app.contains(document.activeElement)) {
|
|
1399
1406
|
const hits = $app.querySelectorAll(kb)
|
|
@@ -1489,12 +1496,14 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
1489
1496
|
// 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
|
|
1490
1497
|
'<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>'
|
|
1491
1498
|
html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
|
|
1499
|
+
html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false, busy: modeBusy.get(s.id) || null, local: state.viewerLocal !== false }) // 0.9.9: started from the board: Stop / Resume, Permissions
|
|
1500
|
+
if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
|
|
1492
1501
|
html += '<div class="facts">' +
|
|
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)) +
|
|
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) +
|
|
1495
|
-
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
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>'
|
|
1497
|
-
if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
|
|
1502
|
+
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : ''), '', { copy: (s.repo && s.repo.root) || null, what: 'repo folder' }) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap', { copy: s.worktree.path || null, what: 'worktree folder' }) : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—', '', { copy: s.cwd || null, clip: 34 }) + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
1503
|
+
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, '', { copy: s.id, what: 'session id' }) +
|
|
1504
|
+
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown', '', { copy: s.live === 'open' && s.liveVia === 'pid' && s.pid != null ? String(s.pid) : null, what: 'process id' }) +
|
|
1505
|
+
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 || ''), '', { copy: s.shortId ? 'cp:' + s.shortId : null, what: 'terminal tab id' }) + '</div>'
|
|
1506
|
+
if (isLive(s)) html += (s.needsYou && !askDocked(s) ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV) // 0.9.9: a permission ask or question docks in Live, in the composer's place
|
|
1498
1507
|
html += parkRow(s)
|
|
1499
1508
|
html += spacePicker(s, spv, 'session', s.id)
|
|
1500
1509
|
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
|
|
@@ -1504,7 +1513,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
1504
1513
|
// 0.9.8: the last tools it ran and its folder's changes, folded; loads when you open it
|
|
1505
1514
|
html += '<section class="wid" id="what-it-did">' + whatItDidSection(s, widOf(s.id)) + '</section>'
|
|
1506
1515
|
// 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
|
|
1507
|
-
if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
|
|
1516
|
+
if (s.needsYou && !isLive(s) && !askDocked(s)) html += sessionNeedsStrip(s)
|
|
1508
1517
|
if (!isLive(s)) html += liveBoxHtml(s, liveV)
|
|
1509
1518
|
if (s.statusChanges && s.statusChanges.length) {
|
|
1510
1519
|
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>'
|
|
@@ -1539,6 +1548,16 @@ function sessionNeedsStrip(s) { return '<section class="needs-strip one" aria-la
|
|
|
1539
1548
|
// 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
|
|
1540
1549
|
// bottom); Claude's answer is the session's own (markdown, like the terminal).
|
|
1541
1550
|
const liveKeeper = createLiveKeeper()
|
|
1551
|
+
// A long command folds to three lines with "Show all"; where it fits anyway (a wide window), no button.
|
|
1552
|
+
function fitAskDetail(root) {
|
|
1553
|
+
for (const c of root.querySelectorAll('.ask-dock .ask-detail.long:not(.open)')) {
|
|
1554
|
+
if (c.scrollHeight > c.clientHeight + 2) continue
|
|
1555
|
+
c.classList.remove('long')
|
|
1556
|
+
const b = c.parentElement && c.parentElement.querySelector('.ask-more'); if (b) b.hidden = true
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
// 0.9.9 the docked ask: which ones show their whole command, and which you chose to reply to instead (this page only)
|
|
1560
|
+
const askUi = { open: new Set(), reply: new Set(), groups: new Set() } // groups: the folded ask rows you opened (closed again on a fresh page)
|
|
1542
1561
|
const liveSeenKey = (id) => 'lo-live-seen:' + id
|
|
1543
1562
|
function liveSeen(id) { try { return localStorage.getItem(liveSeenKey(id)) } catch { return null } }
|
|
1544
1563
|
function markLiveSeen(id, at) { try { localStorage.setItem(liveSeenKey(id), at || new Date().toISOString()) } catch {} }
|
|
@@ -1554,7 +1573,8 @@ async function liveHow() {
|
|
|
1554
1573
|
return state.liveHow
|
|
1555
1574
|
}
|
|
1556
1575
|
// Does this session's page show a Live thread (and so fetch one)?
|
|
1557
|
-
|
|
1576
|
+
// (0.9.9: a session Leftoff started reports as "hooks" but always has its thread: before, its page never fetched it and read "Nothing sent yet")
|
|
1577
|
+
const liveWanted = (s) => liveThreadWanted(s)
|
|
1558
1578
|
// GET the Live thread; what it says is the newest the page knows (0.9.8: the
|
|
1559
1579
|
// time of the last good answer decides "Reconnecting…" on a pending bubble).
|
|
1560
1580
|
async function fetchLive(id, { timeoutMs } = {}) {
|
|
@@ -1566,9 +1586,9 @@ async function fetchLive(id, { timeoutMs } = {}) {
|
|
|
1566
1586
|
} catch (e) { return { messages: [], error: e.message } }
|
|
1567
1587
|
}
|
|
1568
1588
|
function liveBoxHtml(s, v) {
|
|
1569
|
-
|
|
1570
|
-
if (
|
|
1571
|
-
|
|
1589
|
+
const kind = liveBoxKind(s) // 0.9.9: one Leftoff started talks Live whatever reports it
|
|
1590
|
+
if (kind === 'none') return ''
|
|
1591
|
+
if (kind === 'how') {
|
|
1572
1592
|
const how = state.liveHow
|
|
1573
1593
|
return '<section class="live-box off" aria-label="Live link"><div class="live-head"><h2>Live <span class="pill soon">Beta</span></h2></div>' +
|
|
1574
1594
|
'<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>' +
|
|
@@ -1581,33 +1601,37 @@ function liveBoxHtml(s, v) {
|
|
|
1581
1601
|
const now = Date.now()
|
|
1582
1602
|
const lastClaude = [...msgs].reverse().find((m) => m.from === 'claude')
|
|
1583
1603
|
if (lastClaude) markLiveSeen(s.id, lastClaude.at)
|
|
1584
|
-
|
|
1604
|
+
// a session Leftoff started that isn't running now: what was said, read-only (Resume here sends again)
|
|
1605
|
+
if (kind === 'readonly') {
|
|
1606
|
+
if (!msgs.length) return ''
|
|
1607
|
+
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + modePillHtml(s, { esc }) + '<span class="faint">Not running. Resume it to send more.</span></div>' +
|
|
1608
|
+
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-label="Messages with this session">' +
|
|
1609
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id), inserts: askRows(s), openGroups: askUi.groups, earlier: (v && v.earlier) || 0, may: false }) + '</ol>' +
|
|
1610
|
+
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div></section>'
|
|
1611
|
+
}
|
|
1612
|
+
// 0.9.9: a waiting permission ask (or question) takes the composer's place; "Reply instead" brings the composer back for that ask
|
|
1613
|
+
const rows = askRows(s)
|
|
1614
|
+
const bottom = askBottom(s, askUi.reply)
|
|
1615
|
+
const dock = bottom === 'dock', docked = bottom !== 'composer'
|
|
1616
|
+
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + modePillHtml(s, { esc }) + '<span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
|
|
1585
1617
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1586
|
-
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
1587
|
-
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
|
|
1618
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id), inserts: rows, openGroups: askUi.groups, earlier: (v && v.earlier) || 0, may }) + '</ol>' +
|
|
1619
|
+
'<p class="faint live-empty"' + (msgs.length || rows.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
|
|
1588
1620
|
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
|
|
1589
1621
|
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
1590
|
-
composerHtml(s, draft, may) +
|
|
1622
|
+
(dock ? '<div class="ask-dock-wrap">' + askDockHtml({ ...s.needsYou, session: s }, needOpts({ askOpen: askUi.open })) + '</div>' : (docked ? askSlimHtml(s.needsYou) : '') + composerHtml(s, draft, may)) +
|
|
1591
1623
|
(may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.</p>') + '</section>'
|
|
1592
1624
|
}
|
|
1593
1625
|
// The Live composer: one rounded box, a textarea that starts at one line and
|
|
1594
1626
|
// grows, and a round ↑ in its corner (dim until there's something to send).
|
|
1595
1627
|
// No interrupt reaches Claude Code from the board, so while it works the
|
|
1596
1628
|
// button still sends: the message waits for its next turn, as before.
|
|
1597
|
-
const SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
|
|
1598
|
-
const CLIP_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
|
|
1599
1629
|
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>'
|
|
1600
1630
|
function composerHtml(s, draft, may) {
|
|
1601
1631
|
const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
'<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
|
|
1606
|
-
'<textarea class="composer-input live-text" rows="1" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" autocomplete="off" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd) + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
|
|
1607
|
-
// the phone's own picker (Photo Library, Take Photo, Choose File): any file
|
|
1608
|
-
'<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
|
|
1609
|
-
'<button type="button" class="composer-attach" data-action="attach" data-id="' + esc(s.id) + '" aria-label="Attach" title="Attach photos or files"' + (may ? '' : ' disabled') + '><span class="composer-clip">' + CLIP_IC + '</span></button>' +
|
|
1610
|
-
'<button type="button" class="composer-send" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '" aria-label="Send" title="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
|
|
1632
|
+
return composerBoxHtml({ sid: s.id, submit: 'live', sendAction: 'live-send', draftKey: 'live:' + s.id, draft, may,
|
|
1633
|
+
placeholder: 'Message this session…', label: 'Message this session', boxClass: 'live-row', inputClass: 'live-text',
|
|
1634
|
+
attach: true, atts: attachList(s.id), chipHtml, keys: isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd })
|
|
1611
1635
|
}
|
|
1612
1636
|
|
|
1613
1637
|
// ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
|
|
@@ -1895,6 +1919,7 @@ function syncComposer(ta) {
|
|
|
1895
1919
|
}
|
|
1896
1920
|
document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
|
|
1897
1921
|
// Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
|
|
1922
|
+
// The same for Live and the Needs-you Reply composer: the key presses its own box's ↑.
|
|
1898
1923
|
document.addEventListener('keydown', (ev) => {
|
|
1899
1924
|
const ta = ev.target
|
|
1900
1925
|
if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
|
|
@@ -1903,8 +1928,8 @@ document.addEventListener('keydown', (ev) => {
|
|
|
1903
1928
|
ev.preventDefault()
|
|
1904
1929
|
const cb = ta.closest('.composer')
|
|
1905
1930
|
if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
|
|
1906
|
-
const box = ta.closest('.live-box')
|
|
1907
|
-
const btn = box && box.querySelector('[data-submit-for="live"]')
|
|
1931
|
+
const box = ta.closest('.live-box') || ta.closest('.need') || ta.closest('.start-box')
|
|
1932
|
+
const btn = box && box.querySelector('[data-submit-for="' + CSS.escape(ta.dataset.submit || 'live') + '"]')
|
|
1908
1933
|
if (!btn || btn.disabled) return
|
|
1909
1934
|
const last = Number(box.dataset.kbdAt || 0)
|
|
1910
1935
|
if (Date.now() - last < 1500) return
|
|
@@ -2092,6 +2117,58 @@ function onWake(ev) {
|
|
|
2092
2117
|
}
|
|
2093
2118
|
for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
|
|
2094
2119
|
document.addEventListener('visibilitychange', onWake)
|
|
2120
|
+
// 0.9.9 Cancel / Edit on a Queued message: the daemon takes it back from
|
|
2121
|
+
// Claude Code's queue (only while it still waits; too late says so plainly).
|
|
2122
|
+
// Edit then puts its words (and files) back in the box, after anything typed.
|
|
2123
|
+
// 0.9.9 Send now on a Queued message: Claude stops its current step and this
|
|
2124
|
+
// one runs next (ahead of any queued before it). The daemon says what Claude
|
|
2125
|
+
// Code did; too late (Claude already has it) is a plain message.
|
|
2126
|
+
async function sendNowClick(btn) {
|
|
2127
|
+
const box = btn.closest('.live-box')
|
|
2128
|
+
const sid = box && box.dataset.id
|
|
2129
|
+
const msg = btn.dataset.msg
|
|
2130
|
+
if (!sid || !msg || btn.disabled) return
|
|
2131
|
+
const li = btn.closest('.lv')
|
|
2132
|
+
if (li) li.querySelectorAll('.lv-q-btn').forEach((b) => { b.disabled = true })
|
|
2133
|
+
btn.setAttribute('aria-busy', 'true')
|
|
2134
|
+
try {
|
|
2135
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live/' + encodeURIComponent(msg) + '/sendnow', {})
|
|
2136
|
+
toast(r.result === 'stopped' ? 'Sent now · Claude stopped its step for it' : 'Sending now', 3000)
|
|
2137
|
+
} catch (e) {
|
|
2138
|
+
toast(e.message, 4200)
|
|
2139
|
+
}
|
|
2140
|
+
await refreshLive('sendnow')
|
|
2141
|
+
await render()
|
|
2142
|
+
}
|
|
2143
|
+
async function queuedClick(btn, edit) {
|
|
2144
|
+
const box = btn.closest('.live-box')
|
|
2145
|
+
const sid = box && box.dataset.id
|
|
2146
|
+
const msg = btn.dataset.msg
|
|
2147
|
+
if (!sid || !msg || btn.disabled) return
|
|
2148
|
+
const li = btn.closest('.lv')
|
|
2149
|
+
if (li) li.querySelectorAll('.lv-q-btn').forEach((b) => { b.disabled = true })
|
|
2150
|
+
btn.setAttribute('aria-busy', 'true')
|
|
2151
|
+
try {
|
|
2152
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live/' + encodeURIComponent(msg) + '/cancel', {})
|
|
2153
|
+
const s = state.sessions.find((x) => x.id === sid)
|
|
2154
|
+
if (edit) {
|
|
2155
|
+
const typed = ((state.drafts && state.drafts['live:' + sid]) || '').trim()
|
|
2156
|
+
state.drafts['live:' + sid] = typed ? typed + '\n\n' + (r.text || '') : (r.text || '')
|
|
2157
|
+
const atts = (r.attachments || []).filter((a) => a && a.id)
|
|
2158
|
+
if (atts.length && !(s && s.isLocal === false)) attachUi.by.set(sid, [...attachList(sid), ...atts.map((a) => ({ lid: 'a' + (++attachUi.seq), sid, file: null, name: a.name || 'file', size: a.size || 0, type: a.type || '', kind: a.kind || 'file', status: 'done', sent: a.size || 0, total: a.size || 0, id: a.id, preview: null, thumb: null, error: null }))])
|
|
2159
|
+
else if (atts.length) toast('Its files didn’t come back: attach them again.', 4200)
|
|
2160
|
+
}
|
|
2161
|
+
toast(edit ? 'Taken back · edit it and send again' : 'Cancelled · Claude never saw it', 3000)
|
|
2162
|
+
await refreshLive('cancel')
|
|
2163
|
+
await render()
|
|
2164
|
+
if (edit) { const ta = document.querySelector('.live-box .composer-input'); if (ta && !ta.disabled) { ta.value = state.drafts['live:' + sid] || ''; fitComposer(ta); ta.focus({ preventScroll: true }) } }
|
|
2165
|
+
} catch (e) {
|
|
2166
|
+
toast(e.message, 4200)
|
|
2167
|
+
await refreshLive('cancel')
|
|
2168
|
+
await render()
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2095
2172
|
async function liveSendClick(btn) {
|
|
2096
2173
|
const box = btn.closest('.live-box')
|
|
2097
2174
|
const ta = box && box.querySelector('.live-text')
|
|
@@ -2100,6 +2177,7 @@ async function liveSendClick(btn) {
|
|
|
2100
2177
|
if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
|
|
2101
2178
|
if (btn.disabled || btn.classList.contains('sending')) return
|
|
2102
2179
|
const hadFocus = !!(ta && document.activeElement === ta)
|
|
2180
|
+
let sent = false // 0.9.9: went through: a phone's keyboard closes
|
|
2103
2181
|
btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
|
|
2104
2182
|
try {
|
|
2105
2183
|
// 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
|
|
@@ -2113,6 +2191,7 @@ async function liveSendClick(btn) {
|
|
|
2113
2191
|
if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
|
|
2114
2192
|
if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
|
|
2115
2193
|
if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
|
|
2194
|
+
else sent = true
|
|
2116
2195
|
const ol = box && box.querySelector('.live-log')
|
|
2117
2196
|
if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
|
|
2118
2197
|
setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
|
|
@@ -2121,7 +2200,11 @@ async function liveSendClick(btn) {
|
|
|
2121
2200
|
const nb = document.querySelector('.live-box [data-action="live-send"]')
|
|
2122
2201
|
if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
|
|
2123
2202
|
const nt = document.querySelector('.live-box .composer-input')
|
|
2124
|
-
if (nt) {
|
|
2203
|
+
if (nt) {
|
|
2204
|
+
fitComposer(nt)
|
|
2205
|
+
if (!keepFocusAfterSend({ ok: sent, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document)
|
|
2206
|
+
else if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true })
|
|
2207
|
+
}
|
|
2125
2208
|
}
|
|
2126
2209
|
}
|
|
2127
2210
|
|
|
@@ -2230,7 +2313,8 @@ function spacePicker(s, spv, kind, key) {
|
|
|
2230
2313
|
options: [['', own ? 'Default (repo or Mac)' : s.space + ' · ' + (SPACE_WHY[s.spaceSource] || 'default')], ...opts.map((x) => [x, x]), ['__new', 'New space…']] }) +
|
|
2231
2314
|
'<span class="faint">' + (kind === 'agent' ? 'For everything from this agent’s feed.' : 'Just this session; repos and Macs are set in <a href="#/settings/spaces">Settings → Spaces</a>.') + '</span></div>'
|
|
2232
2315
|
}
|
|
2233
|
-
|
|
2316
|
+
// (0.9.9: ui/facts.js; an id or a path copies on tap, its whole value in a data-tip)
|
|
2317
|
+
function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
|
|
2234
2318
|
|
|
2235
2319
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
2236
2320
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
@@ -3028,7 +3112,8 @@ function anywhereSection(r, adv = []) {
|
|
|
3028
3112
|
'<div class="full hint">Your board at <b>' + esc(r.host || 'yourname.leftoff.dev') + '</b> from any browser, end-to-end encrypted. <a href="https://leftoff.dev/how-it-works" target="_blank" rel="noopener">How it works ↗</a></div>'
|
|
3029
3113
|
// No name yet on any Mac in this folder: claim one (0.6.1, an email link, no admin token).
|
|
3030
3114
|
if ((!r.user || su.status === 'pending') && su.status !== 'confirmed') h += signupBlock(r, su, local)
|
|
3031
|
-
h += '<label for="f-anywhere">Use from anywhere</label><label class="check"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') +
|
|
3115
|
+
h += '<label for="f-anywhere">Use from anywhere' + localPillHtml({ machine: r.machine, emoji: r.machine ? mEmoji(r.machine) : '', esc }) + '</label><label class="check' + (local ? '' : ' locked') + '"><input id="f-anywhere" type="checkbox" data-action="relay-toggle"' + (r.enabled ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>On for this Mac (' + esc(r.machine || '') + ')</span></label>' +
|
|
3116
|
+
(local ? '' : '<div class="hint local-hint">Change on ' + esc(r.machine || 'that Mac') + '.</div>')
|
|
3032
3117
|
// The name: up top until there is one, then Advanced (0.6.6).
|
|
3033
3118
|
const nameRow = '<label for="f-anywhere-user">' + (r.user ? 'Your name' : 'Have a name?') + '</label><div class="anyuser"><input id="f-anywhere-user" value="' + esc(r.user || '') + '" placeholder="yourname" spellcheck="false" autocapitalize="none" autocomplete="off" maxlength="32"' + dis + '><span class="faint mono">.' + dom + '</span>' +
|
|
3034
3119
|
(local && r.user ? '<button type="button" class="btn sm" data-action="relay-user">Save</button>' : '') + '</div>' +
|
|
@@ -3360,6 +3445,7 @@ function queueSave() { clearTimeout(saveUi.timer); saveUi.timer = setTimeout(pai
|
|
|
3360
3445
|
|
|
3361
3446
|
async function viewSettings() {
|
|
3362
3447
|
const [c, , remote, sync, peers, st, , , relay, gh] = await Promise.all([api('GET', '/v1/config'), spacesLoad(), api('GET', '/v1/remote').catch(() => null), api('GET', '/v1/sync').catch(() => null), api('GET', '/v1/peers').catch(() => null), api('GET', '/v1/syncthing').catch(() => null), refreshHealth().catch(() => null), agentsLoad(), api('GET', '/v1/relay').catch(() => null), api('GET', '/v1/gh').catch(() => null)])
|
|
3448
|
+
const starts = await api('GET', '/v1/starts').catch(() => null) // 0.9.9: this Mac's own switch
|
|
3363
3449
|
state.relay = relay
|
|
3364
3450
|
state.gh = gh
|
|
3365
3451
|
syncUi.data = sync; syncUi.peers = peers; if (st) stUi.data = st
|
|
@@ -3372,7 +3458,7 @@ async function viewSettings() {
|
|
|
3372
3458
|
const panes = {
|
|
3373
3459
|
general: '<div class="section">Needs you</div>' +
|
|
3374
3460
|
'<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a notification when a prompt or question has waited ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
|
|
3375
|
-
'<div class="hint">
|
|
3461
|
+
'<div class="hint">They come from “Leftoff” on the Mac the session runs on; a click opens the session.</div>' +
|
|
3376
3462
|
'<div class="full push-slot" data-push-slot hidden></div>' +
|
|
3377
3463
|
'<div class="section">Appearance</div>' +
|
|
3378
3464
|
'<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>' +
|
|
@@ -3391,13 +3477,14 @@ async function viewSettings() {
|
|
|
3391
3477
|
'<label data-row="model">Model</label><div data-row="model" class="models" id="f-models-list" role="radiogroup" aria-label="Model"><div class="nocp">Loading…</div></div>' +
|
|
3392
3478
|
'<input type="hidden" id="f-model" value="' + esc(sm.model || '') + '">' +
|
|
3393
3479
|
'<label for="f-base" data-row="base">Server URL</label><input id="f-base" data-row="base" value="' + esc(sm.baseUrl || '') + '">' +
|
|
3394
|
-
'<label for="f-key" data-row="key">API key</label><div class="keyrow" data-row="key"><input id="f-key" type="password" autocomplete="new-password" spellcheck="false"><span class="keystate" id="f-keystate"></span></div>' +
|
|
3480
|
+
'<label for="f-key" data-row="key">API key' + localPillHtml({ machine: c.machineResolved, emoji: c.machineResolved ? mEmoji(c.machineResolved) : '', esc }) + '</label><div class="keyrow" data-row="key"><input id="f-key" type="password" autocomplete="new-password" spellcheck="false"><span class="keystate" id="f-keystate"></span></div>' +
|
|
3395
3481
|
'<div class="hint" data-row="key" id="f-key-hint"></div>' +
|
|
3396
3482
|
summariesSection(c) +
|
|
3397
3483
|
advanced('models', lengthExampleHtml(c)),
|
|
3398
3484
|
phone: phonePane(relay, remote, unavailable),
|
|
3399
3485
|
security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
|
|
3400
3486
|
'<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone & remote</a> is on.</div>') +
|
|
3487
|
+
startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer), emoji: starts ? mEmoji(starts.machine) : '', icon }) +
|
|
3401
3488
|
// a changed machine key needs a click here, so Other machines stays up top then
|
|
3402
3489
|
(peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
|
|
3403
3490
|
advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
|
|
@@ -3422,6 +3509,13 @@ async function viewSettings() {
|
|
|
3422
3509
|
SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
|
|
3423
3510
|
'<div class="full set-save" id="setSave"><span class="faint" id="setSaveNote">Saves every section to config.json in your Leftoff folder.</span><button class="btn primary" type="submit" disabled aria-disabled="true">' + icon('check') + 'Save</button></div></form></div></div>'
|
|
3424
3511
|
showSettingsSection(r)
|
|
3512
|
+
approvedUi.info = starts; approvedUi.err = ''
|
|
3513
|
+
fillApproved($app, starts) // approved folders: paths as text
|
|
3514
|
+
const ap = document.getElementById('f-start-approve')
|
|
3515
|
+
if (ap) {
|
|
3516
|
+
ap.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); approvedAdd() } })
|
|
3517
|
+
ap.addEventListener('input', () => { if (approvedUi.err) { approvedUi.err = ''; fillApproved($app, approvedUi.info) } })
|
|
3518
|
+
}
|
|
3425
3519
|
paintSettingsDots()
|
|
3426
3520
|
syncProviderFields()
|
|
3427
3521
|
// Claim your name (0.6.1): Enter sends the link instead of saving the form; a link already out keeps polling.
|
|
@@ -3601,9 +3695,10 @@ function machinesSection(p) {
|
|
|
3601
3695
|
if (!p || p.off) return ''
|
|
3602
3696
|
const local = p.viewer && p.viewer.local
|
|
3603
3697
|
let h = '<div class="section" id="machines">Other machines</div>' +
|
|
3604
|
-
'<label for="f-peer-allow">Answer and Go</label><label class="check"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + (local
|
|
3698
|
+
'<label for="f-peer-allow">Answer and Go' + localPillHtml({ machine: p.machine, emoji: p.machine ? mEmoji(p.machine) : '', esc }) + '</label><label class="check' + (local ? '' : ' locked') + '"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + lockedAttrs(local) + '>' + lockHtml(local, icon) + '<span>Allow other machines to answer and Go here</span></label>' +
|
|
3605
3699
|
'<div class="hint">Off by default. On, a Mac that shares this folder can answer this Mac’s permission prompts and questions and press Go, from its own board. Each command is signed with that Mac’s key from the folder, encrypted, and expires in a minute. ' +
|
|
3606
|
-
(p.allowIncoming ? 'Listening on port ' + esc(p.listening || '—') + (p.relay ? (p.relay.connected ? ' and connected to the relay' : ' · relay not connected') : '') + '.' : 'While it’s off nothing listens.') + ' This Mac’s key: <code>' + esc(p.fingerprint) + '</code></div>'
|
|
3700
|
+
(p.allowIncoming ? 'Listening on port ' + esc(p.listening || '—') + (p.relay ? (p.relay.connected ? ' and connected to the relay' : ' · relay not connected') : '') + '.' : 'While it’s off nothing listens.') + ' This Mac’s key: <code>' + esc(p.fingerprint) + '</code></div>' +
|
|
3701
|
+
(local ? '' : '<div class="hint local-hint">Change on ' + esc(p.machine || 'that Mac') + '.</div>')
|
|
3607
3702
|
h += '<div class="full">' + protectHtml('machines') + '</div>'
|
|
3608
3703
|
if (!p.peers.length) return h + '<label>Machines</label><div class="hint">No other Mac in this folder yet.</div>'
|
|
3609
3704
|
h += '<label>Machines</label><div class="peers">' + p.peers.map((x) => {
|
|
@@ -4149,16 +4244,172 @@ async function render() {
|
|
|
4149
4244
|
else if (r.name === 'whats-new') await viewWhatsNew()
|
|
4150
4245
|
else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
|
|
4151
4246
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
4247
|
+
else if (r.name === 'new') await viewNewSession()
|
|
4152
4248
|
else if (r.name === 'setup') await viewSetup()
|
|
4153
4249
|
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
4154
4250
|
else await viewBoard()
|
|
4155
4251
|
tabsAfterRender()
|
|
4252
|
+
fillHeadlessErrors()
|
|
4253
|
+
if (r.name !== 'new') fillStartTexts($app, startTexts(state.starts, {})) // 0.9.9: a waiting start's folder, as text
|
|
4254
|
+
// a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
|
|
4255
|
+
for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
|
|
4156
4256
|
} catch (e) {
|
|
4157
4257
|
if (e.code === 'login_required') return showLogin()
|
|
4158
4258
|
$app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
|
|
4159
4259
|
}
|
|
4160
4260
|
}
|
|
4161
4261
|
|
|
4262
|
+
// ------------------------------------------------------------ New session (0.9.9, ui/start.js)
|
|
4263
|
+
|
|
4264
|
+
// this Mac's own browser, or the hosted board open on it (the direct door)
|
|
4265
|
+
function startViewerLocal(v) { return startViewerAt(v, state.viewerLocal !== false) }
|
|
4266
|
+
const modeBusy = new Map() // 0.9.9: a session whose Permissions switch is on its way: id -> mode
|
|
4267
|
+
const startUi = { data: null, machine: null, repo: null, where: 'repo', allRepos: false, ask: null, busy: new Set(), sending: false, poll: null, browse: null, custom: null }
|
|
4268
|
+
async function viewNewSession() {
|
|
4269
|
+
try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
|
|
4270
|
+
const fa = document.activeElement
|
|
4271
|
+
const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
4272
|
+
$app.innerHTML = startPageHtml(startUi.data, startUi, { esc, icon, emoji: mEmoji, local: startViewerLocal(startUi.data.viewer), clock, draft: (state.drafts && state.drafts.start) || '', busy: startUi.sending })
|
|
4273
|
+
fillStartTexts($app, startTexts(startUi.data, startUi)) // folder names and paths: text, never markup
|
|
4274
|
+
{ const pi = $app.querySelector('.sb-path-in'); if (pi && startUi.browse && startUi.browse.typed && pi.value !== startUi.browse.typed) pi.value = startUi.browse.typed } // what you were typing survives a redraw
|
|
4275
|
+
const ta = $app.querySelector('.start-text')
|
|
4276
|
+
if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
|
|
4277
|
+
}
|
|
4278
|
+
// Other folder…: list one folder on the picked Mac (that Mac's own disk, over the peer link for another Mac)
|
|
4279
|
+
async function startBrowse(machine, p, { typed = false } = {}) {
|
|
4280
|
+
const seq = (startUi.browseSeq = (startUi.browseSeq || 0) + 1)
|
|
4281
|
+
const prev = startUi.browse && startUi.browse.machine === machine ? startUi.browse : {}
|
|
4282
|
+
startUi.browse = { ...prev, machine, loading: true, error: null, pathError: null }
|
|
4283
|
+
if (state.route && state.route.name === 'new') render()
|
|
4284
|
+
try {
|
|
4285
|
+
const d = await api('GET', '/v1/start/dirs?machine=' + encodeURIComponent(machine) + (p ? '&path=' + encodeURIComponent(p) : ''), undefined, { timeoutMs: 20000 })
|
|
4286
|
+
if (seq !== startUi.browseSeq || !startUi.browse) return
|
|
4287
|
+
startUi.browse = { machine, loading: false, error: null, pathError: null, typed: typed ? '' : startUi.browse.typed, data: d }
|
|
4288
|
+
} catch (e) {
|
|
4289
|
+
if (seq !== startUi.browseSeq || !startUi.browse) return
|
|
4290
|
+
// a typed path that isn't one you can open: said under the box, and the folder you were in stays
|
|
4291
|
+
startUi.browse = typed ? { ...startUi.browse, loading: false, pathError: e.message } : { ...startUi.browse, loading: false, error: e.message }
|
|
4292
|
+
}
|
|
4293
|
+
if (state.route && state.route.name === 'new') {
|
|
4294
|
+
await render() // the page re-reads before it redraws: focus after
|
|
4295
|
+
const pi = typed && startUi.browse.pathError ? $app.querySelector('.sb-path-in') : null
|
|
4296
|
+
const f = pi || (startUi.browseFocus ? $app.querySelector('.sb-crumb[aria-current]') : null)
|
|
4297
|
+
if (f) f.focus({ preventScroll: true })
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
// "Go to a folder": Enter or Go takes the browser there (the Mac checks it like any other step)
|
|
4301
|
+
document.addEventListener('submit', (ev) => {
|
|
4302
|
+
const f = ev.target && ev.target.closest && ev.target.closest('form[data-form="start-browse-path"]')
|
|
4303
|
+
if (!f) return
|
|
4304
|
+
ev.preventDefault()
|
|
4305
|
+
const b = startUi.browse, inp = f.querySelector('.sb-path-in')
|
|
4306
|
+
const want = typedPath(inp ? inp.value : '')
|
|
4307
|
+
if (!b) return
|
|
4308
|
+
if (!want) { startUi.browse = { ...b, pathError: 'Type a folder, like ~/code/web.' }; render().then(() => { const pi = $app.querySelector('.sb-path-in'); if (pi) pi.focus() }); return }
|
|
4309
|
+
startUi.browseFocus = true
|
|
4310
|
+
startBrowse(b.machine, want, { typed: true })
|
|
4311
|
+
})
|
|
4312
|
+
document.addEventListener('input', (ev) => {
|
|
4313
|
+
if (!ev.target || !ev.target.classList || !ev.target.classList.contains('sb-path-in') || !startUi.browse) return
|
|
4314
|
+
startUi.browse.typed = ev.target.value // kept across the board's redraws
|
|
4315
|
+
})
|
|
4316
|
+
// The per-Mac pill sits inside its setting's <label>: a tap on it shows its tip
|
|
4317
|
+
// (touch has no hover) and never flips the setting.
|
|
4318
|
+
document.addEventListener('click', (ev) => {
|
|
4319
|
+
const pill = ev.target && ev.target.closest && ev.target.closest('.local-pill')
|
|
4320
|
+
for (const x of document.querySelectorAll('.local-pill.show')) if (x !== pill) x.classList.remove('show')
|
|
4321
|
+
if (!pill) return
|
|
4322
|
+
ev.preventDefault()
|
|
4323
|
+
pill.classList.toggle('show')
|
|
4324
|
+
}, true)
|
|
4325
|
+
// 0.9.9 Settings → Approved folders: add (the Mac checks it: inside home, not
|
|
4326
|
+
// home itself, not the synced folder; an inline error otherwise, and what you
|
|
4327
|
+
// typed stays) and remove. This Mac only (the server checks).
|
|
4328
|
+
const approvedUi = { info: null, err: '', busy: false }
|
|
4329
|
+
async function approvedAdd() {
|
|
4330
|
+
const inp = document.getElementById('f-start-approve')
|
|
4331
|
+
if (!inp || inp.disabled || approvedUi.busy) return
|
|
4332
|
+
const want = typedPath(inp.value)
|
|
4333
|
+
const fail = (m) => { approvedUi.err = m; fillApproved($app, approvedUi.info, m); inp.focus() }
|
|
4334
|
+
if (!want) return fail('Type a folder, like ~/src.')
|
|
4335
|
+
approvedUi.busy = true
|
|
4336
|
+
try {
|
|
4337
|
+
const r = await api('POST', '/v1/starts/approved', { path: want })
|
|
4338
|
+
approvedUi.busy = false
|
|
4339
|
+
toast(r.added ? 'Approved ' + r.folder + (r.allowRemote ? '' : '. Turn on Allow starting sessions from other devices for it to skip the ask') : r.coveredBy ? 'Already inside ' + r.coveredBy + ', which is approved' : r.folder + ' is already approved', r.added && !r.allowRemote ? 6000 : 3000)
|
|
4340
|
+
await viewSettings()
|
|
4341
|
+
const again = document.getElementById('f-start-approve'); if (again) again.focus({ preventScroll: true })
|
|
4342
|
+
} catch (e) { approvedUi.busy = false; fail(e.message) }
|
|
4343
|
+
}
|
|
4344
|
+
async function approvedRemove(i) {
|
|
4345
|
+
const a = approvedUi.info && (approvedUi.info.approved || [])[i]
|
|
4346
|
+
if (!a || approvedUi.busy) return
|
|
4347
|
+
approvedUi.busy = true
|
|
4348
|
+
try { const r = await api('POST', '/v1/starts/approved/remove', { path: a.path }); toast(r.folder + ' is no longer approved') } catch (e) { toast(e.message, 5000) }
|
|
4349
|
+
approvedUi.busy = false
|
|
4350
|
+
await viewSettings()
|
|
4351
|
+
}
|
|
4352
|
+
// 0.9.9: a setting that saves the moment you change it (no Save button) says
|
|
4353
|
+
// so beside it: "Saved ✓" for a moment, or what went wrong, inline.
|
|
4354
|
+
const savedTimers = new Map()
|
|
4355
|
+
function markSaved(id, err = null) {
|
|
4356
|
+
const el = document.getElementById(id)
|
|
4357
|
+
const row = el && (el.closest('label.check') || el)
|
|
4358
|
+
if (!row) return
|
|
4359
|
+
let m = row.querySelector('.saved-mark')
|
|
4360
|
+
if (!m) { m = document.createElement('span'); m.className = 'saved-mark'; row.appendChild(m) } // right beside the checkbox's words
|
|
4361
|
+
m.classList.toggle('bad', !!err); m.classList.remove('gone')
|
|
4362
|
+
m.setAttribute('role', err ? 'alert' : 'status')
|
|
4363
|
+
m.textContent = err ? String(err) : 'Saved ✓'
|
|
4364
|
+
clearTimeout(savedTimers.get(id))
|
|
4365
|
+
if (!err) savedTimers.set(id, setTimeout(() => { m.classList.add('gone'); setTimeout(() => { if (m.classList.contains('gone')) m.remove() }, 400) }, 2000))
|
|
4366
|
+
}
|
|
4367
|
+
// Claude Code's own words when a session it ran stopped with an error: text, never markup.
|
|
4368
|
+
const headlessErrors = new Map()
|
|
4369
|
+
function fillHeadlessErrors() {
|
|
4370
|
+
for (const el of $app.querySelectorAll('[data-headless-err]')) {
|
|
4371
|
+
const t = headlessErrors.get(el.dataset.headlessErr) || ''
|
|
4372
|
+
el.textContent = t
|
|
4373
|
+
el.hidden = !t
|
|
4374
|
+
}
|
|
4375
|
+
}
|
|
4376
|
+
async function startSend(btn) {
|
|
4377
|
+
const box = btn.closest('.start-box')
|
|
4378
|
+
const ta = box && box.querySelector('.start-text')
|
|
4379
|
+
const text = ta ? ta.value.trim() : ''
|
|
4380
|
+
if (!text) { if (ta) ta.focus(); return }
|
|
4381
|
+
if (startUi.sending) return
|
|
4382
|
+
const pick = startPick(startUi.data, startUi)
|
|
4383
|
+
if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
|
|
4384
|
+
startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
|
|
4385
|
+
try {
|
|
4386
|
+
const r = await api('POST', '/v1/sessions/start', { machine: pick.m.machine, cwd: pick.repo.path, prompt: text, where: pick.where, mode: pick.mode }, { timeoutMs: 60000 })
|
|
4387
|
+
if (state.drafts) delete state.drafts.start
|
|
4388
|
+
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
|
|
4389
|
+
if (r.state === 'started' && r.sessionId) { startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
|
|
4390
|
+
startUi.ask = { ...r, machine: r.machine || pick.m.machine }
|
|
4391
|
+
pollStartAsk()
|
|
4392
|
+
} catch (e) { toast(e.message, 6000) } finally { startUi.sending = false }
|
|
4393
|
+
if (state.route && state.route.name === 'new') render()
|
|
4394
|
+
}
|
|
4395
|
+
// The asker's side of a start that waits for a yes at the Mac.
|
|
4396
|
+
function pollStartAsk() {
|
|
4397
|
+
clearTimeout(startUi.poll)
|
|
4398
|
+
const a = startUi.ask
|
|
4399
|
+
if (!a || !a.id || !['pending', 'starting'].includes(a.state)) return
|
|
4400
|
+
startUi.poll = setTimeout(async () => {
|
|
4401
|
+
try {
|
|
4402
|
+
const r = await api('GET', '/v1/sessions/start/' + encodeURIComponent(a.id) + '?machine=' + encodeURIComponent(a.machine))
|
|
4403
|
+
if (startUi.ask && startUi.ask.id === a.id) {
|
|
4404
|
+
startUi.ask = { ...a, ...r }
|
|
4405
|
+
if (r.state === 'started' && r.sessionId) { startUi.ask = null; location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
|
|
4406
|
+
}
|
|
4407
|
+
} catch (e) { if (e.status === 404) { startUi.ask = { ...a, state: 'failed', error: e.message } } }
|
|
4408
|
+
if (state.route && state.route.name === 'new') render()
|
|
4409
|
+
pollStartAsk()
|
|
4410
|
+
}, 2000)
|
|
4411
|
+
}
|
|
4412
|
+
|
|
4162
4413
|
// First-run setup wizard (0.9.7, ui/wizard.js). Live updates don't redraw it:
|
|
4163
4414
|
// it keeps its own step and fetches its own state.
|
|
4164
4415
|
async function viewSetup() {
|
|
@@ -5105,7 +5356,7 @@ window.addEventListener('hashchange', () => closeActionMenu())
|
|
|
5105
5356
|
const tipsUi = wireTips(document) // 0.9.8: the ⓘ tooltips (Always allow here)
|
|
5106
5357
|
window.addEventListener('hashchange', () => tipsUi.hide())
|
|
5107
5358
|
// 0.8.9: a session page's title is a button too (Enter or Space renames it)
|
|
5108
|
-
document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"]')) { e.preventDefault(); t.click() } })
|
|
5359
|
+
document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"], .lv-q-long[role="button"]')) { e.preventDefault(); t.click() } })
|
|
5109
5360
|
// 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
|
|
5110
5361
|
document.addEventListener('keydown', (ev) => {
|
|
5111
5362
|
const b = ev.target && ev.target.closest && ev.target.closest('[data-action="card-more"]')
|
|
@@ -5122,6 +5373,15 @@ document.addEventListener('click', async (ev) => {
|
|
|
5122
5373
|
const id = btn.dataset.id || (card && card.dataset.id)
|
|
5123
5374
|
const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
|
|
5124
5375
|
try {
|
|
5376
|
+
// 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
|
|
5377
|
+
if (action === 'copy-fact') {
|
|
5378
|
+
const ok = await copy(btn.dataset.copy)
|
|
5379
|
+
if (ok) {
|
|
5380
|
+
clearTimeout(btn._copied); btn.classList.add('copied'); btn.dataset.tip = 'Copied'
|
|
5381
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.dataset.tip = btn.dataset.copy }, 1500)
|
|
5382
|
+
}
|
|
5383
|
+
return
|
|
5384
|
+
}
|
|
5125
5385
|
if (action === 'copy-cmd') {
|
|
5126
5386
|
const ok = await copy(btn.dataset.cmd)
|
|
5127
5387
|
if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
|
|
@@ -5291,6 +5551,88 @@ document.addEventListener('click', async (ev) => {
|
|
|
5291
5551
|
} catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
|
|
5292
5552
|
return
|
|
5293
5553
|
}
|
|
5554
|
+
// 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
|
|
5555
|
+
if (action === 'start-pick') {
|
|
5556
|
+
const k = btn.dataset.k, v = btn.dataset.v
|
|
5557
|
+
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null } else if (k === 'repo') startUi.repo = v; else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v
|
|
5558
|
+
return render()
|
|
5559
|
+
}
|
|
5560
|
+
// Other folder…: open the browser at ~, walk it, use a folder
|
|
5561
|
+
if (action === 'start-browse') {
|
|
5562
|
+
const pick = startPick(startUi.data, startUi)
|
|
5563
|
+
if (!pick.m) return
|
|
5564
|
+
if (startUi.browse && startUi.browse.machine === pick.m.machine) { startUi.browse = null; return render() }
|
|
5565
|
+
startUi.browseFocus = false
|
|
5566
|
+
return startBrowse(pick.m.machine, null)
|
|
5567
|
+
}
|
|
5568
|
+
if (action === 'start-browse-go' || action === 'start-browse-up') {
|
|
5569
|
+
const b = startUi.browse
|
|
5570
|
+
const to = action === 'start-browse-up' ? (b && b.data && b.data.parent ? { path: b.data.parent } : null) : browseTarget(b, btn)
|
|
5571
|
+
if (!b || !to) return
|
|
5572
|
+
startUi.browseFocus = true
|
|
5573
|
+
return startBrowse(b.machine, to.path)
|
|
5574
|
+
}
|
|
5575
|
+
if (action === 'start-browse-close') { startUi.browse = null; return render() }
|
|
5576
|
+
if (action === 'start-browse-use') {
|
|
5577
|
+
const b = startUi.browse, d = b && b.data
|
|
5578
|
+
if (!d || !d.parent) return
|
|
5579
|
+
const known = startPick(startUi.data, { ...startUi, machine: b.machine }).m
|
|
5580
|
+
if (d.ran || (known && known.repos.some((r) => r.path === d.path))) startUi.custom = null
|
|
5581
|
+
else startUi.custom = { machine: b.machine, path: d.path, name: d.crumbs[d.crumbs.length - 1].name, label: d.label, git: !!d.git }
|
|
5582
|
+
startUi.repo = d.path
|
|
5583
|
+
if (!d.git) startUi.where = 'repo'
|
|
5584
|
+
startUi.browse = null
|
|
5585
|
+
render()
|
|
5586
|
+
const ta = $app.querySelector('.start-text'); if (ta && !ta.disabled) ta.focus({ preventScroll: true })
|
|
5587
|
+
return
|
|
5588
|
+
}
|
|
5589
|
+
if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
|
|
5590
|
+
if (action === 'pair-ask-yes' || action === 'pair-ask-no') {
|
|
5591
|
+
if (startUi.pairBusy) return
|
|
5592
|
+
startUi.pairBusy = true
|
|
5593
|
+
try {
|
|
5594
|
+
const r = await api('POST', '/v1/starts/pair-ask', { yes: action === 'pair-ask-yes' })
|
|
5595
|
+
toast(r.answered === 'yes' ? 'Other devices can start sessions here' + (r.trustTurnedOn ? ' (Trust my paired devices is on too)' : '') : 'OK, they’ll ask here first', 4000)
|
|
5596
|
+
} catch (e) { toast(e.message, 5000) }
|
|
5597
|
+
startUi.pairBusy = false
|
|
5598
|
+
try { state.starts = await api('GET', '/v1/starts') } catch {}
|
|
5599
|
+
return render()
|
|
5600
|
+
}
|
|
5601
|
+
if (action === 'start-again') { startUi.ask = null; return render() }
|
|
5602
|
+
if (action === 'start-send') return startSend(btn)
|
|
5603
|
+
if (action === 'start-approved-add') return approvedAdd()
|
|
5604
|
+
if (action === 'start-approved-remove') return approvedRemove(Number(btn.dataset.i))
|
|
5605
|
+
if (action === 'start-ask-confirm' || action === 'start-ask-decline' || action === 'start-ask-approve') {
|
|
5606
|
+
if (!id || startUi.busy.has(id)) return
|
|
5607
|
+
startUi.busy.add(id); render()
|
|
5608
|
+
try {
|
|
5609
|
+
// "Always allow in <folder>": approves that folder here, then starts this one
|
|
5610
|
+
const r = await api('POST', '/v1/sessions/start/' + encodeURIComponent(id) + '/' + (action === 'start-ask-decline' ? 'decline' : 'confirm'), action === 'start-ask-approve' ? { approve: true } : {}, { timeoutMs: 60000 })
|
|
5611
|
+
if (r.approved && r.state !== 'failed') toast((r.state === 'started' ? 'Started. ' : '') + 'Folders inside ' + r.approved + ' now start without asking' + (r.allowRemote ? '' : ' once Allow starting sessions from other devices is on'), 5000)
|
|
5612
|
+
else toast(r.state === 'started' ? 'Started' : r.state === 'switched' ? 'Switched to ' + modeLabel(r.mode) : r.state === 'declined' ? 'Declined' : r.state === 'failed' ? 'Didn’t start: ' + (r.error || '') : 'That request was ' + r.state, r.state === 'failed' ? 6000 : 2500)
|
|
5613
|
+
startUi.busy.delete(id)
|
|
5614
|
+
if (r.state === 'started' && r.sessionId) { location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
|
|
5615
|
+
} catch (e) { toast(e.message, 5000) }
|
|
5616
|
+
startUi.busy.delete(id)
|
|
5617
|
+
return render()
|
|
5618
|
+
}
|
|
5619
|
+
// 0.9.9 Permissions of a session Leftoff runs: switch it (or, from another device, ask that Mac)
|
|
5620
|
+
if (action === 'headless-mode') {
|
|
5621
|
+
if (!id || modeBusy.has(id) || btn.getAttribute('aria-checked') === 'true') return
|
|
5622
|
+
modeBusy.set(id, btn.dataset.mode); render()
|
|
5623
|
+
try { modeToast(await switchMode(id, btn.dataset.mode)) } catch (e) { toast(e.message, 6000) }
|
|
5624
|
+
modeBusy.delete(id)
|
|
5625
|
+
return render()
|
|
5626
|
+
}
|
|
5627
|
+
if (action === 'ask-auto') return autoRestClick(btn)
|
|
5628
|
+
if (action === 'headless-stop' || action === 'headless-run') {
|
|
5629
|
+
btn.disabled = true
|
|
5630
|
+
try {
|
|
5631
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/' + (action === 'headless-stop' ? 'stop' : 'run'), {}, { timeoutMs: 30000 })
|
|
5632
|
+
toast(action === 'headless-stop' ? 'Stopping' : r.already ? 'Already running' : 'Running again')
|
|
5633
|
+
} catch (e) { toast(e.message, 6000) }
|
|
5634
|
+
return render()
|
|
5635
|
+
}
|
|
5294
5636
|
if (action === 'restore-ask-confirm' || action === 'restore-ask-decline') {
|
|
5295
5637
|
const id = btn.dataset.id
|
|
5296
5638
|
if (!id || restoreUi.busy.has(id)) return
|
|
@@ -5474,6 +5816,34 @@ document.addEventListener('click', async (ev) => {
|
|
|
5474
5816
|
return paintDigestFilter()
|
|
5475
5817
|
}
|
|
5476
5818
|
// ---- Needs you (v0.4.4)
|
|
5819
|
+
// 0.9.9 the docked ask: open a long command in place; reply instead (the composer comes back); back to the card
|
|
5820
|
+
if (action === 'ask-expand') {
|
|
5821
|
+
const code = btn.closest('.ask-dock') && btn.closest('.ask-dock').querySelector('.ask-detail')
|
|
5822
|
+
const open = !askUi.open.has(btn.dataset.ask)
|
|
5823
|
+
if (open) askUi.open.add(btn.dataset.ask); else askUi.open.delete(btn.dataset.ask)
|
|
5824
|
+
if (code) code.classList.toggle('open', open)
|
|
5825
|
+
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.textContent = open ? 'Show less' : 'Show all'
|
|
5826
|
+
return
|
|
5827
|
+
}
|
|
5828
|
+
// a turn's answered asks, folded into one row: open them in place, fold them again
|
|
5829
|
+
if (action === 'ask-group') {
|
|
5830
|
+
const g = btn.dataset.group, li = btn.closest('.lv-askgroup'), list = li && li.querySelector('.lvag-list')
|
|
5831
|
+
const open = !askUi.groups.has(g)
|
|
5832
|
+
if (open) askUi.groups.add(g); else askUi.groups.delete(g)
|
|
5833
|
+
if (li) li.classList.toggle('open', open)
|
|
5834
|
+
if (list) list.hidden = !open
|
|
5835
|
+
btn.setAttribute('aria-expanded', open ? 'true' : 'false')
|
|
5836
|
+
btn.setAttribute('aria-label', (btn.getAttribute('aria-label') || '').replace(/, (shown|tap to show)$/, open ? ', shown' : ', tap to show'))
|
|
5837
|
+
liveJumpButton(btn.closest('.live-thread'), liveKeeper.unseen)
|
|
5838
|
+
return
|
|
5839
|
+
}
|
|
5840
|
+
if (action === 'ask-reply-instead' || action === 'ask-back') {
|
|
5841
|
+
if (action === 'ask-reply-instead') askUi.reply.add(btn.dataset.ask); else askUi.reply.delete(btn.dataset.ask)
|
|
5842
|
+
await render()
|
|
5843
|
+
if (action === 'ask-reply-instead') { const ta = document.querySelector('.live-box .composer-input'); if (ta && !ta.disabled) ta.focus({ preventScroll: true }) }
|
|
5844
|
+
else { const a = document.querySelector('.ask-dock [data-action="ask-answer"][data-choice="allow"], .ask-dock button:not([disabled])'); if (a) a.focus({ preventScroll: true }) }
|
|
5845
|
+
return
|
|
5846
|
+
}
|
|
5477
5847
|
if (action === 'ask-deny-open' || action === 'ask-deny-close') {
|
|
5478
5848
|
const need = btn.closest('.need')
|
|
5479
5849
|
const form = need && need.querySelector('.deny-form')
|
|
@@ -5489,6 +5859,9 @@ document.addEventListener('click', async (ev) => {
|
|
|
5489
5859
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
5490
5860
|
if (action === 'reply-send') return replyClick(btn)
|
|
5491
5861
|
if (action === 'live-send') return liveSendClick(btn)
|
|
5862
|
+
if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
|
|
5863
|
+
if (action === 'lv-queued-now') return sendNowClick(btn)
|
|
5864
|
+
if (action === 'lv-queued-more') { if (ev.target.closest('a[href]')) return; const open = !btn.classList.contains('open'); btn.classList.toggle('open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); return }
|
|
5492
5865
|
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
|
|
5493
5866
|
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
5494
5867
|
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
@@ -5623,6 +5996,28 @@ document.addEventListener('click', async (ev) => {
|
|
|
5623
5996
|
|
|
5624
5997
|
// Answer a permission prompt or a question from the board. The daemon hands it
|
|
5625
5998
|
// to the waiting hook or mod; the terminal's own prompt may already have won.
|
|
5999
|
+
// 0.9.9 Permissions mid-session (src/session-start.js switchMode): switched, or waiting for a yes at that Mac.
|
|
6000
|
+
function switchMode(id, mode) { return api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/mode', { mode }, { timeoutMs: 30000 }) }
|
|
6001
|
+
function modeToast(r) {
|
|
6002
|
+
if (r && r.state === 'pending') toast('Asked ' + (r.machine || 'that Mac') + ': someone there says yes, then it switches', 5000)
|
|
6003
|
+
else toast('Permissions: ' + modeLabel(r && r.mode) + (r && r.running === false ? ' (when it runs again)' : ''), 3000)
|
|
6004
|
+
}
|
|
6005
|
+
// "Auto-approve the rest" on a permission card: allow this call, and switch the session to Auto.
|
|
6006
|
+
async function autoRestClick(btn) {
|
|
6007
|
+
const need = btn.closest('.need')
|
|
6008
|
+
if (!need || !need.dataset.ask) return
|
|
6009
|
+
const sid = need.dataset.id
|
|
6010
|
+
need.querySelectorAll('button, input, textarea').forEach((el) => { if (!el.classList.contains('go')) el.disabled = true })
|
|
6011
|
+
btn.textContent = 'Switching…'
|
|
6012
|
+
let sw = null
|
|
6013
|
+
try { sw = await switchMode(sid, 'auto') } catch (e) { toast(e.message, 6000); render(); return }
|
|
6014
|
+
try {
|
|
6015
|
+
await api('POST', '/v1/asks/' + encodeURIComponent(need.dataset.ask) + '/answer', { action: 'allow', auto: true }) // 0.9.9: the thread's row says Auto-approved
|
|
6016
|
+
toast(sw && sw.state === 'pending' ? 'Allowed. Auto waits for a yes on ' + (sw.machine || 'that Mac') : 'Allowed; the rest of this session runs in Auto', 4000)
|
|
6017
|
+
} catch (e) { toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200) }
|
|
6018
|
+
render()
|
|
6019
|
+
}
|
|
6020
|
+
|
|
5626
6021
|
async function answerClick(btn, action) {
|
|
5627
6022
|
const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
|
|
5628
6023
|
if (!need) return
|
|
@@ -5653,6 +6048,7 @@ async function answerClick(btn, action) {
|
|
|
5653
6048
|
try {
|
|
5654
6049
|
const r = await api('POST', '/v1/asks/' + encodeURIComponent(askId) + '/answer', body)
|
|
5655
6050
|
toast(r.summary + (r.delivered ? ' · Claude Code has it' : ' · waiting for Claude Code to pick it up'), 3600)
|
|
6051
|
+
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // a typed answer sent: a phone's keyboard closes
|
|
5656
6052
|
for (const k of Object.keys(state.drafts)) if (k.includes(askId)) delete state.drafts[k]
|
|
5657
6053
|
} catch (e) {
|
|
5658
6054
|
toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
|
|
@@ -5667,13 +6063,16 @@ async function replyClick(btn) {
|
|
|
5667
6063
|
const need = btn.closest('.need')
|
|
5668
6064
|
const ta = need && need.querySelector('.reply-text')
|
|
5669
6065
|
const text = ta ? ta.value.trim() : ''
|
|
5670
|
-
if (!text) { toast('Type a reply first'); return }
|
|
6066
|
+
if (!text) { toast('Type a reply first'); if (ta && !ta.disabled) ta.focus(); return }
|
|
5671
6067
|
if (text.startsWith('/')) { toast('Replies can’t start with “/”'); return }
|
|
5672
|
-
|
|
6068
|
+
if (btn.classList.contains('sending')) return
|
|
6069
|
+
// the composer's ↑ (0.9.8): it pulses while the reply goes, like Live's
|
|
6070
|
+
btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
|
|
5673
6071
|
try {
|
|
5674
6072
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
|
|
5675
6073
|
delete state.drafts['reply:' + need.dataset.id]
|
|
5676
6074
|
toast(r.queuedBehindTurn ? 'Sent · Claude reads it when the current turn ends' : 'Sent · Claude has it as its next prompt', 3600)
|
|
6075
|
+
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // a phone's keyboard closes
|
|
5677
6076
|
} catch (e) { toast(e.message, 4200) } finally { render() }
|
|
5678
6077
|
}
|
|
5679
6078
|
|
|
@@ -6014,7 +6413,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
6014
6413
|
|
|
6015
6414
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
6016
6415
|
let pending = null
|
|
6017
|
-
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value)
|
|
6416
|
+
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.start-box') && document.activeElement.value) || !!(document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('sb-path-in') && document.activeElement.value)
|
|
6018
6417
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
6019
6418
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
6020
6419
|
function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name)) && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
|
|
@@ -6028,7 +6427,7 @@ function connect() {
|
|
|
6028
6427
|
es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
|
|
6029
6428
|
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
6030
6429
|
es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
|
|
6031
|
-
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() })
|
|
6430
|
+
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', 'starts.updated']) es.addEventListener(t, () => { if (stream === es) streamDog.seen(); soon() })
|
|
6032
6431
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
6033
6432
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
6034
6433
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
|
@@ -6052,10 +6451,19 @@ document.addEventListener('change', async (ev) => {
|
|
|
6052
6451
|
} catch (e) { toast(e.message, 5000) }
|
|
6053
6452
|
return sel.closest('#spacesBox') ? spacesRepaint() : render()
|
|
6054
6453
|
}
|
|
6454
|
+
if (ev.target.id === 'f-start-allow') { // 0.9.9: this Mac only
|
|
6455
|
+
const cb = ev.target
|
|
6456
|
+
let err = null
|
|
6457
|
+
try { const r = await api('POST', '/v1/starts/allow', { allow: cb.checked }); toast(r.allowRemote ? 'Your phone and other Macs can now start sessions here' : 'Sessions started from other devices wait for a yes here') } catch (e) { cb.checked = !cb.checked; err = e.message; toast(e.message, 6000) }
|
|
6458
|
+
await viewSettings()
|
|
6459
|
+
return markSaved('f-start-allow', err)
|
|
6460
|
+
}
|
|
6055
6461
|
if (ev.target.id === 'f-peer-allow') {
|
|
6056
6462
|
const cb = ev.target
|
|
6057
|
-
|
|
6058
|
-
|
|
6463
|
+
let err = null
|
|
6464
|
+
try { const r = await api('POST', '/v1/peers/allow', { allow: cb.checked }); toast(r.allowIncoming ? 'Other machines may now answer and Go here' : 'Other machines can no longer answer or Go here') } catch (e) { cb.checked = !cb.checked; err = e.message; toast(e.message, 6000) }
|
|
6465
|
+
await viewSettings()
|
|
6466
|
+
return markSaved('f-peer-allow', err)
|
|
6059
6467
|
}
|
|
6060
6468
|
if (ev.target.dataset && ev.target.dataset.list && ev.target.type === 'checkbox') {
|
|
6061
6469
|
const cb = ev.target, done = cb.checked, li = cb.closest('li')
|