@leftoff/cli 0.9.9-beta.1 → 0.9.9-beta.2
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 -0
- package/bin/leftoff.js +365 -358
- package/docs/SECURITY.md +12 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +9 -1
- package/mod/hooks/register.js +21 -8
- package/package.json +1 -1
- package/ui/app.css +37 -0
- package/ui/app.js +105 -6
- package/ui/icons.js +1 -0
- package/ui/start.js +136 -0
package/docs/SECURITY.md
CHANGED
|
@@ -106,6 +106,18 @@ Live link is opt-in, per session: only sessions started with `leftoff live` have
|
|
|
106
106
|
- **Outside events** need this Mac's event key (`~/.config/checkpoints/secrets/live-events`, mode 600, made by `leftoff live key`, replaced with `--new`). Locally that's a Bearer key. Through the relay only an HMAC signature is accepted (5-minute window, replays refused), so the relay can pass an event along but can't read the key, forge an event or change one. Both the relay and the Mac rate-limit, and bodies are capped at 16 KB. Claude's instructions call an event information, not a command, but it is still text Claude reads: anyone with the key can put words in front of your session. Keep the key in your CI's secrets.
|
|
107
107
|
- **Remote Restore** opens Ghostty tabs, so a phone or another Mac can only ask. The Mac shows a notification and a card, and only that Mac's own browser can confirm. Requests expire after 5 minutes, at most 3 wait at once, and they're rate-limited.
|
|
108
108
|
|
|
109
|
+
### Start a session from the board (0.9.9 beta)
|
|
110
|
+
|
|
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
|
+
|
|
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
|
+
- **Only repos 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 nobody opened in Claude Code can't be started.
|
|
115
|
+
- **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).
|
|
117
|
+
- **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
|
+
- **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
|
+
- **Logged:** one daemon-log line per start, stop and permission request (session id and tool, never prompt text).
|
|
120
|
+
|
|
109
121
|
### What it did: tool output and the diff (0.9.8 beta)
|
|
110
122
|
|
|
111
123
|
The session page's **What it did** shows the last tools a session ran with what they printed, and **This folder's changes** (the session's folder's uncommitted changes). This puts command output and file contents on the board and on your phone, so:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.9-beta.
|
|
3
|
+
"version": "0.9.9-beta.2",
|
|
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
|
@@ -567,7 +567,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
567
567
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
568
568
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
569
569
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
570
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive']
|
|
570
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom']
|
|
571
571
|
|
|
572
572
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
573
573
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
|
@@ -1840,6 +1840,14 @@ export function isNonInteractive({ surface = null, entrypoint = null } = {}) {
|
|
|
1840
1840
|
return surface === 'headless' || surface === 'codex-exec'
|
|
1841
1841
|
}
|
|
1842
1842
|
|
|
1843
|
+
// 0.9.9: a session Leftoff itself started from the board (src/headless.js).
|
|
1844
|
+
// It runs as `claude -p` (entrypoint sdk-cli) but it is your work: the daemon
|
|
1845
|
+
// spawned it with LEFTOFF_HEADLESS=<its session id>. Anything else in that
|
|
1846
|
+
// variable (empty, a stray value) doesn't count.
|
|
1847
|
+
export function isLeftoffHeadless(value) {
|
|
1848
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim())
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1843
1851
|
// Our own summarizer's and titler's prompts (buildCheckpointPrompt, TITLE
|
|
1844
1852
|
// prompt): a session that starts with one is Leftoff talking to a model.
|
|
1845
1853
|
export function isOwnSummarizerPrompt(p) {
|
package/mod/hooks/register.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
|
|
20
20
|
parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
|
|
21
21
|
listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
|
|
22
|
-
boardLinkLines, isNonInteractive, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
|
|
22
|
+
boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
|
|
23
23
|
} from './core.js'
|
|
24
24
|
|
|
25
25
|
const PANE_ID = 'checkpoints'
|
|
@@ -28,10 +28,11 @@ 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.2' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
|
35
|
+
headless: false, // 0.9.9: started from the board (LEFTOFF_HEADLESS)
|
|
35
36
|
ready: false,
|
|
36
37
|
sessionId: null,
|
|
37
38
|
root: null,
|
|
@@ -150,10 +151,19 @@ async function sayHelloAgain($) {
|
|
|
150
151
|
async function isInternal($) {
|
|
151
152
|
// Leftoff's own claude runs (the summarizer's claude -p, claude plugin …) carry this.
|
|
152
153
|
if ((await $.env.get('LEFTOFF_INTERNAL')) || (await $.env.get('CHECKPOINTS_INTERNAL'))) return true
|
|
154
|
+
// 0.9.9: a session Leftoff started from the board runs as claude -p, but it is yours.
|
|
155
|
+
if (await isHeadless($)) return false
|
|
153
156
|
// 0.8.6: `claude -p` / the Agent SDK (CLAUDE_CODE_ENTRYPOINT sdk-*): not a work session.
|
|
154
157
|
try { return isNonInteractive({ entrypoint: await $.env.get('CLAUDE_CODE_ENTRYPOINT') }) } catch { return false }
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
// 0.9.9: started by Leftoff from the board (src/headless.js): no terminal, no
|
|
161
|
+
// tab to name, the daemon writes the board's messages on its stdin and answers
|
|
162
|
+
// its permission prompts itself, so the mod only reports.
|
|
163
|
+
async function isHeadless($) {
|
|
164
|
+
try { return isLeftoffHeadless(await $.env.get('LEFTOFF_HEADLESS')) } catch { return false }
|
|
165
|
+
}
|
|
166
|
+
|
|
157
167
|
async function loadSetup($) {
|
|
158
168
|
const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
|
|
159
169
|
st.root = ((await $.env.get('LEFTOFF_DIR')) || (await $.env.get('CHECKPOINTS_DIR')) || home + '/checkpoints').replace(/\/+$/, '')
|
|
@@ -577,7 +587,8 @@ async function readBackend($, model) {
|
|
|
577
587
|
// tab titled. (Claude Code's terminalTitleFromRename setting has to stay on,
|
|
578
588
|
// which it is by default.) With CLAUDE_CODE_DISABLE_TERMINAL_TITLE set, Claude
|
|
579
589
|
// Code draws nothing, so the mod writes OSC 2 to the session's tty instead.
|
|
580
|
-
|
|
590
|
+
const noTab = () => st.surface === 'headless' || st.surface === 'leftoff' // no terminal tab (claude -p, or started from the board)
|
|
591
|
+
function tabTitleOn() { return st.cfg.tabTitle !== false && !st.internal && !noTab() }
|
|
581
592
|
|
|
582
593
|
function desiredTabTitle(name) {
|
|
583
594
|
const repo = st.record ? repoName(st.record) : null
|
|
@@ -865,7 +876,7 @@ const ASK_MAX_MS = 9.5 * 60 * 1000
|
|
|
865
876
|
|
|
866
877
|
async function permissionHook($, e, next) {
|
|
867
878
|
const base = await next(e)
|
|
868
|
-
if (st.internal || !st.sessionId || (base && base.decision)) return base
|
|
879
|
+
if (st.internal || st.headless || !st.sessionId || (base && base.decision)) return base // headless: the daemon answers on its pipe
|
|
869
880
|
const opened = await api($, 'POST', '/v1/asks', {
|
|
870
881
|
sessionId: e.session_id || st.sessionId, tool: e.tool_name, input: e.tool_input, suggestions: e.permission_suggestions,
|
|
871
882
|
capture: 'mod', cwd: e.cwd, permissionMode: e.permission_mode,
|
|
@@ -913,7 +924,7 @@ async function toolRan($, e) {
|
|
|
913
924
|
// typed in with $.prompt.submit, which Claude Code runs once the session is idle
|
|
914
925
|
// (checked on 2.1.295: a submit during a turn waits for it to end).
|
|
915
926
|
function startReplyLoop($) {
|
|
916
|
-
if (st.replyLoop || st.internal ||
|
|
927
|
+
if (st.replyLoop || st.internal || noTab()) return
|
|
917
928
|
st.replyLoop = true
|
|
918
929
|
const loop = async () => {
|
|
919
930
|
let backoff = 0
|
|
@@ -964,6 +975,8 @@ export function register(on, options) {
|
|
|
964
975
|
try { model = await $.session.model() } catch {}
|
|
965
976
|
let surface = null
|
|
966
977
|
try { const surfaces = await $.session.surfaces(); surface = surfaces && surfaces.length ? surfaces[0] : 'headless' } catch {}
|
|
978
|
+
st.headless = await isHeadless($)
|
|
979
|
+
if (st.headless) surface = 'leftoff' // 0.9.9: started from the board, a work session (not 'headless')
|
|
967
980
|
st.record = null
|
|
968
981
|
// Where this session runs: Claude Code's pid + tty (for open/closed and
|
|
969
982
|
// Terminal.app) and the terminal's own ids (for Go).
|
|
@@ -1006,7 +1019,7 @@ export function register(on, options) {
|
|
|
1006
1019
|
// a new id in the same process (/clear, /resume) runs in the same tab
|
|
1007
1020
|
...(parentSessionId ? { proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term } : {}),
|
|
1008
1021
|
})
|
|
1009
|
-
if (
|
|
1022
|
+
if (!noTab()) {
|
|
1010
1023
|
const own = e.session_title ? parseTabTitle(e.session_title) : null
|
|
1011
1024
|
const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
|
|
1012
1025
|
await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
|
|
@@ -1055,8 +1068,8 @@ export function register(on, options) {
|
|
|
1055
1068
|
})
|
|
1056
1069
|
// The daemon titles a session after its first turn (a few seconds of
|
|
1057
1070
|
// summarizer); name the tab once it has, without waiting for the minute heartbeat.
|
|
1058
|
-
if (st.daemonTitle &&
|
|
1059
|
-
else if (!st.titleSoon &&
|
|
1071
|
+
if (st.daemonTitle && !noTab()) await noteTitle($, st.daemonTitle)
|
|
1072
|
+
else if (!st.titleSoon && !noTab() && tabTitleOn()) {
|
|
1060
1073
|
st.titleSoon = true
|
|
1061
1074
|
$.clock.after(12000, () => { heartbeat($).catch(() => {}) })
|
|
1062
1075
|
}
|
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.2",
|
|
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
|
@@ -2846,3 +2846,40 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
2846
2846
|
.lv-tools .lv-tool-open::after { content: '›'; position: absolute; right: 8px; top: 0; color: var(--faint); font-size: 14px; }
|
|
2847
2847
|
.lv-tools .lv-tool-open:hover { background: var(--bg2); color: var(--text); }
|
|
2848
2848
|
.lv-tools .lv-tool-open:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
2849
|
+
|
|
2850
|
+
/* 0.9.9 New session (ui/start.js): plain lists of big choices, inline on a phone */
|
|
2851
|
+
.start { display: flex; flex-direction: column; gap: 18px; max-width: 760px; margin-top: 16px; }
|
|
2852
|
+
.start-where .start-opt-sub { white-space: normal; }
|
|
2853
|
+
.start-sec { display: flex; flex-direction: column; gap: 8px; }
|
|
2854
|
+
.start-h { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; margin: 0; }
|
|
2855
|
+
.start-opts { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
|
|
2856
|
+
.start-where { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
2857
|
+
.start-opt { min-height: 44px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px; text-align: left; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--text); font: inherit; font-size: 14px; cursor: pointer; min-width: 0; }
|
|
2858
|
+
.start-opt:hover:not(:disabled) { border-color: var(--line-strong); }
|
|
2859
|
+
.start-opt[aria-checked="true"] { border-color: var(--accent); background: var(--tint-accent); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
2860
|
+
.start-opt:disabled { opacity: .5; cursor: default; }
|
|
2861
|
+
.start-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2862
|
+
.start-opt-main { font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2863
|
+
.start-opt-sub { font-size: 12px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2864
|
+
.start-branch { font-weight: 400; color: var(--muted); font-size: 12px; }
|
|
2865
|
+
.start-more { align-self: flex-start; min-height: 44px; }
|
|
2866
|
+
.start-hint, .start-empty { margin: 0; font-size: 12.5px; color: var(--muted); }
|
|
2867
|
+
.start-rule { margin: 0; font-size: 13px; color: var(--text2); }
|
|
2868
|
+
.start-rule.bad { color: var(--red); }
|
|
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
|
+
.start-ask.wait { border-color: var(--yellow); background: var(--tint-yellow); }
|
|
2871
|
+
.start-ask.bad { border-color: var(--red); background: var(--tint-red); }
|
|
2872
|
+
.btn.start-new { min-height: 36px; }
|
|
2873
|
+
.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
|
+
.headless-bar .grow { flex: 1 1 200px; display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
|
|
2875
|
+
.headless-bar.running { border-color: var(--green); background: var(--tint-green); }
|
|
2876
|
+
.headless-bar.paused { border-color: var(--yellow); background: var(--tint-yellow); }
|
|
2877
|
+
.headless-bar .btn { min-height: 44px; }
|
|
2878
|
+
.headless-err { margin: -4px 0 10px; padding: 8px 12px; border-radius: 8px; background: var(--tint-red); color: var(--text2); font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 9em; overflow: auto; }
|
|
2879
|
+
@media (max-width: 640px) {
|
|
2880
|
+
.start-opts { grid-template-columns: 1fr; }
|
|
2881
|
+
.start-where { grid-template-columns: 1fr 1fr; }
|
|
2882
|
+
.start-opt { min-height: 48px; }
|
|
2883
|
+
.btn.start-new { min-height: 44px; }
|
|
2884
|
+
.start-ask-banner .btn, .start-ask .btn { min-height: 44px; }
|
|
2885
|
+
}
|
package/ui/app.js
CHANGED
|
@@ -16,6 +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 } from './start.js' // 0.9.9 New session
|
|
19
20
|
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
20
21
|
import { wireTips } from './tips.js'
|
|
21
22
|
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
@@ -796,6 +797,7 @@ async function viewBoard() {
|
|
|
796
797
|
state.ghostty = viewer ? viewer.ghostty === true : false
|
|
797
798
|
state.mayAnswer = viewer ? viewer.mayAnswer !== false : true
|
|
798
799
|
try { state.restore = await api('GET', '/v1/restore') } catch { state.restore = null }
|
|
800
|
+
try { state.starts = await api('GET', '/v1/starts') } catch { state.starts = null } // 0.9.9: starts waiting for a yes here
|
|
799
801
|
const today = new Date(); today.setHours(0, 0, 0, 0)
|
|
800
802
|
const recent = sessions.filter((s) => !(s.status === 'ended' && Date.parse(s.endedAt || 0) < today.getTime()))
|
|
801
803
|
// v0.4.8: sessions with nothing in them yet (no prompt, name or checkpoint,
|
|
@@ -835,10 +837,11 @@ async function viewBoard() {
|
|
|
835
837
|
let html = '<div class="pagehead"><div><h1>Sessions</h1><div class="sub">Every Claude Code session on ' + (multi() ? 'your machines' : 'this machine') + (agentNames().length ? ', plus ' + esc(agentNames().join(' and ')) : '') + ', grouped by what needs you.</div>' + syncChip() + '</div>' +
|
|
836
838
|
'<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
|
|
837
839
|
options: [['', 'All machines'], ...state.health.machines.map((m) => [m, mEmoji(m) + ' ' + m + thisOneSuffix(m, state.health)]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
|
|
840
|
+
'<a class="btn start-new" href="#/new">' + icon('plus') + 'New session</a>' +
|
|
838
841
|
'<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
|
|
839
842
|
'<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
|
|
840
843
|
html += syncDetails() + staleLine(sessions)
|
|
841
|
-
html += restoreAsksBanner() + restoreBanner()
|
|
844
|
+
html += restoreAsksBanner() + startAsksBanner(state.starts, { esc, icon, local: !!(state.starts && state.starts.viewer && state.starts.viewer.local), clock, busy: startUi.busy }) + restoreBanner()
|
|
842
845
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
843
846
|
const needs = needsItems(sessions)
|
|
844
847
|
html += needsStrip(needs, needOpts())
|
|
@@ -1489,6 +1492,8 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
1489
1492
|
// 0.9.8: one line each, 44px, an icon and a short label; the group drops under the title as a whole when it doesn't fit beside it
|
|
1490
1493
|
'<div class="actions s-actions">' + goButton(s, { page: true }) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + 'Copy resume</button>' + (s.liveResumeCommand ? '<button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(s.liveResumeCommand) + '" title="Exit the session in its terminal, then run this to bring it back Live">' + icon('copy') + 'Copy Live resume</button>' : '') + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
|
|
1491
1494
|
html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
|
|
1495
|
+
html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false }) // 0.9.9: started from the board: Stop / Resume
|
|
1496
|
+
if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
|
|
1492
1497
|
html += '<div class="facts">' +
|
|
1493
1498
|
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
1494
1499
|
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) +
|
|
@@ -1566,7 +1571,7 @@ async function fetchLive(id, { timeoutMs } = {}) {
|
|
|
1566
1571
|
} catch (e) { return { messages: [], error: e.message } }
|
|
1567
1572
|
}
|
|
1568
1573
|
function liveBoxHtml(s, v) {
|
|
1569
|
-
if (s.agent || s.codingAgent === 'codex' || s.capture === 'hooks') return ''
|
|
1574
|
+
if (s.agent || s.codingAgent === 'codex' || (s.capture === 'hooks' && !s.headless)) return '' // 0.9.9: one Leftoff started talks Live whatever reports it
|
|
1570
1575
|
if (!isLive(s)) {
|
|
1571
1576
|
if (s.endedAt || s.live === 'closed') return ''
|
|
1572
1577
|
const how = state.liveHow
|
|
@@ -1584,7 +1589,7 @@ function liveBoxHtml(s, v) {
|
|
|
1584
1589
|
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2><span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
|
|
1585
1590
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1586
1591
|
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
1587
|
-
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here and in the terminal.</p>' +
|
|
1592
|
+
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
|
|
1588
1593
|
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
|
|
1589
1594
|
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
1590
1595
|
composerHtml(s, draft, may) +
|
|
@@ -1896,7 +1901,7 @@ document.addEventListener('keydown', (ev) => {
|
|
|
1896
1901
|
ev.preventDefault()
|
|
1897
1902
|
const cb = ta.closest('.composer')
|
|
1898
1903
|
if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
|
|
1899
|
-
const box = ta.closest('.live-box') || ta.closest('.need')
|
|
1904
|
+
const box = ta.closest('.live-box') || ta.closest('.need') || ta.closest('.start-box')
|
|
1900
1905
|
const btn = box && box.querySelector('[data-submit-for="' + CSS.escape(ta.dataset.submit || 'live') + '"]')
|
|
1901
1906
|
if (!btn || btn.disabled) return
|
|
1902
1907
|
const last = Number(box.dataset.kbdAt || 0)
|
|
@@ -3353,6 +3358,7 @@ function queueSave() { clearTimeout(saveUi.timer); saveUi.timer = setTimeout(pai
|
|
|
3353
3358
|
|
|
3354
3359
|
async function viewSettings() {
|
|
3355
3360
|
const [c, , remote, sync, peers, st, , , relay, gh] = await Promise.all([api('GET', '/v1/config'), spacesLoad(), api('GET', '/v1/remote').catch(() => null), api('GET', '/v1/sync').catch(() => null), api('GET', '/v1/peers').catch(() => null), api('GET', '/v1/syncthing').catch(() => null), refreshHealth().catch(() => null), agentsLoad(), api('GET', '/v1/relay').catch(() => null), api('GET', '/v1/gh').catch(() => null)])
|
|
3361
|
+
const starts = await api('GET', '/v1/starts').catch(() => null) // 0.9.9: this Mac's own switch
|
|
3356
3362
|
state.relay = relay
|
|
3357
3363
|
state.gh = gh
|
|
3358
3364
|
syncUi.data = sync; syncUi.peers = peers; if (st) stUi.data = st
|
|
@@ -3391,6 +3397,7 @@ async function viewSettings() {
|
|
|
3391
3397
|
phone: phonePane(relay, remote, unavailable),
|
|
3392
3398
|
security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
|
|
3393
3399
|
'<div class="full hint">Nothing reaches this board from elsewhere until <a href="#/settings/phone">Phone & remote</a> is on.</div>') +
|
|
3400
|
+
startsSettingHtml(starts, { esc, local: !!(starts && starts.viewer && starts.viewer.local) }) +
|
|
3394
3401
|
// a changed machine key needs a click here, so Other machines stays up top then
|
|
3395
3402
|
(peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
|
|
3396
3403
|
advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
|
|
@@ -4142,10 +4149,12 @@ async function render() {
|
|
|
4142
4149
|
else if (r.name === 'whats-new') await viewWhatsNew()
|
|
4143
4150
|
else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
|
|
4144
4151
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
4152
|
+
else if (r.name === 'new') await viewNewSession()
|
|
4145
4153
|
else if (r.name === 'setup') await viewSetup()
|
|
4146
4154
|
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
4147
4155
|
else await viewBoard()
|
|
4148
4156
|
tabsAfterRender()
|
|
4157
|
+
fillHeadlessErrors()
|
|
4149
4158
|
// a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
|
|
4150
4159
|
for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
|
|
4151
4160
|
} catch (e) {
|
|
@@ -4154,6 +4163,62 @@ async function render() {
|
|
|
4154
4163
|
}
|
|
4155
4164
|
}
|
|
4156
4165
|
|
|
4166
|
+
// ------------------------------------------------------------ New session (0.9.9, ui/start.js)
|
|
4167
|
+
|
|
4168
|
+
const startUi = { data: null, machine: null, repo: null, where: 'repo', allRepos: false, ask: null, busy: new Set(), sending: false, poll: null }
|
|
4169
|
+
async function viewNewSession() {
|
|
4170
|
+
try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
|
|
4171
|
+
const fa = document.activeElement
|
|
4172
|
+
const kf = fa && fa.classList && fa.classList.contains('start-text') ? { a: fa.selectionStart, b: fa.selectionEnd } : null
|
|
4173
|
+
$app.innerHTML = startPageHtml(startUi.data, startUi, { esc, icon, emoji: mEmoji, local: state.viewerLocal !== false && !!(startUi.data.viewer && startUi.data.viewer.local), clock, draft: (state.drafts && state.drafts.start) || '', busy: startUi.sending })
|
|
4174
|
+
const ta = $app.querySelector('.start-text')
|
|
4175
|
+
if (ta) { fitComposer(ta); if (kf && !ta.disabled) { ta.focus({ preventScroll: true }); try { ta.setSelectionRange(kf.a, kf.b) } catch {} } }
|
|
4176
|
+
}
|
|
4177
|
+
// Claude Code's own words when a session it ran stopped with an error: text, never markup.
|
|
4178
|
+
const headlessErrors = new Map()
|
|
4179
|
+
function fillHeadlessErrors() {
|
|
4180
|
+
for (const el of $app.querySelectorAll('[data-headless-err]')) {
|
|
4181
|
+
const t = headlessErrors.get(el.dataset.headlessErr) || ''
|
|
4182
|
+
el.textContent = t
|
|
4183
|
+
el.hidden = !t
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
async function startSend(btn) {
|
|
4187
|
+
const box = btn.closest('.start-box')
|
|
4188
|
+
const ta = box && box.querySelector('.start-text')
|
|
4189
|
+
const text = ta ? ta.value.trim() : ''
|
|
4190
|
+
if (!text) { if (ta) ta.focus(); return }
|
|
4191
|
+
if (startUi.sending) return
|
|
4192
|
+
const pick = startPick(startUi.data, startUi)
|
|
4193
|
+
if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
|
|
4194
|
+
startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
|
|
4195
|
+
try {
|
|
4196
|
+
const r = await api('POST', '/v1/sessions/start', { machine: pick.m.machine, cwd: pick.repo.path, prompt: text, where: pick.where }, { timeoutMs: 60000 })
|
|
4197
|
+
if (state.drafts) delete state.drafts.start
|
|
4198
|
+
if (r.state === 'started' && r.sessionId) { startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
|
|
4199
|
+
startUi.ask = { ...r, machine: r.machine || pick.m.machine }
|
|
4200
|
+
pollStartAsk()
|
|
4201
|
+
} catch (e) { toast(e.message, 6000) } finally { startUi.sending = false }
|
|
4202
|
+
if (state.route && state.route.name === 'new') render()
|
|
4203
|
+
}
|
|
4204
|
+
// The asker's side of a start that waits for a yes at the Mac.
|
|
4205
|
+
function pollStartAsk() {
|
|
4206
|
+
clearTimeout(startUi.poll)
|
|
4207
|
+
const a = startUi.ask
|
|
4208
|
+
if (!a || !a.id || !['pending', 'starting'].includes(a.state)) return
|
|
4209
|
+
startUi.poll = setTimeout(async () => {
|
|
4210
|
+
try {
|
|
4211
|
+
const r = await api('GET', '/v1/sessions/start/' + encodeURIComponent(a.id) + '?machine=' + encodeURIComponent(a.machine))
|
|
4212
|
+
if (startUi.ask && startUi.ask.id === a.id) {
|
|
4213
|
+
startUi.ask = { ...a, ...r }
|
|
4214
|
+
if (r.state === 'started' && r.sessionId) { startUi.ask = null; location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
|
|
4215
|
+
}
|
|
4216
|
+
} catch (e) { if (e.status === 404) { startUi.ask = { ...a, state: 'failed', error: e.message } } }
|
|
4217
|
+
if (state.route && state.route.name === 'new') render()
|
|
4218
|
+
pollStartAsk()
|
|
4219
|
+
}, 2000)
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4157
4222
|
// First-run setup wizard (0.9.7, ui/wizard.js). Live updates don't redraw it:
|
|
4158
4223
|
// it keeps its own step and fetches its own state.
|
|
4159
4224
|
async function viewSetup() {
|
|
@@ -5286,6 +5351,35 @@ document.addEventListener('click', async (ev) => {
|
|
|
5286
5351
|
} catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
|
|
5287
5352
|
return
|
|
5288
5353
|
}
|
|
5354
|
+
// 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
|
|
5355
|
+
if (action === 'start-pick') {
|
|
5356
|
+
const k = btn.dataset.k, v = btn.dataset.v
|
|
5357
|
+
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'
|
|
5358
|
+
return render()
|
|
5359
|
+
}
|
|
5360
|
+
if (action === 'start-all-repos') { startUi.allRepos = true; return render() }
|
|
5361
|
+
if (action === 'start-again') { startUi.ask = null; return render() }
|
|
5362
|
+
if (action === 'start-send') return startSend(btn)
|
|
5363
|
+
if (action === 'start-ask-confirm' || action === 'start-ask-decline') {
|
|
5364
|
+
if (!id || startUi.busy.has(id)) return
|
|
5365
|
+
startUi.busy.add(id); render()
|
|
5366
|
+
try {
|
|
5367
|
+
const r = await api('POST', '/v1/sessions/start/' + encodeURIComponent(id) + '/' + (action === 'start-ask-confirm' ? 'confirm' : 'decline'), {}, { timeoutMs: 60000 })
|
|
5368
|
+
toast(r.state === 'started' ? 'Started' : r.state === 'declined' ? 'Declined' : r.state === 'failed' ? 'Didn’t start: ' + (r.error || '') : 'That request was ' + r.state, r.state === 'failed' ? 6000 : 2500)
|
|
5369
|
+
startUi.busy.delete(id)
|
|
5370
|
+
if (r.state === 'started' && r.sessionId) { location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
|
|
5371
|
+
} catch (e) { toast(e.message, 5000) }
|
|
5372
|
+
startUi.busy.delete(id)
|
|
5373
|
+
return render()
|
|
5374
|
+
}
|
|
5375
|
+
if (action === 'headless-stop' || action === 'headless-run') {
|
|
5376
|
+
btn.disabled = true
|
|
5377
|
+
try {
|
|
5378
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/' + (action === 'headless-stop' ? 'stop' : 'run'), {}, { timeoutMs: 30000 })
|
|
5379
|
+
toast(action === 'headless-stop' ? 'Stopping' : r.already ? 'Already running' : 'Running again')
|
|
5380
|
+
} catch (e) { toast(e.message, 6000) }
|
|
5381
|
+
return render()
|
|
5382
|
+
}
|
|
5289
5383
|
if (action === 'restore-ask-confirm' || action === 'restore-ask-decline') {
|
|
5290
5384
|
const id = btn.dataset.id
|
|
5291
5385
|
if (!id || restoreUi.busy.has(id)) return
|
|
@@ -6011,7 +6105,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
6011
6105
|
|
|
6012
6106
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
6013
6107
|
let pending = null
|
|
6014
|
-
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value)
|
|
6108
|
+
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)
|
|
6015
6109
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
6016
6110
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
6017
6111
|
function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && ['settings', 'setup'].includes(state.route.name)) && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
|
|
@@ -6025,7 +6119,7 @@ function connect() {
|
|
|
6025
6119
|
es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
|
|
6026
6120
|
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
6027
6121
|
es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
|
|
6028
|
-
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'projects.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, () => { if (stream === es) streamDog.seen(); soon() })
|
|
6122
|
+
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'projects.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated', 'starts.updated']) es.addEventListener(t, () => { if (stream === es) streamDog.seen(); soon() })
|
|
6029
6123
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
6030
6124
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
6031
6125
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
|
@@ -6049,6 +6143,11 @@ document.addEventListener('change', async (ev) => {
|
|
|
6049
6143
|
} catch (e) { toast(e.message, 5000) }
|
|
6050
6144
|
return sel.closest('#spacesBox') ? spacesRepaint() : render()
|
|
6051
6145
|
}
|
|
6146
|
+
if (ev.target.id === 'f-start-allow') { // 0.9.9: this Mac only
|
|
6147
|
+
const cb = ev.target
|
|
6148
|
+
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; toast(e.message, 6000) }
|
|
6149
|
+
return viewSettings()
|
|
6150
|
+
}
|
|
6052
6151
|
if (ev.target.id === 'f-peer-allow') {
|
|
6053
6152
|
const cb = ev.target
|
|
6054
6153
|
try { const r = await api('POST', '/v1/peers/allow', { allow: cb.checked }); toast(r.allowIncoming ? 'Other machines may now answer and Go here' : 'Other machines can no longer answer or Go here') } catch (e) { cb.checked = !cb.checked; toast(e.message, 6000) }
|
package/ui/icons.js
CHANGED
|
@@ -46,6 +46,7 @@ export const ICONS = {
|
|
|
46
46
|
"archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
|
|
47
47
|
"bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
|
|
48
48
|
"circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>",
|
|
49
|
+
"square": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"/>",
|
|
49
50
|
"message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>"
|
|
50
51
|
}
|
|
51
52
|
|
package/ui/start.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// 0.9.9 New session (docs/plans/start-from-board.md): pick a Mac, a repo it
|
|
2
|
+
// has run Claude Code in, where (the repo, or a new worktree), and the first
|
|
3
|
+
// message (the Live composer, ui/composer.js); Leftoff starts Claude Code there
|
|
4
|
+
// and the session shows on every board right away. Inline on a phone: plain
|
|
5
|
+
// lists of big buttons, no menus or popovers. Pure HTML builders, shared with
|
|
6
|
+
// the unit tests; app.js wires them.
|
|
7
|
+
import { composerBoxHtml } from './composer.js'
|
|
8
|
+
|
|
9
|
+
const REPOS_SHOWN = 6
|
|
10
|
+
|
|
11
|
+
// The choice the page shows: what you picked, else this Mac (or the first
|
|
12
|
+
// online one), its newest repo, and "In the repo".
|
|
13
|
+
export function startPick(data, ui = {}) {
|
|
14
|
+
const ms = (data && data.machines) || []
|
|
15
|
+
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
|
|
16
|
+
const repos = m ? m.repos : []
|
|
17
|
+
const repo = repos.find((r) => r.path === ui.repo) || repos[0] || null
|
|
18
|
+
return { m, repo, where: ui.where === 'worktree' ? 'worktree' : 'repo' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// What happens when you press ↑, in one sentence (or why you can't).
|
|
22
|
+
export function startRule(data, pick, { local = true } = {}) {
|
|
23
|
+
const m = pick.m
|
|
24
|
+
if (!m) return { ok: false, text: 'No Mac to start on.' }
|
|
25
|
+
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.' }
|
|
26
|
+
if (!m.online) return { ok: false, text: m.machine + ' is offline right now.' }
|
|
27
|
+
if (!pick.repo) return { ok: false, text: m.machine + ' hasn’t run Claude Code in a repo yet. Open Claude Code there once and it shows up here.' }
|
|
28
|
+
if (m.here && local) return { ok: true, text: 'It starts right away on this Mac.' }
|
|
29
|
+
if (m.here && m.allowRemote === true) return { ok: true, text: 'It starts right away, and ' + m.machine + ' shows a notification.' }
|
|
30
|
+
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.' }
|
|
31
|
+
return { ok: true, text: 'It starts right away if ' + m.machine + ' allows starting sessions from other devices; otherwise someone there confirms it first.' }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ago = (iso, now) => {
|
|
35
|
+
const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return ''
|
|
36
|
+
const s = Math.max(0, Math.round((now - t) / 1000))
|
|
37
|
+
if (s < 3600) return Math.max(1, Math.round(s / 60)) + 'm ago'
|
|
38
|
+
if (s < 86400) return Math.round(s / 3600) + 'h ago'
|
|
39
|
+
return Math.round(s / 86400) + 'd ago'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function opt({ k, v, label, sub = '', on = false, disabled = false, esc }) {
|
|
43
|
+
return '<button type="button" class="start-opt" role="radio" aria-checked="' + on + '" data-action="start-pick" data-k="' + esc(k) + '" data-v="' + esc(v) + '"' + (disabled ? ' disabled' : '') + '>' +
|
|
44
|
+
'<span class="start-opt-main">' + label + '</span>' + (sub ? '<span class="start-opt-sub">' + sub + '</span>' : '') + '</button>'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The waiting line after a start that has to be confirmed at the Mac.
|
|
48
|
+
export function startAskLine(ask, { clock = (x) => x } = {}) {
|
|
49
|
+
if (!ask) return null
|
|
50
|
+
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.' }
|
|
51
|
+
if (ask.state === 'starting') return { tone: 'wait', text: 'Starting on ' + ask.machine + '…' }
|
|
52
|
+
if (ask.state === 'declined') return { tone: 'bad', text: 'Declined on ' + ask.machine + '.' }
|
|
53
|
+
if (ask.state === 'expired') return { tone: 'bad', text: 'Nobody confirmed it on ' + ask.machine + ' in time.' }
|
|
54
|
+
if (ask.state === 'failed') return { tone: 'bad', text: 'Didn’t start on ' + ask.machine + ': ' + (ask.error || 'unknown error') }
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function startPageHtml(data, ui = {}, { esc, icon, emoji = () => '', local = true, now = Date.now(), clock = (x) => x, draft = '', busy = false } = {}) {
|
|
59
|
+
const pick = startPick(data, ui)
|
|
60
|
+
const rule = startRule(data, pick, { local })
|
|
61
|
+
let h = '<a class="back" href="#/">← All sessions</a>' +
|
|
62
|
+
'<div class="pagehead"><div><h1>New session</h1><div class="sub">Leftoff starts Claude Code on the Mac you pick. It shows on every board right away: talk to it in Live, approve its edits and commands in Needs you.</div></div></div>'
|
|
63
|
+
h += '<div class="start" data-start>'
|
|
64
|
+
// Mac
|
|
65
|
+
const ms = (data && data.machines) || []
|
|
66
|
+
h += '<section class="start-sec"><h2 class="start-h">Mac</h2><div class="start-opts" role="radiogroup" aria-label="Mac">' +
|
|
67
|
+
ms.map((x) => opt({ k: 'machine', v: x.machine, on: !!pick.m && x.machine === pick.m.machine, disabled: !x.online, esc,
|
|
68
|
+
label: esc((emoji(x.machine) ? emoji(x.machine) + ' ' : '') + x.machine), sub: esc(x.here ? (local ? 'this Mac' : 'the Mac you’re connected to') : x.online ? 'online' : 'offline') })).join('') +
|
|
69
|
+
'</div></section>'
|
|
70
|
+
// Repo
|
|
71
|
+
const repos = pick.m ? pick.m.repos : []
|
|
72
|
+
const all = !!ui.allRepos || repos.length <= REPOS_SHOWN + 1
|
|
73
|
+
const shown = all ? repos : repos.slice(0, REPOS_SHOWN).concat(pick.repo && repos.indexOf(pick.repo) >= REPOS_SHOWN ? [pick.repo] : [])
|
|
74
|
+
h += '<section class="start-sec"><h2 class="start-h">Repo</h2>'
|
|
75
|
+
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>'
|
|
76
|
+
else {
|
|
77
|
+
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,
|
|
78
|
+
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('') + '</div>'
|
|
79
|
+
if (!all) h += '<button type="button" class="btn start-more" data-action="start-all-repos">Show all ' + repos.length + '</button>'
|
|
80
|
+
h += '<p class="start-hint">Only repos ' + esc(pick.m.machine) + ' has already run Claude Code in.</p>'
|
|
81
|
+
}
|
|
82
|
+
h += '</section>'
|
|
83
|
+
// Where
|
|
84
|
+
h += '<section class="start-sec"><h2 class="start-h">Where</h2><div class="start-opts start-where" role="radiogroup" aria-label="Where">' +
|
|
85
|
+
opt({ k: 'where', v: 'repo', on: pick.where === 'repo', esc, label: 'In the repo', sub: 'on its current branch' }) +
|
|
86
|
+
opt({ k: 'where', v: 'worktree', on: pick.where === 'worktree', esc, label: 'New worktree', sub: 'its own branch and folder' }) +
|
|
87
|
+
'</div></section>'
|
|
88
|
+
// First message
|
|
89
|
+
h += '<section class="start-sec"><h2 class="start-h" id="start-msg-h">First message</h2><div class="start-box">' +
|
|
90
|
+
composerBoxHtml({ submit: 'start', sendAction: 'start-send', draftKey: 'start', draft, may: rule.ok && !ui.ask, busy, placeholder: 'What should Claude do?', label: 'First message', inputClass: 'start-text', keys: 'Enter Meta+Enter Control+Enter' }) +
|
|
91
|
+
'</div><p class="start-rule' + (rule.ok ? '' : ' bad') + '" role="status">' + esc(rule.text) + '</p>' +
|
|
92
|
+
'<p class="start-hint">Reads run freely; every edit and command waits for you in Needs you (here, your phone, any board).</p>'
|
|
93
|
+
const line = startAskLine(ui.ask, { clock })
|
|
94
|
+
if (line) h += '<div class="start-ask ' + line.tone + '" role="status">' + esc(line.text) + (ui.ask && ['declined', 'expired', 'failed'].includes(ui.ask.state) ? ' <button type="button" class="btn" data-action="start-again">Try again</button>' : '') + '</div>'
|
|
95
|
+
h += '</section></div>'
|
|
96
|
+
return h
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A request waiting on THIS Mac: its board shows it with Start and Decline
|
|
100
|
+
// (only this Mac's own browser may answer).
|
|
101
|
+
export function startAsksBanner(info, { esc, icon, local = true, clock = (x) => x, busy = new Set() } = {}) {
|
|
102
|
+
const asks = info && Array.isArray(info.pending) ? info.pending : []
|
|
103
|
+
if (!asks.length) return ''
|
|
104
|
+
return asks.map((a) => {
|
|
105
|
+
const b = busy.has(a.id)
|
|
106
|
+
return '<div class="banner warn start-ask-banner" role="alert" data-ask="' + esc(a.id) + '"><span class="grow"><b>' + esc(a.who || 'Another device') + ' asks to start a session</b> in ' + esc(a.repo) + (a.where === 'worktree' ? ' (new worktree)' : '') + ' on ' + esc(info.machine || 'this Mac') + ': “' + esc(a.prompt) + '” <span class="rs-m">Until ' + esc(clock(a.expiresAt)) + '.</span></span>' +
|
|
107
|
+
(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>' +
|
|
108
|
+
'<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>'
|
|
109
|
+
}).join('')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The session page's line for a session Leftoff runs: where it runs, who
|
|
113
|
+
// started it, and Stop / Resume. The error (Claude Code's own words) is put
|
|
114
|
+
// in with textContent by app.js ([data-headless-err]).
|
|
115
|
+
export function headlessBarHtml(s, { esc, icon, may = true } = {}) {
|
|
116
|
+
const h = s && s.headless
|
|
117
|
+
if (!h) return ''
|
|
118
|
+
const from = s.startedFrom && s.startedFrom.device ? ' from ' + s.startedFrom.device : ''
|
|
119
|
+
const where = s.machine ? ' on ' + s.machine : ''
|
|
120
|
+
let text, btn = ''
|
|
121
|
+
if (h.state === 'running') { text = 'Running' + where + ', started' + from + ' by Leftoff' + (h.awake ? ' · keeping it awake' : ''); if (may) btn = '<button type="button" class="btn" data-action="headless-stop" data-id="' + esc(s.id) + '">' + icon('square') + 'Stop</button>' }
|
|
122
|
+
else if (h.state === 'stopping') text = 'Stopping' + where + '…'
|
|
123
|
+
else {
|
|
124
|
+
text = (h.state === 'paused' ? 'Paused' + where + ' (Leftoff restarted)' : h.error ? 'Claude Code stopped' + where : 'Stopped' + where) + (from ? ', started' + from : '')
|
|
125
|
+
if (may) btn = '<button type="button" class="btn primary" data-action="headless-run" data-id="' + esc(s.id) + '">' + icon('play') + 'Resume here</button>'
|
|
126
|
+
}
|
|
127
|
+
return '<div class="headless-bar ' + esc(h.state) + '" data-sid="' + esc(s.id) + '"><span class="grow">' + icon('play') + esc(text) + '</span>' + btn + '</div>' +
|
|
128
|
+
(h.error && h.state !== 'running' ? '<pre class="headless-err" data-headless-err="' + esc(s.id) + '"></pre>' : '')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Settings → Privacy & security: the one switch, on this Mac only.
|
|
132
|
+
export function startsSettingHtml(info, { esc, local = true } = {}) {
|
|
133
|
+
if (!info) return ''
|
|
134
|
+
return '<label for="f-start-allow">Start sessions</label><label class="check"><input id="f-start-allow" type="checkbox"' + (info.allowRemote ? ' checked' : '') + (local ? '' : ' disabled') + '><span>Allow starting sessions from other devices</span></label>' +
|
|
135
|
+
'<div class="hint">Off: a session your phone or another Mac starts here waits until someone at this Mac confirms it. On: it starts right away, and this Mac shows a notification each time. Needs Trust my paired devices. Kept on this Mac only (it doesn’t sync)' + (local ? '.' : '; change it on ' + esc(info.machine || 'that Mac') + '.') + '</div>'
|
|
136
|
+
}
|