@leftoff/cli 0.9.9-beta.6 → 0.9.9-beta.8
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 +12 -1
- package/bin/leftoff.js +466 -407
- package/docs/SECURITY.md +2 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +37 -2
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +139 -0
- package/ui/app.js +229 -36
- package/ui/composer.js +12 -0
- package/ui/facts.js +34 -0
- package/ui/icons.js +1 -0
- package/ui/live-status.js +12 -1
- package/ui/live-view.js +65 -9
- package/ui/live-wake.js +1 -1
- package/ui/needs.js +158 -2
- package/ui/start.js +128 -12
package/ui/app.js
CHANGED
|
@@ -15,19 +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
|
|
19
|
-
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath } from './start.js' // 0.9.9 New session
|
|
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
|
|
20
20
|
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
21
21
|
import { wireTips } from './tips.js'
|
|
22
|
+
import { factHtml } from './facts.js' // 0.9.9 the session page's info cards
|
|
22
23
|
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
23
|
-
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'
|
|
24
25
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
25
26
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
26
27
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
27
28
|
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
|
|
28
29
|
import { restoreAccess, hintHtml, restoreWhen, restoreHeadline, askLine } from './restore-gate.js'
|
|
29
30
|
import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
|
|
30
|
-
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
|
|
31
32
|
import { statusView, elapsedText } from './live-status.js'
|
|
32
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
|
|
33
34
|
import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
|
|
@@ -1397,6 +1398,7 @@ function paintSession(s, data) {
|
|
|
1397
1398
|
liveKeeper.before($app)
|
|
1398
1399
|
$app.innerHTML = sessionPageHtml(s, data)
|
|
1399
1400
|
liveAfterRender(s.id)
|
|
1401
|
+
fitAskDetail($app)
|
|
1400
1402
|
if (kf) {
|
|
1401
1403
|
const nt = $app.querySelector('.composer-input[data-draft="' + CSS.escape(kf.d || '') + '"]')
|
|
1402
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 {} }
|
|
@@ -1494,14 +1496,14 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
1494
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
|
|
1495
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>'
|
|
1496
1498
|
html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
|
|
1497
|
-
html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false }) // 0.9.9: started from the board: Stop / Resume
|
|
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
|
|
1498
1500
|
if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
|
|
1499
1501
|
html += '<div class="facts">' +
|
|
1500
|
-
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)) +
|
|
1501
|
-
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) +
|
|
1502
|
-
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
1503
|
-
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>'
|
|
1504
|
-
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
|
|
1505
1507
|
html += parkRow(s)
|
|
1506
1508
|
html += spacePicker(s, spv, 'session', s.id)
|
|
1507
1509
|
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
|
|
@@ -1511,7 +1513,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
1511
1513
|
// 0.9.8: the last tools it ran and its folder's changes, folded; loads when you open it
|
|
1512
1514
|
html += '<section class="wid" id="what-it-did">' + whatItDidSection(s, widOf(s.id)) + '</section>'
|
|
1513
1515
|
// 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
|
|
1514
|
-
if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
|
|
1516
|
+
if (s.needsYou && !isLive(s) && !askDocked(s)) html += sessionNeedsStrip(s)
|
|
1515
1517
|
if (!isLive(s)) html += liveBoxHtml(s, liveV)
|
|
1516
1518
|
if (s.statusChanges && s.statusChanges.length) {
|
|
1517
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>'
|
|
@@ -1546,6 +1548,16 @@ function sessionNeedsStrip(s) { return '<section class="needs-strip one" aria-la
|
|
|
1546
1548
|
// 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
|
|
1547
1549
|
// bottom); Claude's answer is the session's own (markdown, like the terminal).
|
|
1548
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() }
|
|
1549
1561
|
const liveSeenKey = (id) => 'lo-live-seen:' + id
|
|
1550
1562
|
function liveSeen(id) { try { return localStorage.getItem(liveSeenKey(id)) } catch { return null } }
|
|
1551
1563
|
function markLiveSeen(id, at) { try { localStorage.setItem(liveSeenKey(id), at || new Date().toISOString()) } catch {} }
|
|
@@ -1561,7 +1573,8 @@ async function liveHow() {
|
|
|
1561
1573
|
return state.liveHow
|
|
1562
1574
|
}
|
|
1563
1575
|
// Does this session's page show a Live thread (and so fetch one)?
|
|
1564
|
-
|
|
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)
|
|
1565
1578
|
// GET the Live thread; what it says is the newest the page knows (0.9.8: the
|
|
1566
1579
|
// time of the last good answer decides "Reconnecting…" on a pending bubble).
|
|
1567
1580
|
async function fetchLive(id, { timeoutMs } = {}) {
|
|
@@ -1573,9 +1586,9 @@ async function fetchLive(id, { timeoutMs } = {}) {
|
|
|
1573
1586
|
} catch (e) { return { messages: [], error: e.message } }
|
|
1574
1587
|
}
|
|
1575
1588
|
function liveBoxHtml(s, v) {
|
|
1576
|
-
|
|
1577
|
-
if (
|
|
1578
|
-
|
|
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') {
|
|
1579
1592
|
const how = state.liveHow
|
|
1580
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>' +
|
|
1581
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>' +
|
|
@@ -1588,13 +1601,25 @@ function liveBoxHtml(s, v) {
|
|
|
1588
1601
|
const now = Date.now()
|
|
1589
1602
|
const lastClaude = [...msgs].reverse().find((m) => m.from === 'claude')
|
|
1590
1603
|
if (lastClaude) markLiveSeen(s.id, lastClaude.at)
|
|
1591
|
-
|
|
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), 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>' +
|
|
1592
1617
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1593
|
-
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
1594
|
-
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' 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, 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>' +
|
|
1595
1620
|
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
|
|
1596
1621
|
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
1597
|
-
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)) +
|
|
1598
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>'
|
|
1599
1624
|
}
|
|
1600
1625
|
// The Live composer: one rounded box, a textarea that starts at one line and
|
|
@@ -2092,6 +2117,38 @@ 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
|
+
async function queuedClick(btn, edit) {
|
|
2124
|
+
const box = btn.closest('.live-box')
|
|
2125
|
+
const sid = box && box.dataset.id
|
|
2126
|
+
const msg = btn.dataset.msg
|
|
2127
|
+
if (!sid || !msg || btn.disabled) return
|
|
2128
|
+
const li = btn.closest('.lv')
|
|
2129
|
+
if (li) li.querySelectorAll('.lv-q-btn').forEach((b) => { b.disabled = true })
|
|
2130
|
+
btn.setAttribute('aria-busy', 'true')
|
|
2131
|
+
try {
|
|
2132
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live/' + encodeURIComponent(msg) + '/cancel', {})
|
|
2133
|
+
const s = state.sessions.find((x) => x.id === sid)
|
|
2134
|
+
if (edit) {
|
|
2135
|
+
const typed = ((state.drafts && state.drafts['live:' + sid]) || '').trim()
|
|
2136
|
+
state.drafts['live:' + sid] = typed ? typed + '\n\n' + (r.text || '') : (r.text || '')
|
|
2137
|
+
const atts = (r.attachments || []).filter((a) => a && a.id)
|
|
2138
|
+
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 }))])
|
|
2139
|
+
else if (atts.length) toast('Its files didn’t come back: attach them again.', 4200)
|
|
2140
|
+
}
|
|
2141
|
+
toast(edit ? 'Taken back · edit it and send again' : 'Cancelled · Claude never saw it', 3000)
|
|
2142
|
+
await refreshLive('cancel')
|
|
2143
|
+
await render()
|
|
2144
|
+
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 }) } }
|
|
2145
|
+
} catch (e) {
|
|
2146
|
+
toast(e.message, 4200)
|
|
2147
|
+
await refreshLive('cancel')
|
|
2148
|
+
await render()
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2095
2152
|
async function liveSendClick(btn) {
|
|
2096
2153
|
const box = btn.closest('.live-box')
|
|
2097
2154
|
const ta = box && box.querySelector('.live-text')
|
|
@@ -2100,6 +2157,7 @@ async function liveSendClick(btn) {
|
|
|
2100
2157
|
if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
|
|
2101
2158
|
if (btn.disabled || btn.classList.contains('sending')) return
|
|
2102
2159
|
const hadFocus = !!(ta && document.activeElement === ta)
|
|
2160
|
+
let sent = false // 0.9.9: went through: a phone's keyboard closes
|
|
2103
2161
|
btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
|
|
2104
2162
|
try {
|
|
2105
2163
|
// 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
|
|
@@ -2113,6 +2171,7 @@ async function liveSendClick(btn) {
|
|
|
2113
2171
|
if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
|
|
2114
2172
|
if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
|
|
2115
2173
|
if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
|
|
2174
|
+
else sent = true
|
|
2116
2175
|
const ol = box && box.querySelector('.live-log')
|
|
2117
2176
|
if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
|
|
2118
2177
|
setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
|
|
@@ -2121,7 +2180,11 @@ async function liveSendClick(btn) {
|
|
|
2121
2180
|
const nb = document.querySelector('.live-box [data-action="live-send"]')
|
|
2122
2181
|
if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
|
|
2123
2182
|
const nt = document.querySelector('.live-box .composer-input')
|
|
2124
|
-
if (nt) {
|
|
2183
|
+
if (nt) {
|
|
2184
|
+
fitComposer(nt)
|
|
2185
|
+
if (!keepFocusAfterSend({ ok: sent, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document)
|
|
2186
|
+
else if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true })
|
|
2187
|
+
}
|
|
2125
2188
|
}
|
|
2126
2189
|
}
|
|
2127
2190
|
|
|
@@ -2230,7 +2293,8 @@ function spacePicker(s, spv, kind, key) {
|
|
|
2230
2293
|
options: [['', own ? 'Default (repo or Mac)' : s.space + ' · ' + (SPACE_WHY[s.spaceSource] || 'default')], ...opts.map((x) => [x, x]), ['__new', 'New space…']] }) +
|
|
2231
2294
|
'<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
2295
|
}
|
|
2233
|
-
|
|
2296
|
+
// (0.9.9: ui/facts.js; an id or a path copies on tap, its whole value in a data-tip)
|
|
2297
|
+
function fact(k, v, cls, opts = {}) { return factHtml(k, v, { cls, ...opts }) }
|
|
2234
2298
|
|
|
2235
2299
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
2236
2300
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
@@ -3028,7 +3092,8 @@ function anywhereSection(r, adv = []) {
|
|
|
3028
3092
|
'<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
3093
|
// No name yet on any Mac in this folder: claim one (0.6.1, an email link, no admin token).
|
|
3030
3094
|
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' : '') +
|
|
3095
|
+
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>' +
|
|
3096
|
+
(local ? '' : '<div class="hint local-hint">Change on ' + esc(r.machine || 'that Mac') + '.</div>')
|
|
3032
3097
|
// The name: up top until there is one, then Advanced (0.6.6).
|
|
3033
3098
|
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
3099
|
(local && r.user ? '<button type="button" class="btn sm" data-action="relay-user">Save</button>' : '') + '</div>' +
|
|
@@ -3373,7 +3438,7 @@ async function viewSettings() {
|
|
|
3373
3438
|
const panes = {
|
|
3374
3439
|
general: '<div class="section">Needs you</div>' +
|
|
3375
3440
|
'<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>' +
|
|
3376
|
-
'<div class="hint">
|
|
3441
|
+
'<div class="hint">They come from “Leftoff” on the Mac the session runs on; a click opens the session.</div>' +
|
|
3377
3442
|
'<div class="full push-slot" data-push-slot hidden></div>' +
|
|
3378
3443
|
'<div class="section">Appearance</div>' +
|
|
3379
3444
|
'<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>' +
|
|
@@ -3392,14 +3457,14 @@ async function viewSettings() {
|
|
|
3392
3457
|
'<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>' +
|
|
3393
3458
|
'<input type="hidden" id="f-model" value="' + esc(sm.model || '') + '">' +
|
|
3394
3459
|
'<label for="f-base" data-row="base">Server URL</label><input id="f-base" data-row="base" value="' + esc(sm.baseUrl || '') + '">' +
|
|
3395
|
-
'<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>' +
|
|
3460
|
+
'<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>' +
|
|
3396
3461
|
'<div class="hint" data-row="key" id="f-key-hint"></div>' +
|
|
3397
3462
|
summariesSection(c) +
|
|
3398
3463
|
advanced('models', lengthExampleHtml(c)),
|
|
3399
3464
|
phone: phonePane(relay, remote, unavailable),
|
|
3400
3465
|
security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
|
|
3401
3466
|
'<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone & remote</a> is on.</div>') +
|
|
3402
|
-
startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer) }) +
|
|
3467
|
+
startsSettingHtml(starts, { esc, local: startViewerLocal(starts && starts.viewer), emoji: starts ? mEmoji(starts.machine) : '', icon }) +
|
|
3403
3468
|
// a changed machine key needs a click here, so Other machines stays up top then
|
|
3404
3469
|
(peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
|
|
3405
3470
|
advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
|
|
@@ -3424,6 +3489,13 @@ async function viewSettings() {
|
|
|
3424
3489
|
SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
|
|
3425
3490
|
'<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>'
|
|
3426
3491
|
showSettingsSection(r)
|
|
3492
|
+
approvedUi.info = starts; approvedUi.err = ''
|
|
3493
|
+
fillApproved($app, starts) // approved folders: paths as text
|
|
3494
|
+
const ap = document.getElementById('f-start-approve')
|
|
3495
|
+
if (ap) {
|
|
3496
|
+
ap.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); approvedAdd() } })
|
|
3497
|
+
ap.addEventListener('input', () => { if (approvedUi.err) { approvedUi.err = ''; fillApproved($app, approvedUi.info) } })
|
|
3498
|
+
}
|
|
3427
3499
|
paintSettingsDots()
|
|
3428
3500
|
syncProviderFields()
|
|
3429
3501
|
// Claim your name (0.6.1): Enter sends the link instead of saving the form; a link already out keeps polling.
|
|
@@ -3603,9 +3675,10 @@ function machinesSection(p) {
|
|
|
3603
3675
|
if (!p || p.off) return ''
|
|
3604
3676
|
const local = p.viewer && p.viewer.local
|
|
3605
3677
|
let h = '<div class="section" id="machines">Other machines</div>' +
|
|
3606
|
-
'<label for="f-peer-allow">Answer and Go</label><label class="check"><input id="f-peer-allow" type="checkbox"' + (p.allowIncoming ? ' checked' : '') + (local
|
|
3678
|
+
'<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>' +
|
|
3607
3679
|
'<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. ' +
|
|
3608
|
-
(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>'
|
|
3680
|
+
(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>' +
|
|
3681
|
+
(local ? '' : '<div class="hint local-hint">Change on ' + esc(p.machine || 'that Mac') + '.</div>')
|
|
3609
3682
|
h += '<div class="full">' + protectHtml('machines') + '</div>'
|
|
3610
3683
|
if (!p.peers.length) return h + '<label>Machines</label><div class="hint">No other Mac in this folder yet.</div>'
|
|
3611
3684
|
h += '<label>Machines</label><div class="peers">' + p.peers.map((x) => {
|
|
@@ -4170,6 +4243,7 @@ async function render() {
|
|
|
4170
4243
|
|
|
4171
4244
|
// this Mac's own browser, or the hosted board open on it (the direct door)
|
|
4172
4245
|
function startViewerLocal(v) { return startViewerAt(v, state.viewerLocal !== false) }
|
|
4246
|
+
const modeBusy = new Map() // 0.9.9: a session whose Permissions switch is on its way: id -> mode
|
|
4173
4247
|
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 }
|
|
4174
4248
|
async function viewNewSession() {
|
|
4175
4249
|
try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
|
|
@@ -4219,6 +4293,57 @@ document.addEventListener('input', (ev) => {
|
|
|
4219
4293
|
if (!ev.target || !ev.target.classList || !ev.target.classList.contains('sb-path-in') || !startUi.browse) return
|
|
4220
4294
|
startUi.browse.typed = ev.target.value // kept across the board's redraws
|
|
4221
4295
|
})
|
|
4296
|
+
// The per-Mac pill sits inside its setting's <label>: a tap on it shows its tip
|
|
4297
|
+
// (touch has no hover) and never flips the setting.
|
|
4298
|
+
document.addEventListener('click', (ev) => {
|
|
4299
|
+
const pill = ev.target && ev.target.closest && ev.target.closest('.local-pill')
|
|
4300
|
+
for (const x of document.querySelectorAll('.local-pill.show')) if (x !== pill) x.classList.remove('show')
|
|
4301
|
+
if (!pill) return
|
|
4302
|
+
ev.preventDefault()
|
|
4303
|
+
pill.classList.toggle('show')
|
|
4304
|
+
}, true)
|
|
4305
|
+
// 0.9.9 Settings → Approved folders: add (the Mac checks it: inside home, not
|
|
4306
|
+
// home itself, not the synced folder; an inline error otherwise, and what you
|
|
4307
|
+
// typed stays) and remove. This Mac only (the server checks).
|
|
4308
|
+
const approvedUi = { info: null, err: '', busy: false }
|
|
4309
|
+
async function approvedAdd() {
|
|
4310
|
+
const inp = document.getElementById('f-start-approve')
|
|
4311
|
+
if (!inp || inp.disabled || approvedUi.busy) return
|
|
4312
|
+
const want = typedPath(inp.value)
|
|
4313
|
+
const fail = (m) => { approvedUi.err = m; fillApproved($app, approvedUi.info, m); inp.focus() }
|
|
4314
|
+
if (!want) return fail('Type a folder, like ~/src.')
|
|
4315
|
+
approvedUi.busy = true
|
|
4316
|
+
try {
|
|
4317
|
+
const r = await api('POST', '/v1/starts/approved', { path: want })
|
|
4318
|
+
approvedUi.busy = false
|
|
4319
|
+
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)
|
|
4320
|
+
await viewSettings()
|
|
4321
|
+
const again = document.getElementById('f-start-approve'); if (again) again.focus({ preventScroll: true })
|
|
4322
|
+
} catch (e) { approvedUi.busy = false; fail(e.message) }
|
|
4323
|
+
}
|
|
4324
|
+
async function approvedRemove(i) {
|
|
4325
|
+
const a = approvedUi.info && (approvedUi.info.approved || [])[i]
|
|
4326
|
+
if (!a || approvedUi.busy) return
|
|
4327
|
+
approvedUi.busy = true
|
|
4328
|
+
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) }
|
|
4329
|
+
approvedUi.busy = false
|
|
4330
|
+
await viewSettings()
|
|
4331
|
+
}
|
|
4332
|
+
// 0.9.9: a setting that saves the moment you change it (no Save button) says
|
|
4333
|
+
// so beside it: "Saved ✓" for a moment, or what went wrong, inline.
|
|
4334
|
+
const savedTimers = new Map()
|
|
4335
|
+
function markSaved(id, err = null) {
|
|
4336
|
+
const el = document.getElementById(id)
|
|
4337
|
+
const row = el && (el.closest('label.check') || el)
|
|
4338
|
+
if (!row) return
|
|
4339
|
+
let m = row.querySelector('.saved-mark')
|
|
4340
|
+
if (!m) { m = document.createElement('span'); m.className = 'saved-mark'; row.appendChild(m) } // right beside the checkbox's words
|
|
4341
|
+
m.classList.toggle('bad', !!err); m.classList.remove('gone')
|
|
4342
|
+
m.setAttribute('role', err ? 'alert' : 'status')
|
|
4343
|
+
m.textContent = err ? String(err) : 'Saved ✓'
|
|
4344
|
+
clearTimeout(savedTimers.get(id))
|
|
4345
|
+
if (!err) savedTimers.set(id, setTimeout(() => { m.classList.add('gone'); setTimeout(() => { if (m.classList.contains('gone')) m.remove() }, 400) }, 2000))
|
|
4346
|
+
}
|
|
4222
4347
|
// Claude Code's own words when a session it ran stopped with an error: text, never markup.
|
|
4223
4348
|
const headlessErrors = new Map()
|
|
4224
4349
|
function fillHeadlessErrors() {
|
|
@@ -4238,8 +4363,9 @@ async function startSend(btn) {
|
|
|
4238
4363
|
if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
|
|
4239
4364
|
startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
|
|
4240
4365
|
try {
|
|
4241
|
-
const r = await api('POST', '/v1/sessions/start', { machine: pick.m.machine, cwd: pick.repo.path, prompt: text, where: pick.where }, { timeoutMs: 60000 })
|
|
4366
|
+
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 })
|
|
4242
4367
|
if (state.drafts) delete state.drafts.start
|
|
4368
|
+
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
|
|
4243
4369
|
if (r.state === 'started' && r.sessionId) { startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
|
|
4244
4370
|
startUi.ask = { ...r, machine: r.machine || pick.m.machine }
|
|
4245
4371
|
pollStartAsk()
|
|
@@ -5227,6 +5353,15 @@ document.addEventListener('click', async (ev) => {
|
|
|
5227
5353
|
const id = btn.dataset.id || (card && card.dataset.id)
|
|
5228
5354
|
const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
|
|
5229
5355
|
try {
|
|
5356
|
+
// 0.9.9: an info card on the session page (Directory, Worktree, Session id…): the whole value, a check for a moment
|
|
5357
|
+
if (action === 'copy-fact') {
|
|
5358
|
+
const ok = await copy(btn.dataset.copy)
|
|
5359
|
+
if (ok) {
|
|
5360
|
+
clearTimeout(btn._copied); btn.classList.add('copied'); btn.dataset.tip = 'Copied'
|
|
5361
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.dataset.tip = btn.dataset.copy }, 1500)
|
|
5362
|
+
}
|
|
5363
|
+
return
|
|
5364
|
+
}
|
|
5230
5365
|
if (action === 'copy-cmd') {
|
|
5231
5366
|
const ok = await copy(btn.dataset.cmd)
|
|
5232
5367
|
if (ok && btn.classList.contains('copy-float')) { // 0.9.8: the floating copy shows a check for a moment, no tooltip
|
|
@@ -5399,7 +5534,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
5399
5534
|
// 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
|
|
5400
5535
|
if (action === 'start-pick') {
|
|
5401
5536
|
const k = btn.dataset.k, v = btn.dataset.v
|
|
5402
|
-
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'
|
|
5537
|
+
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
|
|
5403
5538
|
return render()
|
|
5404
5539
|
}
|
|
5405
5540
|
// Other folder…: open the browser at ~, walk it, use a folder
|
|
@@ -5445,18 +5580,31 @@ document.addEventListener('click', async (ev) => {
|
|
|
5445
5580
|
}
|
|
5446
5581
|
if (action === 'start-again') { startUi.ask = null; return render() }
|
|
5447
5582
|
if (action === 'start-send') return startSend(btn)
|
|
5448
|
-
if (action === 'start-
|
|
5583
|
+
if (action === 'start-approved-add') return approvedAdd()
|
|
5584
|
+
if (action === 'start-approved-remove') return approvedRemove(Number(btn.dataset.i))
|
|
5585
|
+
if (action === 'start-ask-confirm' || action === 'start-ask-decline' || action === 'start-ask-approve') {
|
|
5449
5586
|
if (!id || startUi.busy.has(id)) return
|
|
5450
5587
|
startUi.busy.add(id); render()
|
|
5451
5588
|
try {
|
|
5452
|
-
|
|
5453
|
-
|
|
5589
|
+
// "Always allow in <folder>": approves that folder here, then starts this one
|
|
5590
|
+
const r = await api('POST', '/v1/sessions/start/' + encodeURIComponent(id) + '/' + (action === 'start-ask-decline' ? 'decline' : 'confirm'), action === 'start-ask-approve' ? { approve: true } : {}, { timeoutMs: 60000 })
|
|
5591
|
+
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)
|
|
5592
|
+
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)
|
|
5454
5593
|
startUi.busy.delete(id)
|
|
5455
5594
|
if (r.state === 'started' && r.sessionId) { location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
|
|
5456
5595
|
} catch (e) { toast(e.message, 5000) }
|
|
5457
5596
|
startUi.busy.delete(id)
|
|
5458
5597
|
return render()
|
|
5459
5598
|
}
|
|
5599
|
+
// 0.9.9 Permissions of a session Leftoff runs: switch it (or, from another device, ask that Mac)
|
|
5600
|
+
if (action === 'headless-mode') {
|
|
5601
|
+
if (!id || modeBusy.has(id) || btn.getAttribute('aria-checked') === 'true') return
|
|
5602
|
+
modeBusy.set(id, btn.dataset.mode); render()
|
|
5603
|
+
try { modeToast(await switchMode(id, btn.dataset.mode)) } catch (e) { toast(e.message, 6000) }
|
|
5604
|
+
modeBusy.delete(id)
|
|
5605
|
+
return render()
|
|
5606
|
+
}
|
|
5607
|
+
if (action === 'ask-auto') return autoRestClick(btn)
|
|
5460
5608
|
if (action === 'headless-stop' || action === 'headless-run') {
|
|
5461
5609
|
btn.disabled = true
|
|
5462
5610
|
try {
|
|
@@ -5648,6 +5796,22 @@ document.addEventListener('click', async (ev) => {
|
|
|
5648
5796
|
return paintDigestFilter()
|
|
5649
5797
|
}
|
|
5650
5798
|
// ---- Needs you (v0.4.4)
|
|
5799
|
+
// 0.9.9 the docked ask: open a long command in place; reply instead (the composer comes back); back to the card
|
|
5800
|
+
if (action === 'ask-expand') {
|
|
5801
|
+
const code = btn.closest('.ask-dock') && btn.closest('.ask-dock').querySelector('.ask-detail')
|
|
5802
|
+
const open = !askUi.open.has(btn.dataset.ask)
|
|
5803
|
+
if (open) askUi.open.add(btn.dataset.ask); else askUi.open.delete(btn.dataset.ask)
|
|
5804
|
+
if (code) code.classList.toggle('open', open)
|
|
5805
|
+
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.textContent = open ? 'Show less' : 'Show all'
|
|
5806
|
+
return
|
|
5807
|
+
}
|
|
5808
|
+
if (action === 'ask-reply-instead' || action === 'ask-back') {
|
|
5809
|
+
if (action === 'ask-reply-instead') askUi.reply.add(btn.dataset.ask); else askUi.reply.delete(btn.dataset.ask)
|
|
5810
|
+
await render()
|
|
5811
|
+
if (action === 'ask-reply-instead') { const ta = document.querySelector('.live-box .composer-input'); if (ta && !ta.disabled) ta.focus({ preventScroll: true }) }
|
|
5812
|
+
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 }) }
|
|
5813
|
+
return
|
|
5814
|
+
}
|
|
5651
5815
|
if (action === 'ask-deny-open' || action === 'ask-deny-close') {
|
|
5652
5816
|
const need = btn.closest('.need')
|
|
5653
5817
|
const form = need && need.querySelector('.deny-form')
|
|
@@ -5663,6 +5827,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
5663
5827
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
5664
5828
|
if (action === 'reply-send') return replyClick(btn)
|
|
5665
5829
|
if (action === 'live-send') return liveSendClick(btn)
|
|
5830
|
+
if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
|
|
5666
5831
|
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
|
|
5667
5832
|
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
5668
5833
|
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
@@ -5797,6 +5962,28 @@ document.addEventListener('click', async (ev) => {
|
|
|
5797
5962
|
|
|
5798
5963
|
// Answer a permission prompt or a question from the board. The daemon hands it
|
|
5799
5964
|
// to the waiting hook or mod; the terminal's own prompt may already have won.
|
|
5965
|
+
// 0.9.9 Permissions mid-session (src/session-start.js switchMode): switched, or waiting for a yes at that Mac.
|
|
5966
|
+
function switchMode(id, mode) { return api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/mode', { mode }, { timeoutMs: 30000 }) }
|
|
5967
|
+
function modeToast(r) {
|
|
5968
|
+
if (r && r.state === 'pending') toast('Asked ' + (r.machine || 'that Mac') + ': someone there says yes, then it switches', 5000)
|
|
5969
|
+
else toast('Permissions: ' + modeLabel(r && r.mode) + (r && r.running === false ? ' (when it runs again)' : ''), 3000)
|
|
5970
|
+
}
|
|
5971
|
+
// "Auto-approve the rest" on a permission card: allow this call, and switch the session to Auto.
|
|
5972
|
+
async function autoRestClick(btn) {
|
|
5973
|
+
const need = btn.closest('.need')
|
|
5974
|
+
if (!need || !need.dataset.ask) return
|
|
5975
|
+
const sid = need.dataset.id
|
|
5976
|
+
need.querySelectorAll('button, input, textarea').forEach((el) => { if (!el.classList.contains('go')) el.disabled = true })
|
|
5977
|
+
btn.textContent = 'Switching…'
|
|
5978
|
+
let sw = null
|
|
5979
|
+
try { sw = await switchMode(sid, 'auto') } catch (e) { toast(e.message, 6000); render(); return }
|
|
5980
|
+
try {
|
|
5981
|
+
await api('POST', '/v1/asks/' + encodeURIComponent(need.dataset.ask) + '/answer', { action: 'allow', auto: true }) // 0.9.9: the thread's row says Auto-approved
|
|
5982
|
+
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)
|
|
5983
|
+
} catch (e) { toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200) }
|
|
5984
|
+
render()
|
|
5985
|
+
}
|
|
5986
|
+
|
|
5800
5987
|
async function answerClick(btn, action) {
|
|
5801
5988
|
const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
|
|
5802
5989
|
if (!need) return
|
|
@@ -5827,6 +6014,7 @@ async function answerClick(btn, action) {
|
|
|
5827
6014
|
try {
|
|
5828
6015
|
const r = await api('POST', '/v1/asks/' + encodeURIComponent(askId) + '/answer', body)
|
|
5829
6016
|
toast(r.summary + (r.delivered ? ' · Claude Code has it' : ' · waiting for Claude Code to pick it up'), 3600)
|
|
6017
|
+
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // a typed answer sent: a phone's keyboard closes
|
|
5830
6018
|
for (const k of Object.keys(state.drafts)) if (k.includes(askId)) delete state.drafts[k]
|
|
5831
6019
|
} catch (e) {
|
|
5832
6020
|
toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
|
|
@@ -5850,6 +6038,7 @@ async function replyClick(btn) {
|
|
|
5850
6038
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
|
|
5851
6039
|
delete state.drafts['reply:' + need.dataset.id]
|
|
5852
6040
|
toast(r.queuedBehindTurn ? 'Sent · Claude reads it when the current turn ends' : 'Sent · Claude has it as its next prompt', 3600)
|
|
6041
|
+
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // a phone's keyboard closes
|
|
5853
6042
|
} catch (e) { toast(e.message, 4200) } finally { render() }
|
|
5854
6043
|
}
|
|
5855
6044
|
|
|
@@ -6230,13 +6419,17 @@ document.addEventListener('change', async (ev) => {
|
|
|
6230
6419
|
}
|
|
6231
6420
|
if (ev.target.id === 'f-start-allow') { // 0.9.9: this Mac only
|
|
6232
6421
|
const cb = ev.target
|
|
6233
|
-
|
|
6234
|
-
|
|
6422
|
+
let err = null
|
|
6423
|
+
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) }
|
|
6424
|
+
await viewSettings()
|
|
6425
|
+
return markSaved('f-start-allow', err)
|
|
6235
6426
|
}
|
|
6236
6427
|
if (ev.target.id === 'f-peer-allow') {
|
|
6237
6428
|
const cb = ev.target
|
|
6238
|
-
|
|
6239
|
-
|
|
6429
|
+
let err = null
|
|
6430
|
+
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) }
|
|
6431
|
+
await viewSettings()
|
|
6432
|
+
return markSaved('f-peer-allow', err)
|
|
6240
6433
|
}
|
|
6241
6434
|
if (ev.target.dataset && ev.target.dataset.list && ev.target.type === 'checkbox') {
|
|
6242
6435
|
const cb = ev.target, done = cb.checked, li = cb.closest('li')
|
package/ui/composer.js
CHANGED
|
@@ -34,6 +34,18 @@ export function isTouchUi(matchMedia) {
|
|
|
34
34
|
} catch { return false }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// 0.9.9 After a send that went through, a phone's keyboard closes: the text
|
|
38
|
+
// box lets go of focus. On a Mac (a fine pointer) focus stays, so you can keep
|
|
39
|
+
// typing. A send that failed keeps its text and its focus everywhere.
|
|
40
|
+
export function keepFocusAfterSend({ ok = false, touch = false } = {}) { return !ok || !touch }
|
|
41
|
+
// Let go of the focused text box (the on-screen keyboard closes); true if one had it.
|
|
42
|
+
export function letGoOfKeyboard(doc) {
|
|
43
|
+
const a = doc && doc.activeElement
|
|
44
|
+
if (!a || a === doc.body || !/^(TEXTAREA|INPUT)$/.test(String(a.tagName || '').toUpperCase()) || typeof a.blur !== 'function') return false
|
|
45
|
+
a.blur()
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
// The height the box should be for its content: scrollHeight is the
|
|
38
50
|
// textarea's at height:auto (padding included), lineHeight and padY in px.
|
|
39
51
|
// -> { height, scroll } where scroll says the content is taller than the cap.
|
package/ui/facts.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 0.9.9 the session page's info cards (Machine, Repo, Directory, Session id…).
|
|
2
|
+
// Pure (the unit tests read it). A card whose value is an id or a path is a
|
|
3
|
+
// button: tap or click copies the whole value (the toast says it), the whole
|
|
4
|
+
// value is its data-tip, and it is a 44px target. A long path shows its start
|
|
5
|
+
// and its end, the part that tells worktrees apart.
|
|
6
|
+
import { icon } from './icons.js'
|
|
7
|
+
import { midClip } from './live-view.js'
|
|
8
|
+
|
|
9
|
+
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
10
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
11
|
+
|
|
12
|
+
// factHtml('Turns', '3') or factHtml('Directory', cwd, { copy: cwd, what: 'directory', clip: 34 })
|
|
13
|
+
// copy: the full value to copy (a card without one is plain); what: its name in "Copy <what>"
|
|
14
|
+
// shown: what the card shows when it isn't the copied value itself (the worktree's name for its path)
|
|
15
|
+
// A long path: its start, then its last folder whole ("/Users/you…/add-a-joke-0994").
|
|
16
|
+
export function clipPath(p, n = 34) {
|
|
17
|
+
const t = String(p == null ? '' : p)
|
|
18
|
+
if (t.length <= n) return t
|
|
19
|
+
const i = t.replace(/\/+$/, '').lastIndexOf('/')
|
|
20
|
+
const last = i >= 0 ? t.slice(i + 1) : ''
|
|
21
|
+
if (!last || last.length > n - 8) return midClip(t, n)
|
|
22
|
+
return t.slice(0, n - last.length - 2) + '…/' + last
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function factHtml(k, v, { cls = '', copy = null, what = null, shown = null, clip = 0 } = {}) {
|
|
26
|
+
const value = String(v == null ? '' : v)
|
|
27
|
+
const view = shown != null ? String(shown) : clip ? (value.includes('/') ? clipPath(value, clip) : midClip(value, clip)) : value
|
|
28
|
+
if (copy == null || copy === '') {
|
|
29
|
+
return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v">' + esc(view) + '</div></div>'
|
|
30
|
+
}
|
|
31
|
+
const full = String(copy)
|
|
32
|
+
return '<button type="button" class="fact copyable' + (cls ? ' ' + cls : '') + '" data-action="copy-fact" data-copy="' + esc(full) + '" data-tip="' + esc(full) + '" aria-label="' + esc('Copy ' + (what || k.toLowerCase()) + ': ' + full) + '">' +
|
|
33
|
+
'<span class="k">' + esc(k) + '<span class="fact-ic" aria-hidden="true">' + icon('copy') + '</span></span><span class="v">' + esc(view) + '</span></button>'
|
|
34
|
+
}
|
package/ui/icons.js
CHANGED
|
@@ -17,6 +17,7 @@ export const ICONS = {
|
|
|
17
17
|
"qr-code": "<rect width=\"5\" height=\"5\" x=\"3\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"16\" y=\"3\" rx=\"1\"/> <rect width=\"5\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/> <path d=\"M21 16h-3a2 2 0 0 0-2 2v3\"/> <path d=\"M21 21v.01\"/> <path d=\"M12 7v3a2 2 0 0 1-2 2H7\"/> <path d=\"M3 12h.01\"/> <path d=\"M12 3h.01\"/> <path d=\"M12 16v.01\"/> <path d=\"M16 12h1\"/> <path d=\"M21 12v.01\"/> <path d=\"M12 21v-1\"/>",
|
|
18
18
|
"refresh-cw": "<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/> <path d=\"M21 3v5h-5\"/> <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/> <path d=\"M8 16H3v5\"/>",
|
|
19
19
|
"check": "<path d=\"M20 6 9 17l-5-5\"/>",
|
|
20
|
+
"lock": "<rect width=\"18\" height=\"11\" x=\"3\" y=\"11\" rx=\"2\" ry=\"2\"/> <path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/>",
|
|
20
21
|
"folder-sync": "<path d=\"M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v.5\"/> <path d=\"M12 10v4h4\"/> <path d=\"m12 14 1.535-1.605a5 5 0 0 1 8 1.5\"/> <path d=\"M22 22v-4h-4\"/> <path d=\"m22 18-1.535 1.605a5 5 0 0 1-8-1.5\"/>",
|
|
21
22
|
"bell": "<path d=\"M10.268 21a2 2 0 0 0 3.464 0\"/> <path d=\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\"/>",
|
|
22
23
|
"x": "<path d=\"M18 6 6 18\"/> <path d=\"m6 6 12 12\"/>",
|