@leftoff/cli 0.9.9-beta.4 → 0.9.9-beta.6
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 +5 -1
- package/bin/leftoff.js +417 -417
- package/docs/SECURITY.md +4 -2
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +2 -2
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +70 -2
- package/ui/app.js +87 -4
- package/ui/index.html +1 -0
- package/ui/start.js +118 -10
package/docs/SECURITY.md
CHANGED
|
@@ -111,9 +111,11 @@ Live link is opt-in, per session: only sessions started with `leftoff live` have
|
|
|
111
111
|
**New session** starts Claude Code on a Mac you pick, headless (`claude -p` with stream-json in and out, run by that Mac's daemon). That is more than answering a prompt (a new agent in your repo, on your Claude plan), so:
|
|
112
112
|
|
|
113
113
|
- **This Mac's own board** starts it at once. **A phone or another Mac** may start one only when that Mac has *Allow starting sessions from other devices* on (Settings → Privacy & security, off by default) **and** *Trust my paired devices* is on. The switch is kept on that Mac (`~/.config/checkpoints/local/`, not the synced folder, so turning it on one Mac never turns it on another) and only that Mac's own browser can change it (loopback; a phone, the relay or a peer gets 403). Every start from another device shows a notification on the Mac. With the switch off, the request waits up to 5 minutes for a yes on that Mac's board, like Remote Restore. At most 3 wait at once; 3 starts at once, then 1 a minute.
|
|
114
|
-
- **
|
|
114
|
+
- **The repo list is folders that Mac has already run Claude Code in** (from its session records; no typed paths). A headless run loads a repo's hooks and `.mcp.json` without the trust dialog, so a folder Claude Code never ran in is treated as a first time:
|
|
115
|
+
- **Other folder…** browses that Mac's folders: names only (never a file or what's in one), inside your home by real path (a symlink out of home is refused or left out), never the synced Leftoff folder or anything in it, no hidden folders, `node_modules` or `Library`, at most 500 per listing. Another Mac's are read over the peer link (`fs.dirs`, not an open action, the same as any read), a phone's through the relay. Nothing is written.
|
|
116
|
+
- **A first time starts at once only from that Mac's own board** (loopback, or your hosted board open on it): you are the trust prompt. From a phone or another Mac it **always** waits for a yes on that Mac's board, even with *Allow starting sessions from other devices* on, and the ask and the notification say "first time in <folder>; this runs its hooks". After one run it's in the list like the rest. Home itself is refused; a folder that isn't a git repo can't get a new worktree.
|
|
115
117
|
- **Permission mode `default`, always passed.** Reads in the repo run; every edit and command becomes a Needs-you request (here, the phone, any board; first answer wins) and waits for it, with no 9.5-minute cap since nobody is at a terminal. A request settled any other way is a no. Never `bypassPermissions` or `auto`. AskUserQuestion is answered "ask in words", so Claude asks in plain text and you reply in Live.
|
|
116
|
-
- **Other Macs** reach it over the signed, encrypted peer link (`session.start`, `session.start.get`, `session.headless`; not open actions, so the target Mac's *Allow other machines* applies too).
|
|
118
|
+
- **Other Macs** reach it over the signed, encrypted peer link (`session.start`, `session.start.get`, `session.headless`, `fs.dirs`; not open actions, so the target Mac's *Allow other machines* applies too).
|
|
117
119
|
- **Nothing new in the folder.** The card, its title and checkpoints as for any session, plus `startedFrom` (device and how it came, not the prompt) and the run's state. Stream output, stdin and stderr stay in the daemon's memory; Claude's transcript stays in `~/.claude` on that Mac; the Live thread and attachments stay where Live keeps them (`~/.config/checkpoints`).
|
|
118
120
|
- **Keeping the Mac awake.** While a turn runs, the daemon holds `caffeinate -i -w <pid>` (idle sleep waits; a closed lid on battery still sleeps). Stop interrupts the turn, closes stdin, then SIGINT, then SIGTERM. A daemon that stops pauses its sessions.
|
|
119
121
|
- **Logged:** one daemon-log line per start, stop and permission request (session id and tool, never prompt text).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.9-beta.
|
|
3
|
+
"version": "0.9.9-beta.6",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/core.js
CHANGED
|
@@ -914,8 +914,8 @@ export function applyEvent(s, ev) {
|
|
|
914
914
|
s.inTurn = false
|
|
915
915
|
s.activeSince = null
|
|
916
916
|
s.needsInput = false
|
|
917
|
-
if (d.countTurn !== false) s.turns = (s.turns || 0) + 1 // false: Codex already counted it at the start
|
|
918
|
-
s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1
|
|
917
|
+
if (d.countTurn !== false && !d.again) s.turns = (s.turns || 0) + 1 // false: Codex already counted it at the start
|
|
918
|
+
if (!d.again) s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1 // again (0.9.9): a late report of a turn already ended and counted
|
|
919
919
|
s.backgroundTasks = Number(d.backgroundTasks || 0) + Number(d.sessionCrons || 0)
|
|
920
920
|
if (d.lastAssistantPreview) s.lastPreview = clip(d.lastAssistantPreview, 280)
|
|
921
921
|
// Claude ended the turn on a question to you (v0.4.4): the board offers a reply.
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.9-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.9-beta.6' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.9-beta.
|
|
3
|
+
"version": "0.9.9-beta.6",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -2869,7 +2869,6 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
2869
2869
|
.start-ask { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); font-size: 13.5px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
2870
2870
|
.start-ask.wait { border-color: var(--yellow); background: var(--tint-yellow); }
|
|
2871
2871
|
.start-ask.bad { border-color: var(--red); background: var(--tint-red); }
|
|
2872
|
-
.btn.start-new { min-height: 36px; }
|
|
2873
2872
|
.headless-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 4px 0 10px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); font-size: 13px; color: var(--text2); }
|
|
2874
2873
|
.headless-bar .grow { flex: 1 1 200px; display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
|
|
2875
2874
|
.headless-bar.running { border-color: var(--green); background: var(--tint-green); }
|
|
@@ -2880,6 +2879,75 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
2880
2879
|
.start-opts { grid-template-columns: 1fr; }
|
|
2881
2880
|
.start-where { grid-template-columns: 1fr 1fr; }
|
|
2882
2881
|
.start-opt { min-height: 48px; }
|
|
2883
|
-
.btn.start-new { min-height: 44px; }
|
|
2884
2882
|
.start-ask-banner .btn, .start-ask .btn { min-height: 44px; }
|
|
2885
2883
|
}
|
|
2884
|
+
|
|
2885
|
+
/* 0.9.9 New session: a + in the top bar on a phone (next to search); on a
|
|
2886
|
+
desktop the button in the board's header row, like the buttons beside it */
|
|
2887
|
+
.topright .newbtn { display: none; }
|
|
2888
|
+
.newbtn .lbl { display: none; }
|
|
2889
|
+
.newbtn[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); right: 0; width: max-content; padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; white-space: nowrap; pointer-events: none; z-index: 20; }
|
|
2890
|
+
@media (hover: hover) { .newbtn[data-tip]:hover::after { display: block; } }
|
|
2891
|
+
@media (max-width: 640px) {
|
|
2892
|
+
.topright .newbtn { display: grid; place-items: center; width: 44px; height: 44px; }
|
|
2893
|
+
.pagehead .actions .start-new { display: none; }
|
|
2894
|
+
}
|
|
2895
|
+
/* A request banner on a phone (start or Restore asked from another device,
|
|
2896
|
+
the restore banner): the words use the whole width, the buttons a row of
|
|
2897
|
+
their own below, 44px tall */
|
|
2898
|
+
@media (max-width: 640px) {
|
|
2899
|
+
.start-ask-banner > .grow, .restore-ask > .grow, .banner.restore:not(.open) > .grow, .pair-ask > .grow { flex: 1 1 100%; }
|
|
2900
|
+
.start-ask-banner > .btn, .restore-ask > .btn, .banner.restore:not(.open) > .btn:not(.x), .pair-ask > .btn { flex: 1 1 0; justify-content: center; min-height: 44px; }
|
|
2901
|
+
.restore-ask > .rs-m { flex: 1 1 100%; }
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
/* 0.9.9 Other folder…: the inline folder browser (full width; no popover) */
|
|
2905
|
+
.start-other .start-opt-main { display: inline-flex; align-items: center; gap: 6px; }
|
|
2906
|
+
.start-other .ic { width: 16px; height: 16px; color: var(--muted); }
|
|
2907
|
+
.start-other { border-style: dashed; }
|
|
2908
|
+
.start-badge { display: inline-block; margin-left: 4px; padding: 0 6px; border-radius: 6px; border: 1px solid var(--line); font-size: 11px; font-weight: 600; line-height: 18px; color: var(--muted); background: var(--bg2); vertical-align: 1px; }
|
|
2909
|
+
.start-badge.git { color: var(--green); border-color: color-mix(in srgb, var(--green) 40%, var(--line)); }
|
|
2910
|
+
.start-badge.new { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 50%, var(--line)); }
|
|
2911
|
+
.start-first { color: var(--text); }
|
|
2912
|
+
.start-browse { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg2); min-width: 0; }
|
|
2913
|
+
.sb-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-width: 0; }
|
|
2914
|
+
.sb-crumb { min-height: 44px; min-width: 44px; padding: 0 8px; border: 0; border-radius: 8px; background: none; color: var(--accent); font: inherit; font-size: 14px; cursor: pointer; overflow-wrap: anywhere; text-align: left; }
|
|
2915
|
+
.sb-crumb[aria-current] { color: var(--text); font-weight: 600; cursor: default; }
|
|
2916
|
+
.sb-crumb:hover:not([aria-current]) { background: var(--card); }
|
|
2917
|
+
.sb-sep { color: var(--faint); }
|
|
2918
|
+
.sb-here { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
2919
|
+
.sb-here .btn { min-height: 44px; }
|
|
2920
|
+
.sb-cur { flex: 1 1 160px; min-width: 0; font-size: 13px; color: var(--text2); overflow-wrap: anywhere; }
|
|
2921
|
+
.sb-cur-name { font-family: var(--mono); font-size: 12.5px; }
|
|
2922
|
+
.sb-cuts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
2923
|
+
.sb-cuts-l { font-size: 12px; color: var(--muted); }
|
|
2924
|
+
.sb-cut { min-height: 44px; min-width: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 22px; background: var(--card); color: var(--text2); font: inherit; font-family: var(--mono); font-size: 12.5px; cursor: pointer; }
|
|
2925
|
+
.sb-cut:hover { border-color: var(--line-strong); }
|
|
2926
|
+
.sb-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 6px; max-height: 360px; overflow: auto; }
|
|
2927
|
+
.sb-dir { width: 100%; min-height: 44px; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--text); font: inherit; font-size: 14px; text-align: left; cursor: pointer; min-width: 0; }
|
|
2928
|
+
.sb-dir:hover { border-color: var(--line-strong); }
|
|
2929
|
+
.sb-dir .ic { width: 16px; height: 16px; flex: none; color: var(--muted); }
|
|
2930
|
+
.sb-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2931
|
+
.sb-dir .sb-go { color: var(--faint); }
|
|
2932
|
+
.sb-foot { display: flex; justify-content: flex-end; }
|
|
2933
|
+
.sb-foot .btn { min-height: 44px; }
|
|
2934
|
+
.sb-crumb:focus-visible, .sb-cut:focus-visible, .sb-dir:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2935
|
+
/* Go to a folder: type a path you know */
|
|
2936
|
+
.sb-path { display: flex; flex-direction: column; gap: 4px; margin: 0; }
|
|
2937
|
+
.sb-path-l { font-size: 12px; color: var(--muted); }
|
|
2938
|
+
.sb-path-row { display: flex; gap: 8px; min-width: 0; }
|
|
2939
|
+
.sb-path-in { flex: 1 1 auto; min-width: 0; min-height: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--card); color: var(--text); font: inherit; font-family: var(--mono); font-size: 16px; }
|
|
2940
|
+
.sb-path-in:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
2941
|
+
.sb-path-in[aria-invalid="true"] { border-color: var(--red); }
|
|
2942
|
+
.sb-path-row .btn { min-height: 44px; min-width: 44px; justify-content: center; }
|
|
2943
|
+
.sb-path-err { margin: 0; }
|
|
2944
|
+
.start-custom .start-opt-main { white-space: normal; }
|
|
2945
|
+
@media (max-width: 640px) {
|
|
2946
|
+
.start-browse { padding: 10px; margin-left: -4px; margin-right: -4px; }
|
|
2947
|
+
.sb-list { grid-template-columns: 1fr; max-height: none; overflow: visible; }
|
|
2948
|
+
.sb-here .sb-use { flex: 1 1 100%; justify-content: center; }
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
/* 0.9.9 asked at pairing time: may other devices start sessions here? */
|
|
2952
|
+
.pair-ask .grow { min-width: 0; }
|
|
2953
|
+
@media (pointer: coarse) { .pair-ask > .btn { min-height: 44px; } }
|
package/ui/app.js
CHANGED
|
@@ -16,7 +16,7 @@ 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
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 } from './start.js' // 0.9.9 New session
|
|
19
|
+
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath } 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
22
|
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
@@ -841,6 +841,7 @@ async function viewBoard() {
|
|
|
841
841
|
'<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
|
|
842
842
|
'<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
|
|
843
843
|
html += syncDetails() + staleLine(sessions)
|
|
844
|
+
html += startPairAskHtml(state.starts, { esc, local: startViewerLocal(state.starts && state.starts.viewer), busy: startUi.pairBusy }) // 0.9.9: asked once, at pairing time
|
|
844
845
|
html += restoreAsksBanner() + startAsksBanner(state.starts, { esc, icon, local: startViewerLocal(state.starts && state.starts.viewer), clock, busy: startUi.busy }) + restoreBanner()
|
|
845
846
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
846
847
|
const needs = needsItems(sessions)
|
|
@@ -945,6 +946,7 @@ function boardSearchWire() {
|
|
|
945
946
|
try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
|
|
946
947
|
state.route = parseRoute()
|
|
947
948
|
await viewBoard() // the box isn't rebuilt, so the caret stays wherever you've typed to since
|
|
949
|
+
fillStartTexts($app, startTexts(state.starts, {}))
|
|
948
950
|
}, 160)
|
|
949
951
|
})
|
|
950
952
|
q.addEventListener('keydown', (ev) => {
|
|
@@ -4155,6 +4157,7 @@ async function render() {
|
|
|
4155
4157
|
else await viewBoard()
|
|
4156
4158
|
tabsAfterRender()
|
|
4157
4159
|
fillHeadlessErrors()
|
|
4160
|
+
if (r.name !== 'new') fillStartTexts($app, startTexts(state.starts, {})) // 0.9.9: a waiting start's folder, as text
|
|
4158
4161
|
// a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
|
|
4159
4162
|
for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
|
|
4160
4163
|
} catch (e) {
|
|
@@ -4167,15 +4170,55 @@ async function render() {
|
|
|
4167
4170
|
|
|
4168
4171
|
// this Mac's own browser, or the hosted board open on it (the direct door)
|
|
4169
4172
|
function startViewerLocal(v) { return startViewerAt(v, state.viewerLocal !== false) }
|
|
4170
|
-
const startUi = { data: null, machine: null, repo: null, where: 'repo', allRepos: false, ask: null, busy: new Set(), sending: false, poll: null }
|
|
4173
|
+
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 }
|
|
4171
4174
|
async function viewNewSession() {
|
|
4172
4175
|
try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
|
|
4173
4176
|
const fa = document.activeElement
|
|
4174
4177
|
const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
4175
4178
|
$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 })
|
|
4179
|
+
fillStartTexts($app, startTexts(startUi.data, startUi)) // folder names and paths: text, never markup
|
|
4180
|
+
{ 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
|
|
4176
4181
|
const ta = $app.querySelector('.start-text')
|
|
4177
4182
|
if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
|
|
4178
4183
|
}
|
|
4184
|
+
// Other folder…: list one folder on the picked Mac (that Mac's own disk, over the peer link for another Mac)
|
|
4185
|
+
async function startBrowse(machine, p, { typed = false } = {}) {
|
|
4186
|
+
const seq = (startUi.browseSeq = (startUi.browseSeq || 0) + 1)
|
|
4187
|
+
const prev = startUi.browse && startUi.browse.machine === machine ? startUi.browse : {}
|
|
4188
|
+
startUi.browse = { ...prev, machine, loading: true, error: null, pathError: null }
|
|
4189
|
+
if (state.route && state.route.name === 'new') render()
|
|
4190
|
+
try {
|
|
4191
|
+
const d = await api('GET', '/v1/start/dirs?machine=' + encodeURIComponent(machine) + (p ? '&path=' + encodeURIComponent(p) : ''), undefined, { timeoutMs: 20000 })
|
|
4192
|
+
if (seq !== startUi.browseSeq || !startUi.browse) return
|
|
4193
|
+
startUi.browse = { machine, loading: false, error: null, pathError: null, typed: typed ? '' : startUi.browse.typed, data: d }
|
|
4194
|
+
} catch (e) {
|
|
4195
|
+
if (seq !== startUi.browseSeq || !startUi.browse) return
|
|
4196
|
+
// a typed path that isn't one you can open: said under the box, and the folder you were in stays
|
|
4197
|
+
startUi.browse = typed ? { ...startUi.browse, loading: false, pathError: e.message } : { ...startUi.browse, loading: false, error: e.message }
|
|
4198
|
+
}
|
|
4199
|
+
if (state.route && state.route.name === 'new') {
|
|
4200
|
+
await render() // the page re-reads before it redraws: focus after
|
|
4201
|
+
const pi = typed && startUi.browse.pathError ? $app.querySelector('.sb-path-in') : null
|
|
4202
|
+
const f = pi || (startUi.browseFocus ? $app.querySelector('.sb-crumb[aria-current]') : null)
|
|
4203
|
+
if (f) f.focus({ preventScroll: true })
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
// "Go to a folder": Enter or Go takes the browser there (the Mac checks it like any other step)
|
|
4207
|
+
document.addEventListener('submit', (ev) => {
|
|
4208
|
+
const f = ev.target && ev.target.closest && ev.target.closest('form[data-form="start-browse-path"]')
|
|
4209
|
+
if (!f) return
|
|
4210
|
+
ev.preventDefault()
|
|
4211
|
+
const b = startUi.browse, inp = f.querySelector('.sb-path-in')
|
|
4212
|
+
const want = typedPath(inp ? inp.value : '')
|
|
4213
|
+
if (!b) return
|
|
4214
|
+
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 }
|
|
4215
|
+
startUi.browseFocus = true
|
|
4216
|
+
startBrowse(b.machine, want, { typed: true })
|
|
4217
|
+
})
|
|
4218
|
+
document.addEventListener('input', (ev) => {
|
|
4219
|
+
if (!ev.target || !ev.target.classList || !ev.target.classList.contains('sb-path-in') || !startUi.browse) return
|
|
4220
|
+
startUi.browse.typed = ev.target.value // kept across the board's redraws
|
|
4221
|
+
})
|
|
4179
4222
|
// Claude Code's own words when a session it ran stopped with an error: text, never markup.
|
|
4180
4223
|
const headlessErrors = new Map()
|
|
4181
4224
|
function fillHeadlessErrors() {
|
|
@@ -5356,10 +5399,50 @@ document.addEventListener('click', async (ev) => {
|
|
|
5356
5399
|
// 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
|
|
5357
5400
|
if (action === 'start-pick') {
|
|
5358
5401
|
const k = btn.dataset.k, v = btn.dataset.v
|
|
5359
|
-
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false } else if (k === 'repo') startUi.repo = v; else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'
|
|
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'
|
|
5360
5403
|
return render()
|
|
5361
5404
|
}
|
|
5405
|
+
// Other folder…: open the browser at ~, walk it, use a folder
|
|
5406
|
+
if (action === 'start-browse') {
|
|
5407
|
+
const pick = startPick(startUi.data, startUi)
|
|
5408
|
+
if (!pick.m) return
|
|
5409
|
+
if (startUi.browse && startUi.browse.machine === pick.m.machine) { startUi.browse = null; return render() }
|
|
5410
|
+
startUi.browseFocus = false
|
|
5411
|
+
return startBrowse(pick.m.machine, null)
|
|
5412
|
+
}
|
|
5413
|
+
if (action === 'start-browse-go' || action === 'start-browse-up') {
|
|
5414
|
+
const b = startUi.browse
|
|
5415
|
+
const to = action === 'start-browse-up' ? (b && b.data && b.data.parent ? { path: b.data.parent } : null) : browseTarget(b, btn)
|
|
5416
|
+
if (!b || !to) return
|
|
5417
|
+
startUi.browseFocus = true
|
|
5418
|
+
return startBrowse(b.machine, to.path)
|
|
5419
|
+
}
|
|
5420
|
+
if (action === 'start-browse-close') { startUi.browse = null; return render() }
|
|
5421
|
+
if (action === 'start-browse-use') {
|
|
5422
|
+
const b = startUi.browse, d = b && b.data
|
|
5423
|
+
if (!d || !d.parent) return
|
|
5424
|
+
const known = startPick(startUi.data, { ...startUi, machine: b.machine }).m
|
|
5425
|
+
if (d.ran || (known && known.repos.some((r) => r.path === d.path))) startUi.custom = null
|
|
5426
|
+
else startUi.custom = { machine: b.machine, path: d.path, name: d.crumbs[d.crumbs.length - 1].name, label: d.label, git: !!d.git }
|
|
5427
|
+
startUi.repo = d.path
|
|
5428
|
+
if (!d.git) startUi.where = 'repo'
|
|
5429
|
+
startUi.browse = null
|
|
5430
|
+
render()
|
|
5431
|
+
const ta = $app.querySelector('.start-text'); if (ta && !ta.disabled) ta.focus({ preventScroll: true })
|
|
5432
|
+
return
|
|
5433
|
+
}
|
|
5362
5434
|
if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
|
|
5435
|
+
if (action === 'pair-ask-yes' || action === 'pair-ask-no') {
|
|
5436
|
+
if (startUi.pairBusy) return
|
|
5437
|
+
startUi.pairBusy = true
|
|
5438
|
+
try {
|
|
5439
|
+
const r = await api('POST', '/v1/starts/pair-ask', { yes: action === 'pair-ask-yes' })
|
|
5440
|
+
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)
|
|
5441
|
+
} catch (e) { toast(e.message, 5000) }
|
|
5442
|
+
startUi.pairBusy = false
|
|
5443
|
+
try { state.starts = await api('GET', '/v1/starts') } catch {}
|
|
5444
|
+
return render()
|
|
5445
|
+
}
|
|
5363
5446
|
if (action === 'start-again') { startUi.ask = null; return render() }
|
|
5364
5447
|
if (action === 'start-send') return startSend(btn)
|
|
5365
5448
|
if (action === 'start-ask-confirm' || action === 'start-ask-decline') {
|
|
@@ -6107,7 +6190,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
6107
6190
|
|
|
6108
6191
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
6109
6192
|
let pending = null
|
|
6110
|
-
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)
|
|
6193
|
+
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)
|
|
6111
6194
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
6112
6195
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
6113
6196
|
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) }
|
package/ui/index.html
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
</nav>
|
|
37
37
|
<div class="topright">
|
|
38
38
|
<button type="button" class="searchbtn" id="searchBtn" title="Search (⌘K)" aria-label="Search" aria-haspopup="dialog" aria-keyshortcuts="Meta+K Control+K /"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/> <path d="m21 21-4.3-4.3"/></svg><span class="sb-lbl">Search</span><kbd class="sb-kbd" id="searchKbd">⌘K</kbd></button>
|
|
39
|
+
<a href="#/new" data-route="new" id="newTab" class="iconbtn newbtn" data-tip="New session" aria-label="New session"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12h14"/> <path d="M12 5v14"/></svg><span class="lbl">New session</span></a>
|
|
39
40
|
<button type="button" class="health" id="health" title="Status" aria-haspopup="dialog" aria-expanded="false" aria-controls="statusPop"><span class="dot"></span><span class="label">connecting…</span></button>
|
|
40
41
|
<a href="#/whats-new" data-route="whats-new" id="wnTab" class="iconbtn" title="What’s new" aria-label="What’s new"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span class="lbl">What’s new</span><span class="newdot" hidden></span></a>
|
|
41
42
|
<a href="#/settings" data-route="settings" id="setTab" class="iconbtn" title="Settings" aria-label="Settings"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span class="lbl">Settings</span><span class="topdot" hidden></span></a>
|
package/ui/start.js
CHANGED
|
@@ -21,8 +21,11 @@ export function startPick(data, ui = {}) {
|
|
|
21
21
|
const ms = (data && data.machines) || []
|
|
22
22
|
const m = ms.find((x) => x.machine === ui.machine && x.online) || ms.find((x) => x.here && x.online && x.repos.length) || ms.find((x) => x.online && x.repos.length) || ms.find((x) => x.here) || ms[0] || null
|
|
23
23
|
const repos = m ? m.repos : []
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Other folder…: a folder that Mac hasn't run Claude Code in (ui.custom), picked in the browser
|
|
25
|
+
const custom = m && ui.custom && ui.custom.machine === m.machine && ui.custom.path === ui.repo && !repos.some((r) => r.path === ui.custom.path) ? { ...ui.custom, isNew: true } : null
|
|
26
|
+
const repo = repos.find((r) => r.path === ui.repo) || custom || repos[0] || null
|
|
27
|
+
const where = ui.where === 'worktree' && !(repo && repo.git === false) ? 'worktree' : 'repo'
|
|
28
|
+
return { m, repo, where }
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
// What happens when you press ↑, in one sentence (or why you can't).
|
|
@@ -31,7 +34,10 @@ export function startRule(data, pick, { local = true } = {}) {
|
|
|
31
34
|
if (!m) return { ok: false, text: 'No Mac to start on.' }
|
|
32
35
|
if (!local && !data.trust) return { ok: false, text: 'Turn on Trust my paired devices on a Mac first (Settings → Privacy & security), so you can answer its prompts from here.' }
|
|
33
36
|
if (!m.online) return { ok: false, text: m.machine + ' is offline right now.' }
|
|
34
|
-
if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet.
|
|
37
|
+
if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet. Pick Other folder…, or open Claude Code there once and it shows up here.' }
|
|
38
|
+
// a folder that Mac hasn't run Claude Code in: its hooks run, so only a person at that Mac says yes
|
|
39
|
+
if (pick.repo.isNew && m.here && local) return { ok: true, text: 'First time Claude Code runs in this folder on this Mac: it starts right away and runs the folder’s hooks and MCP servers.' }
|
|
40
|
+
if (pick.repo.isNew) return { ok: true, text: 'First time in this folder on ' + m.machine + ', so someone there confirms it first (it runs the folder’s hooks), even with “Allow starting sessions from other devices” on.' }
|
|
35
41
|
if (m.here && local) return { ok: true, text: 'It starts right away on this Mac.' }
|
|
36
42
|
if (m.here && m.allowRemote === true) return { ok: true, text: 'It starts right away, and ' + m.machine + ' shows a notification.' }
|
|
37
43
|
if (m.here) return { ok: true, text: 'Someone at ' + m.machine + ' confirms it first (up to 5 minutes). To skip that, turn on “Allow starting sessions from other devices” in its Settings.' }
|
|
@@ -54,7 +60,7 @@ function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
|
|
|
54
60
|
// The waiting line after a start that has to be confirmed at the Mac.
|
|
55
61
|
export function startAskLine(ask, { clock = (x) => x } = {}) {
|
|
56
62
|
if (!ask) return null
|
|
57
|
-
if (ask.state === 'pending') return { tone: 'wait', text: 'Waiting for a yes on ' + ask.machine + ' (until ' + clock(ask.expiresAt) + '). It shows on that Mac’s board and as a notification.' }
|
|
63
|
+
if (ask.state === 'pending') return { tone: 'wait', text: 'Waiting for a yes on ' + ask.machine + ' (until ' + clock(ask.expiresAt) + '). It shows on that Mac’s board and as a notification.' + (ask.firstTime ? ' A first time in a folder always asks there.' : '') }
|
|
58
64
|
if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
|
|
59
65
|
if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
|
|
60
66
|
if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
|
|
@@ -78,19 +84,25 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
|
|
|
78
84
|
const repos = pick.m ? pick.m.repos : []
|
|
79
85
|
const all = !!ui.allRepos || repos.length <= REPOS_SHOWN + 1
|
|
80
86
|
const shown = all ? repos : repos.slice(0, REPOS_SHOWN).concat(pick.repo && repos.indexOf(pick.repo) >= REPOS_SHOWN ? [pick.repo] : [])
|
|
87
|
+
const custom = pick.repo && pick.repo.isNew ? pick.repo : null
|
|
88
|
+
const browsing = !!(pick.m && ui.browse && ui.browse.machine === pick.m.machine)
|
|
81
89
|
h += '<section class="start-sec"><h2 class="start-h">Repo</h2>'
|
|
82
|
-
if (!repos.length) h += '<p class="start-empty">' + esc(pick.m ? pick.m.machine + ' hasn’t run Claude Code in a repo yet.' : 'No Mac yet.') + '</p>'
|
|
83
|
-
|
|
90
|
+
if (!repos.length && !custom) h += '<p class="start-empty">' + esc(pick.m ? pick.m.machine + ' hasn’t run Claude Code in a repo yet.' : 'No Mac yet.') + '</p>'
|
|
91
|
+
if (pick.m) {
|
|
84
92
|
h += '<div class="start-opts" role="radiogroup" aria-label="Repo">' + shown.map((r) => opt({ k: 'repo', v: r.path, on: !!pick.repo && r.path === pick.repo.path, esc,
|
|
85
|
-
label: esc(r.name) + (r.branch ? ' <span class="start-branch">' + esc(r.branch) + '</span>' : ''), sub: esc(r.path.replace(/^\/Users\/[^/]+/, '~')) + (r.lastAt ? ' · ' + esc(ago(r.lastAt, now)) : '') })).join('') +
|
|
93
|
+
label: esc(r.name) + (r.branch ? ' <span class="start-branch">' + esc(r.branch) + '</span>' : ''), sub: esc(r.path.replace(/^\/Users\/[^/]+/, '~')) + (r.lastAt ? ' · ' + esc(ago(r.lastAt, now)) : '') + (r.git === false ? ' · not a git repo' : '') })).join('') +
|
|
94
|
+
// the folder picked with Other folder… (its name and path go in as text: [data-st], startTexts)
|
|
95
|
+
(custom ? '<button type="button" class="start-opt start-custom" role="radio" aria-checked="true" data-action="start-pick" data-k="repo" data-v="' + esc(custom.path) + '"><span class="start-opt-main"><span data-st="custom-name"></span> <span class="start-badge new">first time here</span>' + (custom.git ? '' : ' <span class="start-badge">not a git repo</span>') + '</span><span class="start-opt-sub" data-st="custom-path"></span></button>' : '') +
|
|
96
|
+
(pick.m.online ? '<button type="button" class="start-opt start-other" data-action="start-browse" aria-expanded="' + browsing + '"' + (browsing ? ' aria-controls="start-browse"' : '') + '><span class="start-opt-main">' + icon('folder') + 'Other folder…</span><span class="start-opt-sub">Browse ' + esc(pick.m.machine) + '’s folders</span></button>' : '') + '</div>'
|
|
86
97
|
if (!all) h += '<button type="button" class="btn start-more" data-action="start-all-repos">Show all ' + repos.length + '</button>'
|
|
87
|
-
h +=
|
|
98
|
+
if (browsing) h += startBrowseHtml(ui.browse, { esc, icon, machine: pick.m.machine })
|
|
99
|
+
if (repos.length) h += '<p class="start-hint">Listed: repos ' + esc(pick.m.machine) + ' has already run Claude Code in. Other folder… picks any folder in your home there; the first time, ' + (pick.m.here && local ? 'starting it is your yes to its hooks.' : 'someone at ' + esc(pick.m.machine) + ' says yes first.') + '</p>'
|
|
88
100
|
}
|
|
89
101
|
h += '</section>'
|
|
90
102
|
// Where
|
|
91
103
|
h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
|
|
92
104
|
opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
|
|
93
|
-
opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: 'its own branch and folder' }) +
|
|
105
|
+
opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: pick.repo && pick.repo.git === false ? 'needs a git repo' : 'its own branch and folder', disabled: !!(pick.repo && pick.repo.git === false) }) +
|
|
94
106
|
'</div></section>'
|
|
95
107
|
// First message
|
|
96
108
|
h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
|
|
@@ -103,6 +115,84 @@ export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', loca
|
|
|
103
115
|
return h
|
|
104
116
|
}
|
|
105
117
|
|
|
118
|
+
// Other folder…: the inline folder browser for one Mac (no popover; full width
|
|
119
|
+
// on a phone). Folder names and paths come from that Mac's disk, so they go in
|
|
120
|
+
// as text afterwards ([data-st], startTexts / fillStartTexts), never as markup.
|
|
121
|
+
// b: { machine, loading, error, data: the GET /v1/start/dirs answer }
|
|
122
|
+
export function startBrowseHtml(b, { esc, icon = () => '', machine = '' } = {}) {
|
|
123
|
+
let h = '<div class="start-browse" id="start-browse" role="region" aria-label="Folders on ' + esc(machine) + '">'
|
|
124
|
+
const d = b && b.data
|
|
125
|
+
// or type one you know (~/src/project, or a full path): the browser goes there; the same rules as walking to it
|
|
126
|
+
const goTo = '<form class="sb-path" data-form="start-browse-path" novalidate><label class="sb-path-l" for="sb-path-in">Go to a folder</label><div class="sb-path-row">' +
|
|
127
|
+
'<input type="text" id="sb-path-in" class="sb-path-in" name="path" placeholder="~/code/web" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go"' + (b && b.pathError ? ' aria-invalid="true" aria-describedby="sb-path-err"' : '') + '>' +
|
|
128
|
+
'<button type="submit" class="btn">Go</button></div>' +
|
|
129
|
+
(b && b.pathError ? '<p class="start-rule bad sb-path-err" id="sb-path-err" role="alert" data-st="path-err"></p>' : '') + '</form>'
|
|
130
|
+
if (!d) h += goTo
|
|
131
|
+
if (d) {
|
|
132
|
+
h += '<nav class="sb-crumbs" aria-label="Folder">' + d.crumbs.map((c, i) => (i ? '<span class="sb-sep" aria-hidden="true">/</span>' : '') +
|
|
133
|
+
'<button type="button" class="sb-crumb" data-action="start-browse-go" data-crumb="' + i + '"' + (i === d.crumbs.length - 1 ? ' aria-current="location"' : '') + ' data-st="crumb-' + i + '"></button>').join('') + '</nav>'
|
|
134
|
+
h += '<div class="sb-here">' +
|
|
135
|
+
(d.parent ? '<button type="button" class="btn sb-up" data-action="start-browse-up" aria-label="Up one folder">' + icon('chevron-left') + 'Up</button>' : '') +
|
|
136
|
+
'<span class="sb-cur"><span class="sb-cur-name" data-st="cur"></span>' + (d.git ? ' <span class="start-badge git">git</span>' : ' <span class="start-badge">not a git repo</span>') + '</span>' +
|
|
137
|
+
'<button type="button" class="btn primary sb-use" data-action="start-browse-use"' + (d.parent ? '' : ' disabled') + '>Use this folder</button></div>'
|
|
138
|
+
h += goTo
|
|
139
|
+
if (d.shortcuts && d.shortcuts.length > 1) h += '<div class="sb-cuts"><span class="sb-cuts-l">Jump to</span>' + d.shortcuts.map((c, i) => '<button type="button" class="sb-cut" data-action="start-browse-go" data-cut="' + i + '" data-st="cut-' + i + '"></button>').join('') + '</div>'
|
|
140
|
+
if (!d.entries.length) h += '<p class="start-empty">No folders in here.</p>'
|
|
141
|
+
else h += '<ul class="sb-list">' + d.entries.map((e, i) => '<li><button type="button" class="sb-dir" data-action="start-browse-go" data-entry="' + i + '">' + icon('folder') + '<span class="sb-name" data-st="entry-' + i + '"></span>' + (e.git ? '<span class="start-badge git">git</span>' : '') + icon('chevron-right', 'ic sb-go') + '</button></li>').join('') + '</ul>'
|
|
142
|
+
if (d.capped) h += '<p class="start-hint">Showing the first ' + esc(String(d.entries.length)) + ' of ' + esc(String(d.total)) + ' folders.</p>'
|
|
143
|
+
}
|
|
144
|
+
if (b && b.loading) h += '<p class="start-hint" role="status">Loading ' + esc(machine) + '’s folders…</p>'
|
|
145
|
+
if (b && b.error) h += '<p class="start-rule bad" role="alert">' + esc(b.error) + '</p>'
|
|
146
|
+
h += '<p class="start-hint">Folders only, by name: hidden ones, node_modules and Library are left out, and so is the synced Leftoff folder.</p>' +
|
|
147
|
+
'<div class="sb-foot"><button type="button" class="btn" data-action="start-browse-close">Cancel</button></div></div>'
|
|
148
|
+
return h
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// What you typed in "Go to a folder", as a path to ask for: no spaces around
|
|
152
|
+
// it, no quotes a copy brought along, a shell's "\ " back to a space, no
|
|
153
|
+
// trailing slash. "~" stays "~"; the Mac resolves it (src/dirs.js checkFolder).
|
|
154
|
+
export function typedPath(v) {
|
|
155
|
+
let t = String(v == null ? '' : v).trim()
|
|
156
|
+
if (/^(['"]).*\1$/.test(t) && t.length >= 2) t = t.slice(1, -1).trim()
|
|
157
|
+
t = t.replace(/\\ /g, ' ')
|
|
158
|
+
if (t.length > 1) t = t.replace(/\/+$/, '') || '/'
|
|
159
|
+
return t
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The text for every [data-st] on the page: names and paths from a Mac's disk.
|
|
163
|
+
export function startTexts(data, ui = {}) {
|
|
164
|
+
const t = {}
|
|
165
|
+
const pick = startPick(data, ui)
|
|
166
|
+
if (pick.repo && pick.repo.isNew) { t['custom-name'] = pick.repo.name || ''; t['custom-path'] = pick.repo.label || pick.repo.path || '' }
|
|
167
|
+
const d = ui.browse && ui.browse.data
|
|
168
|
+
if (d) {
|
|
169
|
+
t.cur = d.label || d.path || ''
|
|
170
|
+
d.crumbs.forEach((c, i) => { t['crumb-' + i] = c.name })
|
|
171
|
+
;(d.shortcuts || []).forEach((c, i) => { t['cut-' + i] = c.label })
|
|
172
|
+
d.entries.forEach((e, i) => { t['entry-' + i] = e.name })
|
|
173
|
+
}
|
|
174
|
+
if (ui.browse && ui.browse.pathError) t['path-err'] = ui.browse.pathError
|
|
175
|
+
for (const a of (data && Array.isArray(data.pending) ? data.pending : [])) if (a.firstTime) t['ask-folder-' + a.id] = a.folder || ''
|
|
176
|
+
return t
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function fillStartTexts(root, texts) {
|
|
180
|
+
if (!root || !root.querySelectorAll) return 0
|
|
181
|
+
let n = 0
|
|
182
|
+
for (const el of root.querySelectorAll('[data-st]')) { el.textContent = texts[el.getAttribute('data-st')] || ''; n++ }
|
|
183
|
+
return n
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The folder a browser click means: { path } from a crumb, a shortcut or an entry.
|
|
187
|
+
export function browseTarget(b, el) {
|
|
188
|
+
const d = b && b.data
|
|
189
|
+
if (!d || !el || !el.dataset) return null
|
|
190
|
+
if (el.dataset.crumb != null) return d.crumbs[Number(el.dataset.crumb)] || null
|
|
191
|
+
if (el.dataset.cut != null) return (d.shortcuts || [])[Number(el.dataset.cut)] || null
|
|
192
|
+
if (el.dataset.entry != null) return d.entries[Number(el.dataset.entry)] || null
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
|
|
106
196
|
// A request waiting on THIS Mac: its board shows it with Start and Decline
|
|
107
197
|
// (only this Mac's own browser may answer).
|
|
108
198
|
export function startAsksBanner(info, { esc, icon, local = true, clock = (x) => x, busy = new Set() } = {}) {
|
|
@@ -110,12 +200,30 @@ export function startAsksBanner(info, { esc, icon, local = true, clock = (x) =>
|
|
|
110
200
|
if (!asks.length) return ''
|
|
111
201
|
return asks.map((a) => {
|
|
112
202
|
const b = busy.has(a.id)
|
|
113
|
-
|
|
203
|
+
const where = a.firstTime ? '<span data-st="ask-folder-' + esc(a.id) + '"></span>' : esc(a.repo)
|
|
204
|
+
return '<div class="banner warn start-ask-banner' + (a.firstTime ? ' first' : '') + '" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + where + (a.where === 'worktree' ? ' (new worktree)' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '”' +
|
|
205
|
+
(a.firstTime ? ' <b class="start-first">First time in this folder; this runs its hooks.</b>' : '') + ' <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
|
|
114
206
|
(local ? '<button type="button" class="btn primary" data-action="start-ask-confirm" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>' + icon('play') + (b ? 'Starting…' : 'Start it') + '</button>' +
|
|
115
207
|
'<button type="button" class="btn" data-action="start-ask-decline" data-id="' + esc(a.id) + '"' + (b ? ' aria-disabled="true"' : '') + '>Decline</button>' : '<span class="rs-m">Someone at that Mac confirms it there.</span>') + '</div>'
|
|
116
208
|
}).join('')
|
|
117
209
|
}
|
|
118
210
|
|
|
211
|
+
// Asked once on THIS Mac's board when it pairs a phone or sees a new Mac:
|
|
212
|
+
// may it start sessions here? No is the default (first, and what doing
|
|
213
|
+
// nothing means); Yes turns on the switch, and Trust my paired devices if it's
|
|
214
|
+
// off (the words say so). Never shown off this Mac.
|
|
215
|
+
export function startPairAskHtml(info, { esc, local = true, busy = false } = {}) {
|
|
216
|
+
const a = info && info.pairAsk
|
|
217
|
+
if (!a || !local) return ''
|
|
218
|
+
const mac = info.machine || 'this Mac'
|
|
219
|
+
const who = a.kind === 'mac' ? (a.name || 'your other Mac') : 'your phone'
|
|
220
|
+
const what = a.kind === 'mac' ? 'Let ' + who + ' start sessions here?' : 'Let your phone start sessions here?'
|
|
221
|
+
const yes = 'Yes turns on “Allow starting sessions from other devices” for ' + mac + (info.trust ? '' : ', and Trust my paired devices so you can answer its prompts there') + '. It applies to every paired phone and Mac; change it any time in Settings → Privacy & security.'
|
|
222
|
+
return '<div class="banner info pair-ask" role="region" aria-label="' + esc(what) + '"><span class="grow"><b>' + esc(what) + '</b> ' + esc(a.kind === 'mac' ? who + ' could start Claude Code on ' + mac + ' with nobody here;' : 'It could start Claude Code on ' + mac + ' with nobody here;') + ' its edits and commands still wait for you in Needs you. ' + esc(yes) + '</span>' +
|
|
223
|
+
'<button type="button" class="btn" data-action="pair-ask-no"' + (busy ? ' aria-disabled="true"' : '') + '>No</button>' +
|
|
224
|
+
'<button type="button" class="btn" data-action="pair-ask-yes"' + (busy ? ' aria-disabled="true"' : '') + '>Yes, allow</button></div>'
|
|
225
|
+
}
|
|
226
|
+
|
|
119
227
|
// The session page's line for a session Leftoff runs: where it runs, who
|
|
120
228
|
// started it, and Stop / Resume. The error (Claude Code's own words) is put
|
|
121
229
|
// in with textContent by app.js ([data-headless-err]).
|