@leftoff/cli 0.9.8 → 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 +7 -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 +54 -5
- package/ui/app.js +125 -29
- package/ui/composer.js +39 -1
- package/ui/icons.js +1 -0
- package/ui/needs.js +16 -11
- 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.
|
|
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.
|
|
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.
|
|
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
|
@@ -525,8 +525,8 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
525
525
|
.need.k-permission .need-actions .btn, .need.k-permission .deny-form .btn { min-height: 44px; }
|
|
526
526
|
.deny-form { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
527
527
|
.deny-form[hidden] { display: none; }
|
|
528
|
-
.deny-form input, .need .qother
|
|
529
|
-
.deny-form input:focus, .need .qother:focus
|
|
528
|
+
.deny-form input, .need .qother { flex: 1 1 220px; min-width: 0; background: var(--field); border: 1px solid var(--line); border-radius: 8px; color: var(--text); padding: 7px 9px; font: inherit; font-size: 13px; }
|
|
529
|
+
.deny-form input:focus, .need .qother:focus { outline: 2px solid var(--focus); border-color: var(--accent); }
|
|
530
530
|
.need-q { display: grid; gap: 6px; }
|
|
531
531
|
.need-q .qtext { font-size: 13.5px; font-weight: 500; }
|
|
532
532
|
.need-q .qhead { display: inline-block; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-right: 8px; }
|
|
@@ -535,8 +535,8 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
535
535
|
.btn.opt small { color: var(--muted); font-size: 11.5px; font-weight: 400; }
|
|
536
536
|
.btn.opt[aria-pressed="true"] { border-color: var(--accent); background: var(--tint-accent); }
|
|
537
537
|
.need-question { margin: 0; padding: 6px 10px; border-left: 3px solid var(--yellow); background: var(--subtle); color: var(--text2); font-size: 13.5px; white-space: pre-wrap; }
|
|
538
|
-
.
|
|
539
|
-
.reply-
|
|
538
|
+
/* 0.9.8 the Needs-you Reply box is the Live composer (.composer below) */
|
|
539
|
+
.need .reply-composer { margin-top: 8px; }
|
|
540
540
|
.need-badge { display: flex; gap: 8px; align-items: baseline; font-size: 12.5px; padding: 6px 9px; border-radius: 8px; background: var(--tint-yellow); min-width: 0; }
|
|
541
541
|
.need-badge.blocking { background: var(--tint-red); }
|
|
542
542
|
.need-badge .nb-k { font-weight: 700; color: var(--yellow); flex: none; }
|
|
@@ -1365,7 +1365,7 @@ body.pairpage { background: var(--bg); color: var(--text); margin: 0; }
|
|
|
1365
1365
|
.empty.login .codeform input { font-size: 22px !important; }
|
|
1366
1366
|
.cmdk-field input, .pairbox input { font-size: 17px !important; }
|
|
1367
1367
|
.addform input { padding: 5px 10px; }
|
|
1368
|
-
.deny-form input, .need .qother
|
|
1368
|
+
.deny-form input, .need .qother { padding: 6px 9px; }
|
|
1369
1369
|
.card .title input { padding: 2px 6px; }
|
|
1370
1370
|
.standup-out textarea { line-height: 1.4; }
|
|
1371
1371
|
}
|
|
@@ -2163,6 +2163,18 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2163
2163
|
.composer-attach:focus { outline: none; }
|
|
2164
2164
|
.composer-attach:focus-visible .composer-clip { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2165
2165
|
.composer-attach:disabled { cursor: default; opacity: .5; }
|
|
2166
|
+
/* 0.9.8 styled tooltips (data-tip, never title=) for the composer's 📎 and ↑
|
|
2167
|
+
(Live and the Needs-you Reply box), the Needs-you card's controls and Go ↗.
|
|
2168
|
+
Hover with a mouse, or keyboard focus; display:none otherwise, so a tip
|
|
2169
|
+
never widens a phone page. aria-label carries the words for a screen reader. */
|
|
2170
|
+
:where(.composer-attach[data-tip], .composer-send[data-tip], .need [data-tip], .btn.go[data-tip]) { position: relative; }
|
|
2171
|
+
.composer-attach[data-tip]::after, .composer-send[data-tip]::after, .need [data-tip]::after, .btn.go[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); right: 0; width: max-content; max-width: min(260px, 70vw); 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; font-style: normal; text-align: left; white-space: normal; letter-spacing: normal; pointer-events: none; z-index: 20; }
|
|
2172
|
+
.composer-attach[data-tip]::after, .composer-send[data-tip]::after { top: auto; bottom: calc(100% + 4px); }
|
|
2173
|
+
.composer-attach[data-tip]::after { right: auto; left: 0; }
|
|
2174
|
+
@media (hover: hover) { .composer-attach[data-tip]:hover::after, .composer-send[data-tip]:hover::after, .need [data-tip]:hover::after, .btn.go[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
|
|
2175
|
+
.composer-attach[data-tip]:focus-visible::after, .composer-send[data-tip]:focus-visible::after, .need [data-tip]:focus-visible::after, .btn.go[data-tip]:focus-visible::after { display: block; animation: tip-in .12s ease-out both; }
|
|
2176
|
+
@keyframes tip-in { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
|
|
2177
|
+
@media (prefers-reduced-motion: reduce) { .composer-attach[data-tip]::after, .composer-send[data-tip]::after, .need [data-tip]::after, .btn.go[data-tip]::after { animation: none !important; } }
|
|
2166
2178
|
.att-chip { position: relative; display: flex; align-items: center; gap: 8px; max-width: 100%; min-width: 0; width: auto; flex: 0 1 auto; height: 52px; padding: 4px 0 4px 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); overflow: hidden; cursor: default; }
|
|
2167
2179
|
[data-theme="light"] .att-chip { background: #f6f7f9; }
|
|
2168
2180
|
.att-chip.failed { border-color: color-mix(in srgb, var(--red) 60%, var(--line)); }
|
|
@@ -2834,3 +2846,40 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
2834
2846
|
.lv-tools .lv-tool-open::after { content: '›'; position: absolute; right: 8px; top: 0; color: var(--faint); font-size: 14px; }
|
|
2835
2847
|
.lv-tools .lv-tool-open:hover { background: var(--bg2); color: var(--text); }
|
|
2836
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
|
@@ -15,7 +15,8 @@ import { canStartPull, pullStep, pullRelease } from './pull.js'
|
|
|
15
15
|
import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
16
16
|
import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
|
|
17
17
|
import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
|
|
18
|
-
import { isBlank, composerKey, isTouchUi, composerHeight } from './composer.js' // 0.9.8 the Live composer
|
|
18
|
+
import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml } 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"
|
|
@@ -426,15 +427,15 @@ function goButton(s, { page = false } = {}) {
|
|
|
426
427
|
if (!j.peer) return ''
|
|
427
428
|
// 0.9.2: "Go ↗" like a local card (a phone cut "Go on <machine>" short); the machine is in its title and label
|
|
428
429
|
const label = 'Go ↗', on = 'Go on ' + s.machine
|
|
429
|
-
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '"
|
|
430
|
-
if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '"
|
|
430
|
+
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" data-tip="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
|
|
431
|
+
if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" data-tip="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
|
|
431
432
|
const st = goState.get(s.id)
|
|
432
|
-
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '"
|
|
433
|
+
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" data-tip="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
|
|
433
434
|
}
|
|
434
|
-
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled
|
|
435
|
-
if (!j.supported) return '<button type="button" class="btn go" disabled
|
|
435
|
+
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled data-tip="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
|
|
436
|
+
if (!j.supported) return '<button type="button" class="btn go" disabled data-tip="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
|
|
436
437
|
const st = goState.get(s.id)
|
|
437
|
-
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗"
|
|
438
|
+
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="Go ↗" data-tip="' + esc('Bring this session’s ' + (s.terminal ? s.terminal.label + ' ' : '') + 'tab to the front') + '">' + esc(st ? st.label : 'Go ↗') + '</button>'
|
|
438
439
|
}
|
|
439
440
|
|
|
440
441
|
// The short reason a cross-machine button is grey (the full one is its tooltip).
|
|
@@ -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())
|
|
@@ -1264,7 +1267,7 @@ function paintNeedsCount(n) {
|
|
|
1264
1267
|
|
|
1265
1268
|
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
|
|
1266
1269
|
function needOpts(extra = {}) {
|
|
1267
|
-
return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', ...extra }
|
|
1270
|
+
return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', composerKeys: isTouchUi(window.matchMedia) ? 'Meta+Enter Control+Enter' : 'Enter Meta+Enter Control+Enter', ...extra }
|
|
1268
1271
|
}
|
|
1269
1272
|
|
|
1270
1273
|
// ⌘↵ (Ctrl+↵ off a Mac) in a Needs-you text box (the Deny note, a question's
|
|
@@ -1393,7 +1396,7 @@ function paintSession(s, data) {
|
|
|
1393
1396
|
$app.innerHTML = sessionPageHtml(s, data)
|
|
1394
1397
|
liveAfterRender(s.id)
|
|
1395
1398
|
if (kf) {
|
|
1396
|
-
const nt = $app.querySelector('.composer-input')
|
|
1399
|
+
const nt = $app.querySelector('.composer-input[data-draft="' + CSS.escape(kf.d || '') + '"]')
|
|
1397
1400
|
if (nt && nt.dataset.draft === kf.d && !nt.disabled) { nt.focus({ preventScroll: true }); try { const n = nt.value.length; nt.setSelectionRange(Math.min(kf.a, n), Math.min(kf.b, n)) } catch {} }
|
|
1398
1401
|
} else if (kb && !$app.contains(document.activeElement)) {
|
|
1399
1402
|
const hits = $app.querySelectorAll(kb)
|
|
@@ -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) +
|
|
@@ -1594,20 +1599,12 @@ function liveBoxHtml(s, v) {
|
|
|
1594
1599
|
// grows, and a round ↑ in its corner (dim until there's something to send).
|
|
1595
1600
|
// No interrupt reaches Claude Code from the board, so while it works the
|
|
1596
1601
|
// button still sends: the message waits for its next turn, as before.
|
|
1597
|
-
const SEND_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>'
|
|
1598
|
-
const CLIP_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg>'
|
|
1599
1602
|
const FILE_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>'
|
|
1600
1603
|
function composerHtml(s, draft, may) {
|
|
1601
1604
|
const kbd = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? 'Meta+Enter' : 'Control+Enter'
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
'<div class="att-chips" aria-label="Attachments"' + (atts.length ? '' : ' hidden') + '>' + atts.map(chipHtml).join('') + '</div>' +
|
|
1606
|
-
'<textarea class="composer-input live-text" rows="1" maxlength="4000" placeholder="Message this session…" aria-label="Message this session" autocomplete="off" data-draft="live:' + esc(s.id) + '" data-submit="live" aria-keyshortcuts="' + (isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd) + '"' + (may ? '' : ' disabled') + '>' + esc(draft) + '</textarea>' +
|
|
1607
|
-
// the phone's own picker (Photo Library, Take Photo, Choose File): any file
|
|
1608
|
-
'<input type="file" class="att-input" multiple tabindex="-1" aria-hidden="true" data-sid="' + esc(s.id) + '"' + (may ? '' : ' disabled') + '>' +
|
|
1609
|
-
'<button type="button" class="composer-attach" data-action="attach" data-id="' + esc(s.id) + '" aria-label="Attach" title="Attach photos or files"' + (may ? '' : ' disabled') + '><span class="composer-clip">' + CLIP_IC + '</span></button>' +
|
|
1610
|
-
'<button type="button" class="composer-send" data-action="live-send" data-submit-for="live" data-id="' + esc(s.id) + '" aria-label="Send" title="Send"' + (off ? ' disabled' : '') + '><span class="composer-dot">' + SEND_IC + '</span></button></div>'
|
|
1605
|
+
return composerBoxHtml({ sid: s.id, submit: 'live', sendAction: 'live-send', draftKey: 'live:' + s.id, draft, may,
|
|
1606
|
+
placeholder: 'Message this session…', label: 'Message this session', boxClass: 'live-row', inputClass: 'live-text',
|
|
1607
|
+
attach: true, atts: attachList(s.id), chipHtml, keys: isTouchUi(window.matchMedia) ? kbd : 'Enter ' + kbd })
|
|
1611
1608
|
}
|
|
1612
1609
|
|
|
1613
1610
|
// ------------------------------------------------------------ attachments (0.9.8, ui/attach.js)
|
|
@@ -1895,6 +1892,7 @@ function syncComposer(ta) {
|
|
|
1895
1892
|
}
|
|
1896
1893
|
document.addEventListener('input', (ev) => { if (ev.target && ev.target.classList && ev.target.classList.contains('composer-input')) fitComposer(ev.target) })
|
|
1897
1894
|
// Enter on a Mac sends (Shift+Enter: a new line); on a phone Return is a new line. ⌘↵ is the shared handler above.
|
|
1895
|
+
// The same for Live and the Needs-you Reply composer: the key presses its own box's ↑.
|
|
1898
1896
|
document.addEventListener('keydown', (ev) => {
|
|
1899
1897
|
const ta = ev.target
|
|
1900
1898
|
if (ev.key !== 'Enter' || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
|
|
@@ -1903,8 +1901,8 @@ document.addEventListener('keydown', (ev) => {
|
|
|
1903
1901
|
ev.preventDefault()
|
|
1904
1902
|
const cb = ta.closest('.composer')
|
|
1905
1903
|
if (ev.repeat || (isBlank(ta.value) && !(cb && attachList(cb.dataset.sid).length))) return
|
|
1906
|
-
const box = ta.closest('.live-box')
|
|
1907
|
-
const btn = box && box.querySelector('[data-submit-for="live"]')
|
|
1904
|
+
const box = ta.closest('.live-box') || ta.closest('.need') || ta.closest('.start-box')
|
|
1905
|
+
const btn = box && box.querySelector('[data-submit-for="' + CSS.escape(ta.dataset.submit || 'live') + '"]')
|
|
1908
1906
|
if (!btn || btn.disabled) return
|
|
1909
1907
|
const last = Number(box.dataset.kbdAt || 0)
|
|
1910
1908
|
if (Date.now() - last < 1500) return
|
|
@@ -3360,6 +3358,7 @@ function queueSave() { clearTimeout(saveUi.timer); saveUi.timer = setTimeout(pai
|
|
|
3360
3358
|
|
|
3361
3359
|
async function viewSettings() {
|
|
3362
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
|
|
3363
3362
|
state.relay = relay
|
|
3364
3363
|
state.gh = gh
|
|
3365
3364
|
syncUi.data = sync; syncUi.peers = peers; if (st) stUi.data = st
|
|
@@ -3398,6 +3397,7 @@ async function viewSettings() {
|
|
|
3398
3397
|
phone: phonePane(relay, remote, unavailable),
|
|
3399
3398
|
security: '<div class="section" id="answering">Other devices</div>' + (answeringSection(remote, relay) ||
|
|
3400
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) }) +
|
|
3401
3401
|
// a changed machine key needs a click here, so Other machines stays up top then
|
|
3402
3402
|
(peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? machinesSection(peers) : '') +
|
|
3403
3403
|
advanced('security', (peers && (peers.peers || []).some((x) => x.trust === 'key_changed') ? '' : machinesSection(peers)) +
|
|
@@ -4149,16 +4149,76 @@ async function render() {
|
|
|
4149
4149
|
else if (r.name === 'whats-new') await viewWhatsNew()
|
|
4150
4150
|
else if (r.name === 'docs' && r.arg) await viewDocPage(r.arg)
|
|
4151
4151
|
else if (r.name === 'search') await viewSearch(r.q || '')
|
|
4152
|
+
else if (r.name === 'new') await viewNewSession()
|
|
4152
4153
|
else if (r.name === 'setup') await viewSetup()
|
|
4153
4154
|
else if (r.name === 'tour') { location.replace('#/'); startTour({ api }); return }
|
|
4154
4155
|
else await viewBoard()
|
|
4155
4156
|
tabsAfterRender()
|
|
4157
|
+
fillHeadlessErrors()
|
|
4158
|
+
// a Needs-you Reply composer (0.9.8) fits a restored multi-line draft
|
|
4159
|
+
for (const ta of $app.querySelectorAll('.reply-composer .composer-input')) fitComposer(ta)
|
|
4156
4160
|
} catch (e) {
|
|
4157
4161
|
if (e.code === 'login_required') return showLogin()
|
|
4158
4162
|
$app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
|
|
4159
4163
|
}
|
|
4160
4164
|
}
|
|
4161
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
|
+
|
|
4162
4222
|
// First-run setup wizard (0.9.7, ui/wizard.js). Live updates don't redraw it:
|
|
4163
4223
|
// it keeps its own step and fetches its own state.
|
|
4164
4224
|
async function viewSetup() {
|
|
@@ -5291,6 +5351,35 @@ document.addEventListener('click', async (ev) => {
|
|
|
5291
5351
|
} catch (e) { cuUi.making = false; cuUi.err = e.message; render() }
|
|
5292
5352
|
return
|
|
5293
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
|
+
}
|
|
5294
5383
|
if (action === 'restore-ask-confirm' || action === 'restore-ask-decline') {
|
|
5295
5384
|
const id = btn.dataset.id
|
|
5296
5385
|
if (!id || restoreUi.busy.has(id)) return
|
|
@@ -5667,9 +5756,11 @@ async function replyClick(btn) {
|
|
|
5667
5756
|
const need = btn.closest('.need')
|
|
5668
5757
|
const ta = need && need.querySelector('.reply-text')
|
|
5669
5758
|
const text = ta ? ta.value.trim() : ''
|
|
5670
|
-
if (!text) { toast('Type a reply first'); return }
|
|
5759
|
+
if (!text) { toast('Type a reply first'); if (ta && !ta.disabled) ta.focus(); return }
|
|
5671
5760
|
if (text.startsWith('/')) { toast('Replies can’t start with “/”'); return }
|
|
5672
|
-
|
|
5761
|
+
if (btn.classList.contains('sending')) return
|
|
5762
|
+
// the composer's ↑ (0.9.8): it pulses while the reply goes, like Live's
|
|
5763
|
+
btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
|
|
5673
5764
|
try {
|
|
5674
5765
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
|
|
5675
5766
|
delete state.drafts['reply:' + need.dataset.id]
|
|
@@ -6014,7 +6105,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
6014
6105
|
|
|
6015
6106
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
6016
6107
|
let pending = null
|
|
6017
|
-
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .tip:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box') && document.activeElement.value)
|
|
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)
|
|
6018
6109
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
6019
6110
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
6020
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) }
|
|
@@ -6028,7 +6119,7 @@ function connect() {
|
|
|
6028
6119
|
es.addEventListener('ping', () => { if (stream === es) streamDog.seen() })
|
|
6029
6120
|
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
6030
6121
|
es.addEventListener('projects.updated', () => { projectsUi.at = 0 }) // 0.9.0
|
|
6031
|
-
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'projects.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, () => { if (stream === es) streamDog.seen(); soon() })
|
|
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() })
|
|
6032
6123
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
6033
6124
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
6034
6125
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
|
@@ -6052,6 +6143,11 @@ document.addEventListener('change', async (ev) => {
|
|
|
6052
6143
|
} catch (e) { toast(e.message, 5000) }
|
|
6053
6144
|
return sel.closest('#spacesBox') ? spacesRepaint() : render()
|
|
6054
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
|
+
}
|
|
6055
6151
|
if (ev.target.id === 'f-peer-allow') {
|
|
6056
6152
|
const cb = ev.target
|
|
6057
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) }
|