@leftoff/cli 0.9.9-beta.1 → 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/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, composerBoxHtml } 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
@@ -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 += restoreAsksBanner() + restoreBanner()
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) => {
@@ -1392,6 +1398,7 @@ 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
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 {} }
@@ -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
- const liveWanted = (s) => !!(s && isLive(s) && !s.agent && s.codingAgent !== 'codex' && s.capture !== 'hooks')
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
- if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
1570
- if (!isLive(s)) {
1571
- if (s.endedAt || s.live === 'closed') return ''
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,13 +1601,25 @@ 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
- 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>' +
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 &amp; 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
@@ -1896,7 +1928,7 @@ document.addEventListener('keydown', (ev) => {
1896
1928
  ev.preventDefault()
1897
1929
  const cb = ta.closest('.composer')
1898
1930
  if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
1899
- const box = ta.closest('.live-box') || ta.closest('.need')
1931
+ const box = ta.closest('.live-box') || ta.closest('.need') || ta.closest('.start-box')
1900
1932
  const btn = box && box.querySelector('[data-submit-for="' + CSS.escape(ta.dataset.submit || 'live') + '"]')
1901
1933
  if (!btn || btn.disabled) return
1902
1934
  const last = Number(box.dataset.kbdAt || 0)
@@ -2085,6 +2117,58 @@ function onWake(ev) {
2085
2117
  }
2086
2118
  for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
2087
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
+
2088
2172
  async function liveSendClick(btn) {
2089
2173
  const box = btn.closest('.live-box')
2090
2174
  const ta = box && box.querySelector('.live-text')
@@ -2093,6 +2177,7 @@ async function liveSendClick(btn) {
2093
2177
  if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
2094
2178
  if (btn.disabled || btn.classList.contains('sending')) return
2095
2179
  const hadFocus = !!(ta && document.activeElement === ta)
2180
+ let sent = false // 0.9.9: went through: a phone's keyboard closes
2096
2181
  btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
2097
2182
  try {
2098
2183
  // 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
@@ -2106,6 +2191,7 @@ async function liveSendClick(btn) {
2106
2191
  if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
2107
2192
  if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
2108
2193
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
2194
+ else sent = true
2109
2195
  const ol = box && box.querySelector('.live-log')
2110
2196
  if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
2111
2197
  setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
@@ -2114,7 +2200,11 @@ async function liveSendClick(btn) {
2114
2200
  const nb = document.querySelector('.live-box [data-action="live-send"]')
2115
2201
  if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
2116
2202
  const nt = document.querySelector('.live-box .composer-input')
2117
- if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
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
+ }
2118
2208
  }
2119
2209
  }
2120
2210
 
@@ -2223,7 +2313,8 @@ function spacePicker(s, spv, kind, key) {
2223
2313
  options: [['', own ? 'Default (repo or Mac)' : s.space + ' · ' + (SPACE_WHY[s.spaceSource] || 'default')], ...opts.map((x) => [x, x]), ['__new', 'New space…']] }) +
2224
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>'
2225
2315
  }
2226
- function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
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 }) }
2227
2318
 
2228
2319
  // PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
2229
2320
  // PR in the repos your sessions are in). The daemon fetches with gh (or asks
@@ -3021,7 +3112,8 @@ function anywhereSection(r, adv = []) {
3021
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>'
3022
3113
  // No name yet on any Mac in this folder: claim one (0.6.1, an email link, no admin token).
3023
3114
  if ((!r.user || su.status === 'pending') && su.status !== 'confirmed') h += signupBlock(r, su, local)
3024
- 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' : '') + dis + '><span>On for this Mac (' + esc(r.machine || '') + ')</span></label>'
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>')
3025
3117
  // The name: up top until there is one, then Advanced (0.6.6).
3026
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>' +
3027
3119
  (local && r.user ? '<button type="button" class="btn sm" data-action="relay-user">Save</button>' : '') + '</div>' +
@@ -3353,6 +3445,7 @@ function queueSave() { clearTimeout(saveUi.timer); saveUi.timer = setTimeout(pai
3353
3445
 
3354
3446
  async function viewSettings() {
3355
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
3356
3449
  state.relay = relay
3357
3450
  state.gh = gh
3358
3451
  syncUi.data = sync; syncUi.peers = peers; if (st) stUi.data = st
@@ -3365,7 +3458,7 @@ async function viewSettings() {
3365
3458
  const panes = {
3366
3459
  general: '<div class="section">Needs you</div>' +
3367
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>' +
3368
- '<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
3461
+ '<div class="hint">They come from “Leftoff” on the Mac the session runs on; a click opens the session.</div>' +
3369
3462
  '<div class="full push-slot" data-push-slot hidden></div>' +
3370
3463
  '<div class="section">Appearance</div>' +
3371
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>' +
@@ -3384,13 +3477,14 @@ async function viewSettings() {
3384
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>' +
3385
3478
  '<input type="hidden" id="f-model" value="' + esc(sm.model || '') + '">' +
3386
3479
  '<label for="f-base" data-row="base">Server URL</label><input id="f-base" data-row="base" value="' + esc(sm.baseUrl || '') + '">' +
3387
- '<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>' +
3388
3481
  '<div class="hint" data-row="key" id="f-key-hint"></div>' +
3389
3482
  summariesSection(c) +
3390
3483
  advanced('models', lengthExampleHtml(c)),
3391
3484
  phone: phonePane(relay, remote, unavailable),
3392
3485
  security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
3393
3486
  '<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone &amp; remote</a> is on.</div>') +
3487
+ startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer), emoji: starts ? mEmoji(starts.machine) : '', icon }) +
3394
3488
  // a changed machine key needs a click here, so Other machines stays up top then
3395
3489
  (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
3396
3490
  advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
@@ -3415,6 +3509,13 @@ async function viewSettings() {
3415
3509
  SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
3416
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>'
3417
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
+ }
3418
3519
  paintSettingsDots()
3419
3520
  syncProviderFields()
3420
3521
  // Claim your name (0.6.1): Enter sends the link instead of saving the form; a link already out keeps polling.
@@ -3594,9 +3695,10 @@ function machinesSection(p) {
3594
3695
  if (!p || p.off) return ''
3595
3696
  const local = p.viewer && p.viewer.local
3596
3697
  let h = '<div class="section" id="machines">Other machines</div>' +
3597
- '<label for="f-peer-allow">Answer and Go</label><label class="check"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow other machines to answer and Go here</span></label>' +
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>' +
3598
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. ' +
3599
- (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>')
3600
3702
  h += '<div class="full">' + protectHtml('machines') + '</div>'
3601
3703
  if (!p.peers.length) return h + '<label>Machines</label><div class="hint">No other Mac in this folder yet.</div>'
3602
3704
  h += '<label>Machines</label><div class="peers">' + p.peers.map((x) => {
@@ -4142,10 +4244,13 @@ async function render() {
4142
4244
  else if (r.name === 'whats-new') await viewWhatsNew()
4143
4245
  else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
4144
4246
  else if (r.name === 'search') await viewSearch(r.q || '')
4247
+ else if (r.name === 'new') await viewNewSession()
4145
4248
  else if (r.name === 'setup') await viewSetup()
4146
4249
  else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
4147
4250
  else await viewBoard()
4148
4251
  tabsAfterRender()
4252
+ fillHeadlessErrors()
4253
+ if (r.name !== 'new') fillStartTexts($app, startTexts(state.starts, {})) // 0.9.9: a waiting start's folder, as text
4149
4254
  // a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
4150
4255
  for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
4151
4256
  } catch (e) {
@@ -4154,6 +4259,157 @@ async function render() {
4154
4259
  }
4155
4260
  }
4156
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
+
4157
4413
  // First-run setup wizard (0.9.7, ui/wizard.js). Live updates don't redraw it:
4158
4414
  // it keeps its own step and fetches its own state.
4159
4415
  async function viewSetup() {
@@ -5100,7 +5356,7 @@ window.addEventListener('hashchange', () => closeActionMenu())
5100
5356
  const tipsUi = wireTips(document) // 0.9.8: the ⓘ tooltips (Always allow here)
5101
5357
  window.addEventListener('hashchange', () => tipsUi.hide())
5102
5358
  // 0.8.9: a session page's title is a button too (Enter or Space renames it)
5103
- 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() } })
5104
5360
  // 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
5105
5361
  document.addEventListener('keydown', (ev) => {
5106
5362
  const b = ev.target && ev.target.closest && ev.target.closest('[data-action="card-more"]')
@@ -5117,6 +5373,15 @@ document.addEventListener('click', async (ev) => {
5117
5373
  const id = btn.dataset.id || (card && card.dataset.id)
5118
5374
  const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
5119
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
+ }
5120
5385
  if (action === 'copy-cmd') {
5121
5386
  const ok = await copy(btn.dataset.cmd)
5122
5387
  if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
@@ -5286,6 +5551,88 @@ document.addEventListener('click', async (ev) => {
5286
5551
  } catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
5287
5552
  return
5288
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
+ }
5289
5636
  if (action === 'restore-ask-confirm' || action === 'restore-ask-decline') {
5290
5637
  const id = btn.dataset.id
5291
5638
  if (!id || restoreUi.busy.has(id)) return
@@ -5469,6 +5816,34 @@ document.addEventListener('click', async (ev) => {
5469
5816
  return paintDigestFilter()
5470
5817
  }
5471
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
+ }
5472
5847
  if (action === 'ask-deny-open' || action === 'ask-deny-close') {
5473
5848
  const need = btn.closest('.need')
5474
5849
  const form = need && need.querySelector('.deny-form')
@@ -5484,6 +5859,9 @@ document.addEventListener('click', async (ev) => {
5484
5859
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
5485
5860
  if (action === 'reply-send') return replyClick(btn)
5486
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 }
5487
5865
  if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
5488
5866
  if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
5489
5867
  if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
@@ -5618,6 +5996,28 @@ document.addEventListener('click', async (ev) => {
5618
5996
 
5619
5997
  // Answer a permission prompt or a question from the board. The daemon hands it
5620
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
+
5621
6021
  async function answerClick(btn, action) {
5622
6022
  const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
5623
6023
  if (!need) return
@@ -5648,6 +6048,7 @@ async function answerClick(btn, action) {
5648
6048
  try {
5649
6049
  const r = await api('POST', '/v1/asks/' + encodeURIComponent(askId) + '/answer', body)
5650
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
5651
6052
  for (const k of Object.keys(state.drafts)) if (k.includes(askId)) delete state.drafts[k]
5652
6053
  } catch (e) {
5653
6054
  toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
@@ -5671,6 +6072,7 @@ async function replyClick(btn) {
5671
6072
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
5672
6073
  delete state.drafts['reply:' + need.dataset.id]
5673
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
5674
6076
  } catch (e) { toast(e.message, 4200) } finally { render() }
5675
6077
  }
5676
6078
 
@@ -6011,7 +6413,7 @@ document.addEventListener('submit', async (ev) => {
6011
6413
 
6012
6414
  // Live updates over SSE; re-render the current view, at most every 800 ms.
6013
6415
  let pending = null
6014
- 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)
6015
6417
  // Settings isn't redrawn by live updates: it would wipe what you're editing,
6016
6418
  // and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
6017
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) }
@@ -6025,7 +6427,7 @@ function connect() {
6025
6427
  es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
6026
6428
  es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
6027
6429
  es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
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() })
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() })
6029
6431
  // v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
6030
6432
  for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
6031
6433
  es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
@@ -6049,10 +6451,19 @@ document.addEventListener('change', async (ev) => {
6049
6451
  } catch (e) { toast(e.message, 5000) }
6050
6452
  return sel.closest('#spacesBox') ? spacesRepaint() : render()
6051
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
+ }
6052
6461
  if (ev.target.id === 'f-peer-allow') {
6053
6462
  const cb = ev.target
6054
- 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; toast(e.message, 6000) }
6055
- return viewSettings()
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)
6056
6467
  }
6057
6468
  if (ev.target.dataset && ev.target.dataset.list && ev.target.type === 'checkbox') {
6058
6469
  const cb = ev.target, done = cb.checked, li = cb.closest('li')