@leftoff/cli 0.9.9-beta.7 → 0.9.9-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/bin/leftoff.js +459 -408
- package/docs/SECURITY.md +2 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +106 -0
- package/ui/app.js +162 -19
- package/ui/composer.js +12 -0
- package/ui/live-status.js +12 -1
- package/ui/live-view.js +48 -9
- package/ui/live-wake.js +1 -1
- package/ui/needs.js +158 -2
- package/ui/start.js +113 -11
package/docs/SECURITY.md
CHANGED
|
@@ -113,7 +113,8 @@ Live link is opt-in, per session: only sessions started with `leftoff live` have
|
|
|
113
113
|
- **This Mac's own board** starts it at once. **A phone or another Mac** may start one only when that Mac has *Allow starting sessions from other devices* on (Settings → Privacy & security, off by default) **and** *Trust my paired devices* is on. The switch is kept on that Mac (`~/.config/checkpoints/local/`, not the synced folder, so turning it on one Mac never turns it on another) and only that Mac's own browser can change it (loopback; a phone, the relay or a peer gets 403). Every start from another device shows a notification on the Mac. With the switch off, the request waits up to 5 minutes for a yes on that Mac's board, like Remote Restore. At most 3 wait at once; 3 starts at once, then 1 a minute.
|
|
114
114
|
- **The repo list is folders that Mac has already run Claude Code in** (from its session records; no typed paths). A headless run loads a repo's hooks and `.mcp.json` without the trust dialog, so a folder Claude Code never ran in is treated as a first time:
|
|
115
115
|
- **Other folder…** browses that Mac's folders: names only (never a file or what's in one), inside your home by real path (a symlink out of home is refused or left out), never the synced Leftoff folder or anything in it, no hidden folders, `node_modules` or `Library`, at most 500 per listing. Another Mac's are read over the peer link (`fs.dirs`, not an open action, the same as any read), a phone's through the relay. Nothing is written.
|
|
116
|
-
- **A first time starts at once only from that Mac's own board** (loopback, or your hosted board open on it): you are the trust prompt. From a phone or another Mac it
|
|
116
|
+
- **A first time starts at once only from that Mac's own board** (loopback, or your hosted board open on it): you are the trust prompt. From a phone or another Mac it waits for a yes on that Mac's board, even with *Allow starting sessions from other devices* on (unless it's inside an approved folder, below), and the ask and the notification say "first time in <folder>; this runs its hooks". After one run it's in the list like the rest. Home itself is refused; a folder that isn't a git repo can't get a new worktree.
|
|
117
|
+
- **Approved folders** (per Mac, 0.9.9): with the switch on, a first time inside a folder you approved on that Mac starts from another device without the ask (and notifies that Mac, like any remote start). They're kept in that Mac's own local file (`~/.config/checkpoints/local/`, not `config.json`, never synced), stored as real paths inside your home; home itself, the synced Leftoff folder (or anything inside it), hidden folders and links out of home are refused. A start counts by the folder's real path, so a link that only looks inside an approved folder doesn't. They're added and removed only at the Mac itself: Settings (locked elsewhere), "Always allow in <folder>" on the first-time ask (the folder above it that holds the repos that Mac knows, or its parent; never home), or `leftoff starts allow|disallow <folder>`.
|
|
117
118
|
- **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.
|
|
118
119
|
- **Other Macs** reach it over the signed, encrypted peer link (`session.start`, `session.start.get`, `session.headless`, `fs.dirs`; not open actions, so the target Mac's *Allow other machines* applies too).
|
|
119
120
|
- **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`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.9-beta.
|
|
3
|
+
"version": "0.9.9-beta.8",
|
|
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/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.9-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.9-beta.8' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.9-beta.
|
|
3
|
+
"version": "0.9.9-beta.8",
|
|
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
|
@@ -2114,6 +2114,16 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
|
|
|
2114
2114
|
.lv-tick { margin-top: 4px; font-size: 11.5px; color: var(--muted); text-align: right; }
|
|
2115
2115
|
.lv-tick.got span { color: var(--accent); letter-spacing: -2px; padding-right: 2px; }
|
|
2116
2116
|
.lv-tick.bad { color: var(--red); text-align: left; }
|
|
2117
|
+
/* 0.9.9 Queued: sent while Claude works, not taken in yet: dimmed, a dashed edge, Queued (not Working) */
|
|
2118
|
+
.lv-you.lv-queued { background: transparent; border-style: dashed; border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
|
|
2119
|
+
.lv-you.lv-queued .lv-text, .lv-you.lv-queued .lv-who, .lv-you.lv-queued .att-bubble { opacity: .62; }
|
|
2120
|
+
.lv-q { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px 8px; margin-top: 6px; font-size: 12px; color: var(--muted); }
|
|
2121
|
+
.lv-q-tag { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; letter-spacing: .01em; color: var(--muted); }
|
|
2122
|
+
.lv-q-tag::before { content: ""; width: 7px; height: 7px; border-radius: 50%; border: 1.5px solid currentColor; }
|
|
2123
|
+
.lv-q-hint { color: var(--muted); }
|
|
2124
|
+
.lv-q-acts { display: inline-flex; gap: 6px; }
|
|
2125
|
+
.lv-q-btn { min-height: 44px; min-width: 64px; padding: 0 14px; font-size: 14px; }
|
|
2126
|
+
.lv-q-btn:focus:not(:focus-visible) { outline: none; }
|
|
2117
2127
|
.lv-working .lvs-line { margin-top: 0; }
|
|
2118
2128
|
.lv-pending .lv-working { margin-top: 4px; }
|
|
2119
2129
|
.lvs-sep { color: var(--faint); }
|
|
@@ -2888,6 +2898,29 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
2888
2898
|
.headless-bar.running { border-color: var(--green); background: var(--tint-green); }
|
|
2889
2899
|
.headless-bar.paused { border-color: var(--yellow); background: var(--tint-yellow); }
|
|
2890
2900
|
.headless-bar .btn { min-height: 44px; }
|
|
2901
|
+
/* 0.9.9 Permissions: the New session choice, the session page's switch and its pill beside Live */
|
|
2902
|
+
.start-mode { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
2903
|
+
.start-mode .start-opt-sub { white-space: normal; }
|
|
2904
|
+
.headless-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: -4px 0 10px; }
|
|
2905
|
+
.headless-mode .hm-l { font-size: 12.5px; font-weight: 600; color: var(--muted); }
|
|
2906
|
+
.hm-seg { display: inline-flex; border: 1px solid var(--line); border-radius: 10px; overflow: visible; background: var(--card); }
|
|
2907
|
+
.hm-opt { position: relative; min-height: 44px; min-width: 44px; padding: 0 14px; border: 0; border-left: 1px solid var(--line); background: transparent; color: var(--text2); font: inherit; font-size: 13.5px; cursor: pointer; }
|
|
2908
|
+
.hm-opt:first-child { border-left: 0; border-radius: 10px 0 0 10px; }
|
|
2909
|
+
.hm-opt:last-child { border-radius: 0 10px 10px 0; }
|
|
2910
|
+
.hm-opt[aria-checked="true"] { background: var(--tint-accent); color: var(--text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent); }
|
|
2911
|
+
.hm-opt:disabled { opacity: .55; cursor: default; }
|
|
2912
|
+
.hm-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; z-index: 1; }
|
|
2913
|
+
.hm-opt[data-tip]::after, .mode-pill[data-tip]::after { content: attr(data-tip); display: none; position: absolute; top: calc(100% + 4px); left: 0; width: max-content; max-width: min(280px, 80vw); 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.35; font-weight: 500; text-align: left; white-space: normal; pointer-events: none; z-index: 20; }
|
|
2914
|
+
@media (hover: hover) { .hm-opt[data-tip]:hover::after, .mode-pill[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
|
|
2915
|
+
.hm-opt[data-tip]:focus-visible::after, .mode-pill[data-tip]:focus-visible::after { display: block; }
|
|
2916
|
+
.hm-hint { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--muted); }
|
|
2917
|
+
.mode-pill { position: relative; display: inline-block; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--bg2); color: var(--text2); font-size: 11.5px; font-weight: 600; line-height: 1.5; white-space: nowrap; }
|
|
2918
|
+
.mode-pill.m-auto { border-color: var(--accent); background: var(--tint-accent); color: var(--text); }
|
|
2919
|
+
.mode-pill.m-acceptEdits { border-color: var(--yellow); background: var(--tint-yellow); color: var(--text); }
|
|
2920
|
+
@media (max-width: 640px) {
|
|
2921
|
+
.hm-seg { display: flex; width: 100%; }
|
|
2922
|
+
.hm-opt { flex: 1 1 0; padding: 0 6px; }
|
|
2923
|
+
}
|
|
2891
2924
|
.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; }
|
|
2892
2925
|
@media (max-width: 640px) {
|
|
2893
2926
|
.start-opts { grid-template-columns: 1fr; }
|
|
@@ -2984,3 +3017,76 @@ del.gd-x { background: var(--gd-del-word); }
|
|
|
2984
3017
|
.form label.check.locked input { cursor: not-allowed; }
|
|
2985
3018
|
.form label.check .lock { display: inline-flex; color: var(--muted); }
|
|
2986
3019
|
.form label.check .lock .ic { width: 14px; height: 14px; }
|
|
3020
|
+
/* 0.9.9 Settings → Approved folders: a list with Remove, and an add field (16px) */
|
|
3021
|
+
.form label[for="f-start-approve"] { align-self: start; padding-top: 12px; }
|
|
3022
|
+
.approved { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
|
3023
|
+
.approved-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
|
3024
|
+
.approved-list li { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg2); }
|
|
3025
|
+
.approved-path { flex: 1; min-width: 0; overflow-wrap: anywhere; color: var(--text); font-size: 13px; }
|
|
3026
|
+
.approved .mono, .start-ask-banner .mono { font-family: var(--mono); }
|
|
3027
|
+
.approved-x { flex: none; min-height: 44px; min-width: 44px; justify-content: center; gap: 4px; }
|
|
3028
|
+
.approved-x .ic { width: 14px; height: 14px; }
|
|
3029
|
+
.approved-none { color: var(--faint); font-size: 13px; }
|
|
3030
|
+
.approved-add { display: flex; gap: 8px; align-items: center; }
|
|
3031
|
+
.form .approved-add input { flex: 1; min-width: 0; height: 44px; font-size: 16px; }
|
|
3032
|
+
.approved-add .btn { min-height: 44px; flex: none; }
|
|
3033
|
+
.approved.locked .approved-add input, .approved.locked .approved-add .btn { cursor: not-allowed; }
|
|
3034
|
+
.approved .lock { display: inline-flex; color: var(--muted); }
|
|
3035
|
+
.approved .lock .ic { width: 14px; height: 14px; }
|
|
3036
|
+
.approved-err { color: var(--red); font-size: 12.5px; overflow-wrap: anywhere; }
|
|
3037
|
+
.start-ask-banner .btn.approve { max-width: 100%; }
|
|
3038
|
+
.start-ask-banner .btn.approve .mono { overflow-wrap: anywhere; white-space: normal; text-align: left; }
|
|
3039
|
+
@media (max-width: 640px) {
|
|
3040
|
+
.start-ask-banner > .btn.approve { order: 1; flex: 1 1 100%; }
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
/* 0.9.9 the ask in Live: a waiting permission prompt (or question) takes the
|
|
3044
|
+
composer's place at the bottom of Live, and stays in reach while you read the
|
|
3045
|
+
thread (sticky inside the Live box, above the phone's tab bar and the home
|
|
3046
|
+
indicator); the thread keeps a short row for each ask where it happened. */
|
|
3047
|
+
.ask-dock-wrap { position: sticky; bottom: max(12px, calc(env(safe-area-inset-bottom) + 8px)); z-index: 6; margin-top: 10px; }
|
|
3048
|
+
.need.ask-dock { gap: 10px; padding: 12px 14px; border-radius: 16px; border-color: color-mix(in srgb, var(--red) 50%, var(--line)); background: var(--card); box-shadow: 0 8px 28px rgba(15, 20, 35, .14), 0 1px 3px rgba(15, 20, 35, .08); }
|
|
3049
|
+
[data-theme="dark"] .need.ask-dock { background: var(--card2); box-shadow: 0 8px 28px rgba(0, 0, 0, .45); }
|
|
3050
|
+
.ask-dock .need-head { font-size: 13px; }
|
|
3051
|
+
.ask-count { font-size: 12px; font-weight: 600; color: var(--text2); background: var(--field); border: 1px solid var(--line); border-radius: 10px; padding: 1px 8px; font-variant-numeric: tabular-nums; }
|
|
3052
|
+
.ask-what { display: grid; gap: 6px; min-width: 0; }
|
|
3053
|
+
.ask-verb { font-weight: 600; font-size: 15px; color: var(--text); }
|
|
3054
|
+
.ask-detail { display: block; font-family: var(--mono); font-size: 13px; line-height: 1.5; background: var(--field); border: 1px solid var(--line); border-radius: 8px; padding: 6px 9px; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-height: 40vh; overflow: auto; }
|
|
3055
|
+
.ask-detail.long { max-height: calc(3 * 1.5em + 12px); overflow: hidden; }
|
|
3056
|
+
.ask-detail.long.open { max-height: 40vh; overflow: auto; }
|
|
3057
|
+
.ask-more, .ask-reply-instead { justify-self: start; min-height: 44px; padding: 0 2px; margin: -6px 0 -8px; border: 0; background: none; color: var(--accent); font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
3058
|
+
.ask-reply-instead { color: var(--muted); }
|
|
3059
|
+
.ask-more:hover, .ask-reply-instead:hover { text-decoration: underline; }
|
|
3060
|
+
.ask-more:focus-visible, .ask-reply-instead:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
|
|
3061
|
+
.ask-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: stretch; }
|
|
3062
|
+
.ask-actions .btn, .ask-dock .deny-form .btn { min-height: 44px; font-size: 14px; padding: 8px 16px; justify-content: center; white-space: nowrap; }
|
|
3063
|
+
.ask-actions .btn.ask-allow { min-width: 120px; font-size: 15px; }
|
|
3064
|
+
.ask-dock .deny-form .deny-note { flex: 1 1 220px; min-width: 0; min-height: 44px; box-sizing: border-box; font-size: 16px; }
|
|
3065
|
+
@media (max-width: 699.98px) {
|
|
3066
|
+
.ask-dock-wrap { bottom: calc(var(--bar-h) + 8px + env(safe-area-inset-bottom)); }
|
|
3067
|
+
body.nav-hidden .ask-dock-wrap { bottom: max(10px, calc(env(safe-area-inset-bottom) + 6px)); }
|
|
3068
|
+
.need.ask-dock { padding: 12px; }
|
|
3069
|
+
.ask-actions .btn { flex: 1 1 calc(50% - 4px); font-size: 15px; min-height: 48px; }
|
|
3070
|
+
.ask-actions .btn.ask-allow, .ask-actions .btn.ask-deny { flex-basis: 100%; }
|
|
3071
|
+
.ask-actions .always-wrap { flex: 1 1 calc(50% - 4px); }
|
|
3072
|
+
.ask-actions .always-wrap .btn { flex: 1 1 auto; width: 100%; }
|
|
3073
|
+
.ask-dock .deny-form .btn { flex: 1 1 calc(50% - 4px); min-height: 48px; }
|
|
3074
|
+
.ask-dock .deny-form .deny-note { flex-basis: 100%; }
|
|
3075
|
+
}
|
|
3076
|
+
/* after "Reply instead": one line above the composer, Answer brings the card back */
|
|
3077
|
+
.ask-slim { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 4px 4px 4px 12px; border: 1px solid color-mix(in srgb, var(--red) 40%, var(--line)); border-radius: 12px; background: var(--tint-red); font-size: 13px; min-width: 0; }
|
|
3078
|
+
.ask-slim .need-dot { background: var(--red); }
|
|
3079
|
+
.ask-slim-t { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text2); }
|
|
3080
|
+
.ask-slim .btn { flex: none; min-height: 44px; min-width: 44px; justify-content: center; }
|
|
3081
|
+
/* the row in the thread: what it asked, and how it was answered */
|
|
3082
|
+
.lv-ask { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 8px; row-gap: 2px; margin: 0 0 8px; padding: 6px 10px; border-left: 3px solid var(--line-strong); border-radius: 0 8px 8px 0; background: color-mix(in srgb, var(--muted) 7%, transparent); font-size: 12.5px; color: var(--text2); min-width: 0; }
|
|
3083
|
+
.lv-ask .lva-what { flex: 1 1 200px; min-width: 0; overflow-wrap: anywhere; }
|
|
3084
|
+
.lv-ask .lva-verb { font-weight: 600; }
|
|
3085
|
+
.lv-ask code { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
|
|
3086
|
+
.lv-ask .lva-state { flex: none; max-width: 100%; font-weight: 600; font-size: 12px; overflow-wrap: anywhere; }
|
|
3087
|
+
.lv-ask.t-ok { border-left-color: var(--green); } .lv-ask.t-ok .lva-state { color: var(--green); }
|
|
3088
|
+
.lv-ask.t-auto { border-left-color: var(--accent); } .lv-ask.t-auto .lva-state { color: var(--accent); }
|
|
3089
|
+
.lv-ask.t-no { border-left-color: var(--red); } .lv-ask.t-no .lva-state { color: var(--red); }
|
|
3090
|
+
.lv-ask.t-wait { border-left-color: var(--red); background: var(--tint-red); } .lv-ask.t-wait .lva-state { color: var(--red); }
|
|
3091
|
+
.ask-note { color: var(--muted); font-size: 13px; margin-top: -4px; }
|
|
3092
|
+
.lv-ask .lva-what { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; }
|
package/ui/app.js
CHANGED
|
@@ -15,13 +15,13 @@ import { canStartPull, pullStep, pullRelease } from './pull.js'
|
|
|
15
15
|
import { navHideStep, NAVHIDE_TYPING } from './navhide.js'
|
|
16
16
|
import { openLightbox } from './lightbox.js' // 0.9.8: the image preview
|
|
17
17
|
import { openPdfView, pdfThumb } from './pdfview.js' // 0.9.8: the PDF preview (pdf.js loads on the first PDF)
|
|
18
|
-
import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml } from './composer.js' // 0.9.8 the Live composer
|
|
19
|
-
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath } from './start.js' // 0.9.9 New session
|
|
18
|
+
import { isBlank, composerKey, isTouchUi, composerHeight, composerBoxHtml, keepFocusAfterSend, letGoOfKeyboard } from './composer.js' // 0.9.8 the Live composer
|
|
19
|
+
import { startPageHtml, startPick, startAsksBanner, headlessBarHtml, modePillHtml, modeLabel, startsSettingHtml, localPillHtml, lockedAttrs, lockHtml, startViewerLocal as startViewerAt, startTexts, fillStartTexts, browseTarget, startPairAskHtml, typedPath, fillApproved } from './start.js' // 0.9.9 New session
|
|
20
20
|
import { attachPlan, skippedText, shortName, prepareFile, uploadFile, fetchAttachment, fmtSize, attachKind, pastedName, ATTACH_MAX_FILES } from './attach.js' // 0.9.8 attachments
|
|
21
21
|
import { wireTips } from './tips.js'
|
|
22
22
|
import { factHtml } from './facts.js' // 0.9.9 the session page's info cards
|
|
23
23
|
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
24
|
-
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
24
|
+
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
|
|
25
25
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
26
26
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
27
27
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
@@ -1398,6 +1398,7 @@ function paintSession(s, data) {
|
|
|
1398
1398
|
liveKeeper.before($app)
|
|
1399
1399
|
$app.innerHTML = sessionPageHtml(s, data)
|
|
1400
1400
|
liveAfterRender(s.id)
|
|
1401
|
+
fitAskDetail($app)
|
|
1401
1402
|
if (kf) {
|
|
1402
1403
|
const nt = $app.querySelector('.composer-input[data-draft="' + CSS.escape(kf.d || '') + '"]')
|
|
1403
1404
|
if (nt && nt.dataset.draft === kf.d && !nt.disabled) { nt.focus({ preventScroll: true }); try { const n = nt.value.length; nt.setSelectionRange(Math.min(kf.a, n), Math.min(kf.b, n)) } catch {} }
|
|
@@ -1495,14 +1496,14 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
1495
1496
|
// 0.9.8: one line each, 44px, an icon and a short label; the group drops under the title as a whole when it doesn't fit beside it
|
|
1496
1497
|
'<div class="actions s-actions">' + goButton(s, { page: true }) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '" title="Open in Timeline" aria-label="Open in Timeline">' + icon('history') + 'Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '" title="' + esc(resumeTip(s, 'Copy resume command')) + '">' + icon('copy') + 'Copy resume</button>' + (s.liveResumeCommand ? '<button type="button" class="btn" data-action="copy-cmd" data-cmd="' + esc(s.liveResumeCommand) + '" title="Exit the session in its terminal, then run this to bring it back Live">' + icon('copy') + 'Copy Live resume</button>' : '') + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
|
|
1497
1498
|
html += ownerOfflineNote(s) // 0.9.8: before the facts, where it's seen
|
|
1498
|
-
html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false }) // 0.9.9: started from the board: Stop / Resume
|
|
1499
|
+
html += headlessBarHtml(s, { esc, icon, may: state.mayAnswer !== false, busy: modeBusy.get(s.id) || null, local: state.viewerLocal !== false }) // 0.9.9: started from the board: Stop / Resume, Permissions
|
|
1499
1500
|
if (s.headless && s.headless.error) headlessErrors.set(s.id, s.headless.error)
|
|
1500
1501
|
html += '<div class="facts">' +
|
|
1501
1502
|
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : ''), '', { copy: (s.repo && s.repo.root) || null, what: 'repo folder' }) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap', { copy: s.worktree.path || null, what: 'worktree folder' }) : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—', '', { copy: s.cwd || null, clip: 34 }) + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
1502
1503
|
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id, '', { copy: s.id, what: 'session id' }) +
|
|
1503
1504
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown', '', { copy: s.live === 'open' && s.liveVia === 'pid' && s.pid != null ? String(s.pid) : null, what: 'process id' }) +
|
|
1504
1505
|
fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || ''), '', { copy: s.shortId ? 'cp:' + s.shortId : null, what: 'terminal tab id' }) + '</div>'
|
|
1505
|
-
if (isLive(s)) html += (s.needsYou ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV)
|
|
1506
|
+
if (isLive(s)) html += (s.needsYou && !askDocked(s) ? sessionNeedsStrip(s) : '') + liveBoxHtml(s, liveV) // 0.9.9: a permission ask or question docks in Live, in the composer's place
|
|
1506
1507
|
html += parkRow(s)
|
|
1507
1508
|
html += spacePicker(s, spv, 'session', s.id)
|
|
1508
1509
|
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent, open: prsUi.open, details: prsUi.details }) + '</section>'
|
|
@@ -1512,7 +1513,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
1512
1513
|
// 0.9.8: the last tools it ran and its folder's changes, folded; loads when you open it
|
|
1513
1514
|
html += '<section class="wid" id="what-it-did">' + whatItDidSection(s, widOf(s.id)) + '</section>'
|
|
1514
1515
|
// 0.9.8: a live-linked session keeps its permission card and the prompt box together, near the top
|
|
1515
|
-
if (s.needsYou && !isLive(s)) html += sessionNeedsStrip(s)
|
|
1516
|
+
if (s.needsYou && !isLive(s) && !askDocked(s)) html += sessionNeedsStrip(s)
|
|
1516
1517
|
if (!isLive(s)) html += liveBoxHtml(s, liveV)
|
|
1517
1518
|
if (s.statusChanges && s.statusChanges.length) {
|
|
1518
1519
|
html += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" title="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
|
|
@@ -1547,6 +1548,16 @@ function sessionNeedsStrip(s) { return '<section class="needs-strip one" aria-la
|
|
|
1547
1548
|
// 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
|
|
1548
1549
|
// bottom); Claude's answer is the session's own (markdown, like the terminal).
|
|
1549
1550
|
const liveKeeper = createLiveKeeper()
|
|
1551
|
+
// A long command folds to three lines with "Show all"; where it fits anyway (a wide window), no button.
|
|
1552
|
+
function fitAskDetail(root) {
|
|
1553
|
+
for (const c of root.querySelectorAll('.ask-dock .ask-detail.long:not(.open)')) {
|
|
1554
|
+
if (c.scrollHeight > c.clientHeight + 2) continue
|
|
1555
|
+
c.classList.remove('long')
|
|
1556
|
+
const b = c.parentElement && c.parentElement.querySelector('.ask-more'); if (b) b.hidden = true
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
// 0.9.9 the docked ask: which ones show their whole command, and which you chose to reply to instead (this page only)
|
|
1560
|
+
const askUi = { open: new Set(), reply: new Set() }
|
|
1550
1561
|
const liveSeenKey = (id) => 'lo-live-seen:' + id
|
|
1551
1562
|
function liveSeen(id) { try { return localStorage.getItem(liveSeenKey(id)) } catch { return null } }
|
|
1552
1563
|
function markLiveSeen(id, at) { try { localStorage.setItem(liveSeenKey(id), at || new Date().toISOString()) } catch {} }
|
|
@@ -1593,18 +1604,22 @@ function liveBoxHtml(s, v) {
|
|
|
1593
1604
|
// a session Leftoff started that isn't running now: what was said, read-only (Resume here sends again)
|
|
1594
1605
|
if (kind === 'readonly') {
|
|
1595
1606
|
if (!msgs.length) return ''
|
|
1596
|
-
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2
|
|
1607
|
+
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + modePillHtml(s, { esc }) + '<span class="faint">Not running. Resume it to send more.</span></div>' +
|
|
1597
1608
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-label="Messages with this session">' +
|
|
1598
|
-
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
1609
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id), inserts: askRows(s), earlier: (v && v.earlier) || 0, may: false }) + '</ol>' +
|
|
1599
1610
|
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div></section>'
|
|
1600
1611
|
}
|
|
1601
|
-
|
|
1612
|
+
// 0.9.9: a waiting permission ask (or question) takes the composer's place; "Reply instead" brings the composer back for that ask
|
|
1613
|
+
const rows = askRows(s)
|
|
1614
|
+
const bottom = askBottom(s, askUi.reply)
|
|
1615
|
+
const dock = bottom === 'dock', docked = bottom !== 'composer'
|
|
1616
|
+
return '<section class="live-box" id="live" aria-label="Live link" data-id="' + esc(s.id) + '"><div class="live-head"><h2>Live</h2>' + modePillHtml(s, { esc }) + '<span class="faint">Goes straight into this session' + (s.isLocal === false ? ' on ' + esc(s.machine) : '') + '. Claude reads it as its next prompt.</span></div>' +
|
|
1602
1617
|
'<div class="live-thread" data-sid="' + esc(s.id) + '"><ol class="live-log" aria-live="polite" aria-label="Messages with this session">' +
|
|
1603
|
-
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id) }) + '</ol>' +
|
|
1604
|
-
'<p class="faint live-empty"' + (msgs.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
|
|
1618
|
+
earlierHtml((v && v.earlier) || 0, { esc }) + threadHtml(msgs, { esc, ago, md: renderDoc, now, attHtml: (atts) => attBubbleHtml(atts, s.id), inserts: rows, earlier: (v && v.earlier) || 0, may }) + '</ol>' +
|
|
1619
|
+
'<p class="faint live-empty"' + (msgs.length || rows.length ? ' hidden' : '') + '>Nothing sent yet. Claude answers here' + (s.headless ? '' : ' and in the terminal') + '.</p>' +
|
|
1605
1620
|
'<button type="button" class="live-jump" data-action="live-jump" hidden>Jump to latest ↓<span class="live-jump-n"></span></button></div>' +
|
|
1606
1621
|
(v && v.error ? '<p class="live-err">' + esc(v.error) + '</p>' : '') +
|
|
1607
|
-
composerHtml(s, draft, may) +
|
|
1622
|
+
(dock ? '<div class="ask-dock-wrap">' + askDockHtml({ ...s.needsYou, session: s }, needOpts({ askOpen: askUi.open })) + '</div>' : (docked ? askSlimHtml(s.needsYou) : '') + composerHtml(s, draft, may)) +
|
|
1608
1623
|
(may ? '' : '<p class="faint">Sending from another device is off. On a Mac, turn on Settings → Privacy & security → Trust my paired devices.</p>') + '</section>'
|
|
1609
1624
|
}
|
|
1610
1625
|
// The Live composer: one rounded box, a textarea that starts at one line and
|
|
@@ -2102,6 +2117,38 @@ function onWake(ev) {
|
|
|
2102
2117
|
}
|
|
2103
2118
|
for (const t of ['pageshow', 'focus', 'blur', 'online']) window.addEventListener(t, onWake)
|
|
2104
2119
|
document.addEventListener('visibilitychange', onWake)
|
|
2120
|
+
// 0.9.9 Cancel / Edit on a Queued message: the daemon takes it back from
|
|
2121
|
+
// Claude Code's queue (only while it still waits; too late says so plainly).
|
|
2122
|
+
// Edit then puts its words (and files) back in the box, after anything typed.
|
|
2123
|
+
async function queuedClick(btn, edit) {
|
|
2124
|
+
const box = btn.closest('.live-box')
|
|
2125
|
+
const sid = box && box.dataset.id
|
|
2126
|
+
const msg = btn.dataset.msg
|
|
2127
|
+
if (!sid || !msg || btn.disabled) return
|
|
2128
|
+
const li = btn.closest('.lv')
|
|
2129
|
+
if (li) li.querySelectorAll('.lv-q-btn').forEach((b) => { b.disabled = true })
|
|
2130
|
+
btn.setAttribute('aria-busy', 'true')
|
|
2131
|
+
try {
|
|
2132
|
+
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live/' + encodeURIComponent(msg) + '/cancel', {})
|
|
2133
|
+
const s = state.sessions.find((x) => x.id === sid)
|
|
2134
|
+
if (edit) {
|
|
2135
|
+
const typed = ((state.drafts && state.drafts['live:' + sid]) || '').trim()
|
|
2136
|
+
state.drafts['live:' + sid] = typed ? typed + '\n\n' + (r.text || '') : (r.text || '')
|
|
2137
|
+
const atts = (r.attachments || []).filter((a) => a && a.id)
|
|
2138
|
+
if (atts.length && !(s && s.isLocal === false)) attachUi.by.set(sid, [...attachList(sid), ...atts.map((a) => ({ lid: 'a' + (++attachUi.seq), sid, file: null, name: a.name || 'file', size: a.size || 0, type: a.type || '', kind: a.kind || 'file', status: 'done', sent: a.size || 0, total: a.size || 0, id: a.id, preview: null, thumb: null, error: null }))])
|
|
2139
|
+
else if (atts.length) toast('Its files didn’t come back: attach them again.', 4200)
|
|
2140
|
+
}
|
|
2141
|
+
toast(edit ? 'Taken back · edit it and send again' : 'Cancelled · Claude never saw it', 3000)
|
|
2142
|
+
await refreshLive('cancel')
|
|
2143
|
+
await render()
|
|
2144
|
+
if (edit) { const ta = document.querySelector('.live-box .composer-input'); if (ta && !ta.disabled) { ta.value = state.drafts['live:' + sid] || ''; fitComposer(ta); ta.focus({ preventScroll: true }) } }
|
|
2145
|
+
} catch (e) {
|
|
2146
|
+
toast(e.message, 4200)
|
|
2147
|
+
await refreshLive('cancel')
|
|
2148
|
+
await render()
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2105
2152
|
async function liveSendClick(btn) {
|
|
2106
2153
|
const box = btn.closest('.live-box')
|
|
2107
2154
|
const ta = box && box.querySelector('.live-text')
|
|
@@ -2110,6 +2157,7 @@ async function liveSendClick(btn) {
|
|
|
2110
2157
|
if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
|
|
2111
2158
|
if (btn.disabled || btn.classList.contains('sending')) return
|
|
2112
2159
|
const hadFocus = !!(ta && document.activeElement === ta)
|
|
2160
|
+
let sent = false // 0.9.9: went through: a phone's keyboard closes
|
|
2113
2161
|
btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
|
|
2114
2162
|
try {
|
|
2115
2163
|
// 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
|
|
@@ -2123,6 +2171,7 @@ async function liveSendClick(btn) {
|
|
|
2123
2171
|
if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
|
|
2124
2172
|
if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
|
|
2125
2173
|
if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
|
|
2174
|
+
else sent = true
|
|
2126
2175
|
const ol = box && box.querySelector('.live-log')
|
|
2127
2176
|
if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
|
|
2128
2177
|
setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
|
|
@@ -2131,7 +2180,11 @@ async function liveSendClick(btn) {
|
|
|
2131
2180
|
const nb = document.querySelector('.live-box [data-action="live-send"]')
|
|
2132
2181
|
if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
|
|
2133
2182
|
const nt = document.querySelector('.live-box .composer-input')
|
|
2134
|
-
if (nt) {
|
|
2183
|
+
if (nt) {
|
|
2184
|
+
fitComposer(nt)
|
|
2185
|
+
if (!keepFocusAfterSend({ ok: sent, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document)
|
|
2186
|
+
else if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true })
|
|
2187
|
+
}
|
|
2135
2188
|
}
|
|
2136
2189
|
}
|
|
2137
2190
|
|
|
@@ -3385,7 +3438,7 @@ async function viewSettings() {
|
|
|
3385
3438
|
const panes = {
|
|
3386
3439
|
general: '<div class="section">Needs you</div>' +
|
|
3387
3440
|
'<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a notification when a prompt or question has waited ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
|
|
3388
|
-
'<div class="hint">
|
|
3441
|
+
'<div class="hint">They come from “Leftoff” on the Mac the session runs on; a click opens the session.</div>' +
|
|
3389
3442
|
'<div class="full push-slot" data-push-slot hidden></div>' +
|
|
3390
3443
|
'<div class="section">Appearance</div>' +
|
|
3391
3444
|
'<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>' +
|
|
@@ -3436,6 +3489,13 @@ async function viewSettings() {
|
|
|
3436
3489
|
SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
|
|
3437
3490
|
'<div class="full set-save" id="setSave"><span class="faint" id="setSaveNote">Saves every section to config.json in your Leftoff folder.</span><button class="btn primary" type="submit" disabled aria-disabled="true">' + icon('check') + 'Save</button></div></form></div></div>'
|
|
3438
3491
|
showSettingsSection(r)
|
|
3492
|
+
approvedUi.info = starts; approvedUi.err = ''
|
|
3493
|
+
fillApproved($app, starts) // approved folders: paths as text
|
|
3494
|
+
const ap = document.getElementById('f-start-approve')
|
|
3495
|
+
if (ap) {
|
|
3496
|
+
ap.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); approvedAdd() } })
|
|
3497
|
+
ap.addEventListener('input', () => { if (approvedUi.err) { approvedUi.err = ''; fillApproved($app, approvedUi.info) } })
|
|
3498
|
+
}
|
|
3439
3499
|
paintSettingsDots()
|
|
3440
3500
|
syncProviderFields()
|
|
3441
3501
|
// Claim your name (0.6.1): Enter sends the link instead of saving the form; a link already out keeps polling.
|
|
@@ -4183,6 +4243,7 @@ async function render() {
|
|
|
4183
4243
|
|
|
4184
4244
|
// this Mac's own browser, or the hosted board open on it (the direct door)
|
|
4185
4245
|
function startViewerLocal(v) { return startViewerAt(v, state.viewerLocal !== false) }
|
|
4246
|
+
const modeBusy = new Map() // 0.9.9: a session whose Permissions switch is on its way: id -> mode
|
|
4186
4247
|
const startUi = { data: null, machine: null, repo: null, where: 'repo', allRepos: false, ask: null, busy: new Set(), sending: false, poll: null, browse: null, custom: null }
|
|
4187
4248
|
async function viewNewSession() {
|
|
4188
4249
|
try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
|
|
@@ -4241,6 +4302,33 @@ document.addEventListener('click', (ev) => {
|
|
|
4241
4302
|
ev.preventDefault()
|
|
4242
4303
|
pill.classList.toggle('show')
|
|
4243
4304
|
}, true)
|
|
4305
|
+
// 0.9.9 Settings → Approved folders: add (the Mac checks it: inside home, not
|
|
4306
|
+
// home itself, not the synced folder; an inline error otherwise, and what you
|
|
4307
|
+
// typed stays) and remove. This Mac only (the server checks).
|
|
4308
|
+
const approvedUi = { info: null, err: '', busy: false }
|
|
4309
|
+
async function approvedAdd() {
|
|
4310
|
+
const inp = document.getElementById('f-start-approve')
|
|
4311
|
+
if (!inp || inp.disabled || approvedUi.busy) return
|
|
4312
|
+
const want = typedPath(inp.value)
|
|
4313
|
+
const fail = (m) => { approvedUi.err = m; fillApproved($app, approvedUi.info, m); inp.focus() }
|
|
4314
|
+
if (!want) return fail('Type a folder, like ~/src.')
|
|
4315
|
+
approvedUi.busy = true
|
|
4316
|
+
try {
|
|
4317
|
+
const r = await api('POST', '/v1/starts/approved', { path: want })
|
|
4318
|
+
approvedUi.busy = false
|
|
4319
|
+
toast(r.added ? 'Approved ' + r.folder + (r.allowRemote ? '' : '. Turn on Allow starting sessions from other devices for it to skip the ask') : r.coveredBy ? 'Already inside ' + r.coveredBy + ', which is approved' : r.folder + ' is already approved', r.added && !r.allowRemote ? 6000 : 3000)
|
|
4320
|
+
await viewSettings()
|
|
4321
|
+
const again = document.getElementById('f-start-approve'); if (again) again.focus({ preventScroll: true })
|
|
4322
|
+
} catch (e) { approvedUi.busy = false; fail(e.message) }
|
|
4323
|
+
}
|
|
4324
|
+
async function approvedRemove(i) {
|
|
4325
|
+
const a = approvedUi.info && (approvedUi.info.approved || [])[i]
|
|
4326
|
+
if (!a || approvedUi.busy) return
|
|
4327
|
+
approvedUi.busy = true
|
|
4328
|
+
try { const r = await api('POST', '/v1/starts/approved/remove', { path: a.path }); toast(r.folder + ' is no longer approved') } catch (e) { toast(e.message, 5000) }
|
|
4329
|
+
approvedUi.busy = false
|
|
4330
|
+
await viewSettings()
|
|
4331
|
+
}
|
|
4244
4332
|
// 0.9.9: a setting that saves the moment you change it (no Save button) says
|
|
4245
4333
|
// so beside it: "Saved ✓" for a moment, or what went wrong, inline.
|
|
4246
4334
|
const savedTimers = new Map()
|
|
@@ -4275,8 +4363,9 @@ async function startSend(btn) {
|
|
|
4275
4363
|
if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
|
|
4276
4364
|
startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
|
|
4277
4365
|
try {
|
|
4278
|
-
const r = await api('POST', '/v1/sessions/start', { machine: pick.m.machine, cwd: pick.repo.path, prompt: text, where: pick.where }, { timeoutMs: 60000 })
|
|
4366
|
+
const r = await api('POST', '/v1/sessions/start', { machine: pick.m.machine, cwd: pick.repo.path, prompt: text, where: pick.where, mode: pick.mode }, { timeoutMs: 60000 })
|
|
4279
4367
|
if (state.drafts) delete state.drafts.start
|
|
4368
|
+
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
|
|
4280
4369
|
if (r.state === 'started' && r.sessionId) { startUi.ask = null; toast('Started on ' + (r.machine || pick.m.machine)); location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
|
|
4281
4370
|
startUi.ask = { ...r, machine: r.machine || pick.m.machine }
|
|
4282
4371
|
pollStartAsk()
|
|
@@ -5445,7 +5534,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
5445
5534
|
// 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
|
|
5446
5535
|
if (action === 'start-pick') {
|
|
5447
5536
|
const k = btn.dataset.k, v = btn.dataset.v
|
|
5448
|
-
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null } else if (k === 'repo') startUi.repo = v; else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'
|
|
5537
|
+
if (k === 'machine') { startUi.machine = v; startUi.repo = null; startUi.allRepos = false; startUi.browse = null; startUi.custom = null } else if (k === 'repo') startUi.repo = v; else if (k === 'where') startUi.where = v === 'worktree' ? 'worktree' : 'repo'; else if (k === 'mode') startUi.mode = v
|
|
5449
5538
|
return render()
|
|
5450
5539
|
}
|
|
5451
5540
|
// Other folder…: open the browser at ~, walk it, use a folder
|
|
@@ -5491,18 +5580,31 @@ document.addEventListener('click', async (ev) => {
|
|
|
5491
5580
|
}
|
|
5492
5581
|
if (action === 'start-again') { startUi.ask = null; return render() }
|
|
5493
5582
|
if (action === 'start-send') return startSend(btn)
|
|
5494
|
-
if (action === 'start-
|
|
5583
|
+
if (action === 'start-approved-add') return approvedAdd()
|
|
5584
|
+
if (action === 'start-approved-remove') return approvedRemove(Number(btn.dataset.i))
|
|
5585
|
+
if (action === 'start-ask-confirm' || action === 'start-ask-decline' || action === 'start-ask-approve') {
|
|
5495
5586
|
if (!id || startUi.busy.has(id)) return
|
|
5496
5587
|
startUi.busy.add(id); render()
|
|
5497
5588
|
try {
|
|
5498
|
-
|
|
5499
|
-
|
|
5589
|
+
// "Always allow in <folder>": approves that folder here, then starts this one
|
|
5590
|
+
const r = await api('POST', '/v1/sessions/start/' + encodeURIComponent(id) + '/' + (action === 'start-ask-decline' ? 'decline' : 'confirm'), action === 'start-ask-approve' ? { approve: true } : {}, { timeoutMs: 60000 })
|
|
5591
|
+
if (r.approved && r.state !== 'failed') toast((r.state === 'started' ? 'Started. ' : '') + 'Folders inside ' + r.approved + ' now start without asking' + (r.allowRemote ? '' : ' once Allow starting sessions from other devices is on'), 5000)
|
|
5592
|
+
else toast(r.state === 'started' ? 'Started' : r.state === 'switched' ? 'Switched to ' + modeLabel(r.mode) : r.state === 'declined' ? 'Declined' : r.state === 'failed' ? 'Didn’t start: ' + (r.error || '') : 'That request was ' + r.state, r.state === 'failed' ? 6000 : 2500)
|
|
5500
5593
|
startUi.busy.delete(id)
|
|
5501
5594
|
if (r.state === 'started' && r.sessionId) { location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
|
|
5502
5595
|
} catch (e) { toast(e.message, 5000) }
|
|
5503
5596
|
startUi.busy.delete(id)
|
|
5504
5597
|
return render()
|
|
5505
5598
|
}
|
|
5599
|
+
// 0.9.9 Permissions of a session Leftoff runs: switch it (or, from another device, ask that Mac)
|
|
5600
|
+
if (action === 'headless-mode') {
|
|
5601
|
+
if (!id || modeBusy.has(id) || btn.getAttribute('aria-checked') === 'true') return
|
|
5602
|
+
modeBusy.set(id, btn.dataset.mode); render()
|
|
5603
|
+
try { modeToast(await switchMode(id, btn.dataset.mode)) } catch (e) { toast(e.message, 6000) }
|
|
5604
|
+
modeBusy.delete(id)
|
|
5605
|
+
return render()
|
|
5606
|
+
}
|
|
5607
|
+
if (action === 'ask-auto') return autoRestClick(btn)
|
|
5506
5608
|
if (action === 'headless-stop' || action === 'headless-run') {
|
|
5507
5609
|
btn.disabled = true
|
|
5508
5610
|
try {
|
|
@@ -5694,6 +5796,22 @@ document.addEventListener('click', async (ev) => {
|
|
|
5694
5796
|
return paintDigestFilter()
|
|
5695
5797
|
}
|
|
5696
5798
|
// ---- Needs you (v0.4.4)
|
|
5799
|
+
// 0.9.9 the docked ask: open a long command in place; reply instead (the composer comes back); back to the card
|
|
5800
|
+
if (action === 'ask-expand') {
|
|
5801
|
+
const code = btn.closest('.ask-dock') && btn.closest('.ask-dock').querySelector('.ask-detail')
|
|
5802
|
+
const open = !askUi.open.has(btn.dataset.ask)
|
|
5803
|
+
if (open) askUi.open.add(btn.dataset.ask); else askUi.open.delete(btn.dataset.ask)
|
|
5804
|
+
if (code) code.classList.toggle('open', open)
|
|
5805
|
+
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.textContent = open ? 'Show less' : 'Show all'
|
|
5806
|
+
return
|
|
5807
|
+
}
|
|
5808
|
+
if (action === 'ask-reply-instead' || action === 'ask-back') {
|
|
5809
|
+
if (action === 'ask-reply-instead') askUi.reply.add(btn.dataset.ask); else askUi.reply.delete(btn.dataset.ask)
|
|
5810
|
+
await render()
|
|
5811
|
+
if (action === 'ask-reply-instead') { const ta = document.querySelector('.live-box .composer-input'); if (ta && !ta.disabled) ta.focus({ preventScroll: true }) }
|
|
5812
|
+
else { const a = document.querySelector('.ask-dock [data-action="ask-answer"][data-choice="allow"], .ask-dock button:not([disabled])'); if (a) a.focus({ preventScroll: true }) }
|
|
5813
|
+
return
|
|
5814
|
+
}
|
|
5697
5815
|
if (action === 'ask-deny-open' || action === 'ask-deny-close') {
|
|
5698
5816
|
const need = btn.closest('.need')
|
|
5699
5817
|
const form = need && need.querySelector('.deny-form')
|
|
@@ -5709,6 +5827,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
5709
5827
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
5710
5828
|
if (action === 'reply-send') return replyClick(btn)
|
|
5711
5829
|
if (action === 'live-send') return liveSendClick(btn)
|
|
5830
|
+
if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
|
|
5712
5831
|
if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
|
|
5713
5832
|
if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
|
|
5714
5833
|
if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
|
|
@@ -5843,6 +5962,28 @@ document.addEventListener('click', async (ev) => {
|
|
|
5843
5962
|
|
|
5844
5963
|
// Answer a permission prompt or a question from the board. The daemon hands it
|
|
5845
5964
|
// to the waiting hook or mod; the terminal's own prompt may already have won.
|
|
5965
|
+
// 0.9.9 Permissions mid-session (src/session-start.js switchMode): switched, or waiting for a yes at that Mac.
|
|
5966
|
+
function switchMode(id, mode) { return api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/mode', { mode }, { timeoutMs: 30000 }) }
|
|
5967
|
+
function modeToast(r) {
|
|
5968
|
+
if (r && r.state === 'pending') toast('Asked ' + (r.machine || 'that Mac') + ': someone there says yes, then it switches', 5000)
|
|
5969
|
+
else toast('Permissions: ' + modeLabel(r && r.mode) + (r && r.running === false ? ' (when it runs again)' : ''), 3000)
|
|
5970
|
+
}
|
|
5971
|
+
// "Auto-approve the rest" on a permission card: allow this call, and switch the session to Auto.
|
|
5972
|
+
async function autoRestClick(btn) {
|
|
5973
|
+
const need = btn.closest('.need')
|
|
5974
|
+
if (!need || !need.dataset.ask) return
|
|
5975
|
+
const sid = need.dataset.id
|
|
5976
|
+
need.querySelectorAll('button, input, textarea').forEach((el) => { if (!el.classList.contains('go')) el.disabled = true })
|
|
5977
|
+
btn.textContent = 'Switching…'
|
|
5978
|
+
let sw = null
|
|
5979
|
+
try { sw = await switchMode(sid, 'auto') } catch (e) { toast(e.message, 6000); render(); return }
|
|
5980
|
+
try {
|
|
5981
|
+
await api('POST', '/v1/asks/' + encodeURIComponent(need.dataset.ask) + '/answer', { action: 'allow', auto: true }) // 0.9.9: the thread's row says Auto-approved
|
|
5982
|
+
toast(sw && sw.state === 'pending' ? 'Allowed. Auto waits for a yes on ' + (sw.machine || 'that Mac') : 'Allowed; the rest of this session runs in Auto', 4000)
|
|
5983
|
+
} catch (e) { toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200) }
|
|
5984
|
+
render()
|
|
5985
|
+
}
|
|
5986
|
+
|
|
5846
5987
|
async function answerClick(btn, action) {
|
|
5847
5988
|
const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
|
|
5848
5989
|
if (!need) return
|
|
@@ -5873,6 +6014,7 @@ async function answerClick(btn, action) {
|
|
|
5873
6014
|
try {
|
|
5874
6015
|
const r = await api('POST', '/v1/asks/' + encodeURIComponent(askId) + '/answer', body)
|
|
5875
6016
|
toast(r.summary + (r.delivered ? ' · Claude Code has it' : ' · waiting for Claude Code to pick it up'), 3600)
|
|
6017
|
+
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // a typed answer sent: a phone's keyboard closes
|
|
5876
6018
|
for (const k of Object.keys(state.drafts)) if (k.includes(askId)) delete state.drafts[k]
|
|
5877
6019
|
} catch (e) {
|
|
5878
6020
|
toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
|
|
@@ -5896,6 +6038,7 @@ async function replyClick(btn) {
|
|
|
5896
6038
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
|
|
5897
6039
|
delete state.drafts['reply:' + need.dataset.id]
|
|
5898
6040
|
toast(r.queuedBehindTurn ? 'Sent · Claude reads it when the current turn ends' : 'Sent · Claude has it as its next prompt', 3600)
|
|
6041
|
+
if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // a phone's keyboard closes
|
|
5899
6042
|
} catch (e) { toast(e.message, 4200) } finally { render() }
|
|
5900
6043
|
}
|
|
5901
6044
|
|
package/ui/composer.js
CHANGED
|
@@ -34,6 +34,18 @@ export function isTouchUi(matchMedia) {
|
|
|
34
34
|
} catch { return false }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// 0.9.9 After a send that went through, a phone's keyboard closes: the text
|
|
38
|
+
// box lets go of focus. On a Mac (a fine pointer) focus stays, so you can keep
|
|
39
|
+
// typing. A send that failed keeps its text and its focus everywhere.
|
|
40
|
+
export function keepFocusAfterSend({ ok = false, touch = false } = {}) { return !ok || !touch }
|
|
41
|
+
// Let go of the focused text box (the on-screen keyboard closes); true if one had it.
|
|
42
|
+
export function letGoOfKeyboard(doc) {
|
|
43
|
+
const a = doc && doc.activeElement
|
|
44
|
+
if (!a || a === doc.body || !/^(TEXTAREA|INPUT)$/.test(String(a.tagName || '').toUpperCase()) || typeof a.blur !== 'function') return false
|
|
45
|
+
a.blur()
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
// The height the box should be for its content: scrollHeight is the
|
|
38
50
|
// textarea's at height:auto (padding included), lineHeight and padY in px.
|
|
39
51
|
// -> { height, scroll } where scroll says the content is taller than the cap.
|
package/ui/live-status.js
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// folded into that turn (folded: the id of the message that owns the turn):
|
|
17
17
|
// it has no Working bubble of its own, and when the turn ends it is
|
|
18
18
|
// "Answered with the reply above".
|
|
19
|
+
// 0.9.9: until Claude takes it in (at its next step, or when the turn ends)
|
|
20
|
+
// such a message is Queued (queued: true; cancellable: Leftoff can still take
|
|
21
|
+
// it back). Anything Claude does with it ends that.
|
|
19
22
|
|
|
20
23
|
export const LIVE_QUIET_MS = 3 * 60 * 1000
|
|
21
24
|
export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
|
|
@@ -27,11 +30,15 @@ export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool:
|
|
|
27
30
|
|
|
28
31
|
// One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
|
|
29
32
|
// delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended | folded | answered | lost
|
|
33
|
+
const KEEPS_QUEUED = new Set(['delivered', 'queued', 'ask_done', 'noop'])
|
|
30
34
|
export function stepStatus(st, ev = {}) {
|
|
31
35
|
const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
|
|
32
36
|
const at = ev.at || s.last
|
|
33
|
-
if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) return s
|
|
37
|
+
if (FINAL.has(s.phase) && !(s.phase === 'replied' && ev.type === 'tool_done')) { if (s.queued) { delete s.queued; delete s.cancellable } return s }
|
|
38
|
+
if (s.queued && !KEEPS_QUEUED.has(ev.type)) { delete s.queued; delete s.cancellable }
|
|
34
39
|
switch (ev.type) {
|
|
40
|
+
case 'queued': if (s.phase === 'sent' || s.phase === 'received') { s.queued = true; s.cancellable = !!ev.cancellable } else return s; break
|
|
41
|
+
case 'unqueued': break
|
|
35
42
|
case 'delivered': if (s.phase === 'sent') s.phase = 'received'; break
|
|
36
43
|
case 'failed': if (s.phase === 'sent') { s.phase = 'failed'; s.error = ev.error || null; s.endedAt = at } break
|
|
37
44
|
case 'started': case 'text': if (s.phase !== 'replied') s.phase = 'working'; if (ev.type === 'text') s.waiting = null; break
|
|
@@ -57,12 +64,16 @@ export function stepStatus(st, ev = {}) {
|
|
|
57
64
|
return s
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
// Waiting behind Claude's turn, not taken in yet.
|
|
68
|
+
export function isQueued(st) { return !!(st && st.queued && !st.folded && (st.phase === 'sent' || st.phase === 'received')) }
|
|
69
|
+
|
|
60
70
|
// The line under the message: { label, steps, spin, tone, elapsedMs }
|
|
61
71
|
export function statusView(st, now = Date.now()) {
|
|
62
72
|
if (!st) return null
|
|
63
73
|
const quiet = !st.folded && (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
|
|
64
74
|
const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
|
|
65
75
|
let label
|
|
76
|
+
if (isQueued(st)) return { label: 'Queued', steps: [{ name: 'Sent', state: 'done' }, { name: 'Queued', state: 'now' }], spin: false, tone: 'queued', quiet: false, elapsedMs, final: false, queued: true }
|
|
66
77
|
if (st.phase === 'sent') label = 'Sent, waiting for the session'
|
|
67
78
|
else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
|
|
68
79
|
else if (st.phase === 'working' && st.folded) label = 'Taken into the turn above'
|