@leftoff/cli 0.9.9-beta.7 → 0.9.9-beta.9

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/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 **always** waits for a yes on that Mac's board, even with *Allow starting sessions from other devices* on, and the ask and the notification say "first time in <folder>; this runs its hooks". After one run it's in the list like the rest. Home itself is refused; a folder that isn't a git repo can't get a new worktree.
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.7",
3
+ "version": "0.9.9-beta.9",
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"
@@ -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.7' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.9-beta.9' // 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.7",
3
+ "version": "0.9.9-beta.9",
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,28 @@ 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
+ Compact: no sender line, a long one folds to two lines (a tap opens it), and one small row
2119
+ "◷ Queued · Send now · Edit · Cancel" of text buttons, each with a 44px hit area (the
2120
+ negative margins keep the row 18px tall and the words evenly spaced; neighbours' hit
2121
+ areas don't overlap). Several stack tight. */
2122
+ .lv-you.lv-queued { background: transparent; border-style: dashed; border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); padding: 6px 10px; margin-bottom: 6px; }
2123
+ .lv-you.lv-queued .lv-text, .lv-you.lv-queued .att-bubble { opacity: .62; }
2124
+ .lv-you.lv-queued .lv-text { margin-top: 0; }
2125
+ .lv-queued .lv-q-long { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; cursor: pointer; border-radius: 4px; }
2126
+ .lv-queued .lv-q-long.open { display: block; -webkit-line-clamp: unset; line-clamp: none; overflow: visible; }
2127
+ .lv-queued .lv-q-long:focus:not(:focus-visible) { outline: none; }
2128
+ .lv-queued .lv-q-long:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2129
+ .lv-q { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; column-gap: 5px; row-gap: 0; margin-top: 4px; min-height: 18px; font-size: 12.5px; line-height: 18px; color: var(--muted); }
2130
+ .lv-q-tag { display: inline-flex; align-items: center; gap: 4px; font-weight: 600; letter-spacing: .01em; color: var(--muted); }
2131
+ .lv-q-tag::before { content: "◷"; font-size: 13px; font-weight: 400; }
2132
+ .lv-q-dot { opacity: .7; }
2133
+ .lv-q-hint { color: var(--muted); }
2134
+ .lv-q-btn { background: none; border: 0; box-shadow: none; margin: -13px -4px; padding: 0 4px; min-height: 44px; min-width: 44px; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; line-height: 18px; font-weight: 600; color: var(--accent); cursor: pointer; border-radius: 8px; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
2135
+ .lv-q-btn:hover { text-decoration: underline; text-underline-offset: 3px; }
2136
+ .lv-q-btn:focus:not(:focus-visible) { outline: none; }
2137
+ .lv-q-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -8px; }
2138
+ .lv-q-btn:disabled { opacity: .45; cursor: default; text-decoration: none; }
2117
2139
  .lv-working .lvs-line { margin-top: 0; }
2118
2140
  .lv-pending .lv-working { margin-top: 4px; }
2119
2141
  .lvs-sep { color: var(--faint); }
@@ -2888,6 +2910,29 @@ del.gd-x { background: var(--gd-del-word); }
2888
2910
  .headless-bar.running { border-color: var(--green); background: var(--tint-green); }
2889
2911
  .headless-bar.paused { border-color: var(--yellow); background: var(--tint-yellow); }
2890
2912
  .headless-bar .btn { min-height: 44px; }
2913
+ /* 0.9.9 Permissions: the New session choice, the session page's switch and its pill beside Live */
2914
+ .start-mode { grid-template-columns: repeat(3, minmax(0, 1fr)); }
2915
+ .start-mode .start-opt-sub { white-space: normal; }
2916
+ .headless-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: -4px 0 10px; }
2917
+ .headless-mode .hm-l { font-size: 12.5px; font-weight: 600; color: var(--muted); }
2918
+ .hm-seg { display: inline-flex; border: 1px solid var(--line); border-radius: 10px; overflow: visible; background: var(--card); }
2919
+ .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; }
2920
+ .hm-opt:first-child { border-left: 0; border-radius: 10px 0 0 10px; }
2921
+ .hm-opt:last-child { border-radius: 0 10px 10px 0; }
2922
+ .hm-opt[aria-checked="true"] { background: var(--tint-accent); color: var(--text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent); }
2923
+ .hm-opt:disabled { opacity: .55; cursor: default; }
2924
+ .hm-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; z-index: 1; }
2925
+ .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; }
2926
+ @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; } }
2927
+ .hm-opt[data-tip]:focus-visible::after, .mode-pill[data-tip]:focus-visible::after { display: block; }
2928
+ .hm-hint { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--muted); }
2929
+ .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; }
2930
+ .mode-pill.m-auto { border-color: var(--accent); background: var(--tint-accent); color: var(--text); }
2931
+ .mode-pill.m-acceptEdits { border-color: var(--yellow); background: var(--tint-yellow); color: var(--text); }
2932
+ @media (max-width: 640px) {
2933
+ .hm-seg { display: flex; width: 100%; }
2934
+ .hm-opt { flex: 1 1 0; padding: 0 6px; }
2935
+ }
2891
2936
  .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
2937
  @media (max-width: 640px) {
2893
2938
  .start-opts { grid-template-columns: 1fr; }
@@ -2984,3 +3029,76 @@ del.gd-x { background: var(--gd-del-word); }
2984
3029
  .form label.check.locked input { cursor: not-allowed; }
2985
3030
  .form label.check .lock { display: inline-flex; color: var(--muted); }
2986
3031
  .form label.check .lock .ic { width: 14px; height: 14px; }
3032
+ /* 0.9.9 Settings → Approved folders: a list with Remove, and an add field (16px) */
3033
+ .form label[for="f-start-approve"] { align-self: start; padding-top: 12px; }
3034
+ .approved { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
3035
+ .approved-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
3036
+ .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); }
3037
+ .approved-path { flex: 1; min-width: 0; overflow-wrap: anywhere; color: var(--text); font-size: 13px; }
3038
+ .approved .mono, .start-ask-banner .mono { font-family: var(--mono); }
3039
+ .approved-x { flex: none; min-height: 44px; min-width: 44px; justify-content: center; gap: 4px; }
3040
+ .approved-x .ic { width: 14px; height: 14px; }
3041
+ .approved-none { color: var(--faint); font-size: 13px; }
3042
+ .approved-add { display: flex; gap: 8px; align-items: center; }
3043
+ .form .approved-add input { flex: 1; min-width: 0; height: 44px; font-size: 16px; }
3044
+ .approved-add .btn { min-height: 44px; flex: none; }
3045
+ .approved.locked .approved-add input, .approved.locked .approved-add .btn { cursor: not-allowed; }
3046
+ .approved .lock { display: inline-flex; color: var(--muted); }
3047
+ .approved .lock .ic { width: 14px; height: 14px; }
3048
+ .approved-err { color: var(--red); font-size: 12.5px; overflow-wrap: anywhere; }
3049
+ .start-ask-banner .btn.approve { max-width: 100%; }
3050
+ .start-ask-banner .btn.approve .mono { overflow-wrap: anywhere; white-space: normal; text-align: left; }
3051
+ @media (max-width: 640px) {
3052
+ .start-ask-banner > .btn.approve { order: 1; flex: 1 1 100%; }
3053
+ }
3054
+
3055
+ /* 0.9.9 the ask in Live: a waiting permission prompt (or question) takes the
3056
+ composer's place at the bottom of Live, and stays in reach while you read the
3057
+ thread (sticky inside the Live box, above the phone's tab bar and the home
3058
+ indicator); the thread keeps a short row for each ask where it happened. */
3059
+ .ask-dock-wrap { position: sticky; bottom: max(12px, calc(env(safe-area-inset-bottom) + 8px)); z-index: 6; margin-top: 10px; }
3060
+ .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); }
3061
+ [data-theme="dark"] .need.ask-dock { background: var(--card2); box-shadow: 0 8px 28px rgba(0, 0, 0, .45); }
3062
+ .ask-dock .need-head { font-size: 13px; }
3063
+ .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; }
3064
+ .ask-what { display: grid; gap: 6px; min-width: 0; }
3065
+ .ask-verb { font-weight: 600; font-size: 15px; color: var(--text); }
3066
+ .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; }
3067
+ .ask-detail.long { max-height: calc(3 * 1.5em + 12px); overflow: hidden; }
3068
+ .ask-detail.long.open { max-height: 40vh; overflow: auto; }
3069
+ .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; }
3070
+ .ask-reply-instead { color: var(--muted); }
3071
+ .ask-more:hover, .ask-reply-instead:hover { text-decoration: underline; }
3072
+ .ask-more:focus-visible, .ask-reply-instead:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
3073
+ .ask-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: stretch; }
3074
+ .ask-actions .btn, .ask-dock .deny-form .btn { min-height: 44px; font-size: 14px; padding: 8px 16px; justify-content: center; white-space: nowrap; }
3075
+ .ask-actions .btn.ask-allow { min-width: 120px; font-size: 15px; }
3076
+ .ask-dock .deny-form .deny-note { flex: 1 1 220px; min-width: 0; min-height: 44px; box-sizing: border-box; font-size: 16px; }
3077
+ @media (max-width: 699.98px) {
3078
+ .ask-dock-wrap { bottom: calc(var(--bar-h) + 8px + env(safe-area-inset-bottom)); }
3079
+ body.nav-hidden .ask-dock-wrap { bottom: max(10px, calc(env(safe-area-inset-bottom) + 6px)); }
3080
+ .need.ask-dock { padding: 12px; }
3081
+ .ask-actions .btn { flex: 1 1 calc(50% - 4px); font-size: 15px; min-height: 48px; }
3082
+ .ask-actions .btn.ask-allow, .ask-actions .btn.ask-deny { flex-basis: 100%; }
3083
+ .ask-actions .always-wrap { flex: 1 1 calc(50% - 4px); }
3084
+ .ask-actions .always-wrap .btn { flex: 1 1 auto; width: 100%; }
3085
+ .ask-dock .deny-form .btn { flex: 1 1 calc(50% - 4px); min-height: 48px; }
3086
+ .ask-dock .deny-form .deny-note { flex-basis: 100%; }
3087
+ }
3088
+ /* after "Reply instead": one line above the composer, Answer brings the card back */
3089
+ .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; }
3090
+ .ask-slim .need-dot { background: var(--red); }
3091
+ .ask-slim-t { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text2); }
3092
+ .ask-slim .btn { flex: none; min-height: 44px; min-width: 44px; justify-content: center; }
3093
+ /* the row in the thread: what it asked, and how it was answered */
3094
+ .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; }
3095
+ .lv-ask .lva-what { flex: 1 1 200px; min-width: 0; overflow-wrap: anywhere; }
3096
+ .lv-ask .lva-verb { font-weight: 600; }
3097
+ .lv-ask code { font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
3098
+ .lv-ask .lva-state { flex: none; max-width: 100%; font-weight: 600; font-size: 12px; overflow-wrap: anywhere; }
3099
+ .lv-ask.t-ok { border-left-color: var(--green); } .lv-ask.t-ok .lva-state { color: var(--green); }
3100
+ .lv-ask.t-auto { border-left-color: var(--accent); } .lv-ask.t-auto .lva-state { color: var(--accent); }
3101
+ .lv-ask.t-no { border-left-color: var(--red); } .lv-ask.t-no .lva-state { color: var(--red); }
3102
+ .lv-ask.t-wait { border-left-color: var(--red); background: var(--tint-red); } .lv-ask.t-wait .lva-state { color: var(--red); }
3103
+ .ask-note { color: var(--muted); font-size: 13px; margin-top: -4px; }
3104
+ .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><span class="faint">Not running. Resume it to send more.</span></div>' +
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
- 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>' +
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 &amp; 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,58 @@ 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
+ // 0.9.9 Send now on a Queued message: Claude stops its current step and this
2124
+ // one runs next (ahead of any queued before it). The daemon says what Claude
2125
+ // Code did; too late (Claude already has it) is a plain message.
2126
+ async function sendNowClick(btn) {
2127
+ const box = btn.closest('.live-box')
2128
+ const sid = box && box.dataset.id
2129
+ const msg = btn.dataset.msg
2130
+ if (!sid || !msg || btn.disabled) return
2131
+ const li = btn.closest('.lv')
2132
+ if (li) li.querySelectorAll('.lv-q-btn').forEach((b) => { b.disabled = true })
2133
+ btn.setAttribute('aria-busy', 'true')
2134
+ try {
2135
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live/' + encodeURIComponent(msg) + '/sendnow', {})
2136
+ toast(r.result === 'stopped' ? 'Sent now · Claude stopped its step for it' : 'Sending now', 3000)
2137
+ } catch (e) {
2138
+ toast(e.message, 4200)
2139
+ }
2140
+ await refreshLive('sendnow')
2141
+ await render()
2142
+ }
2143
+ async function queuedClick(btn, edit) {
2144
+ const box = btn.closest('.live-box')
2145
+ const sid = box && box.dataset.id
2146
+ const msg = btn.dataset.msg
2147
+ if (!sid || !msg || btn.disabled) return
2148
+ const li = btn.closest('.lv')
2149
+ if (li) li.querySelectorAll('.lv-q-btn').forEach((b) => { b.disabled = true })
2150
+ btn.setAttribute('aria-busy', 'true')
2151
+ try {
2152
+ const r = await api('POST', '/v1/sessions/' + encodeURIComponent(sid) + '/live/' + encodeURIComponent(msg) + '/cancel', {})
2153
+ const s = state.sessions.find((x) => x.id === sid)
2154
+ if (edit) {
2155
+ const typed = ((state.drafts && state.drafts['live:' + sid]) || '').trim()
2156
+ state.drafts['live:' + sid] = typed ? typed + '\n\n' + (r.text || '') : (r.text || '')
2157
+ const atts = (r.attachments || []).filter((a) => a && a.id)
2158
+ 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 }))])
2159
+ else if (atts.length) toast('Its files didn’t come back: attach them again.', 4200)
2160
+ }
2161
+ toast(edit ? 'Taken back · edit it and send again' : 'Cancelled · Claude never saw it', 3000)
2162
+ await refreshLive('cancel')
2163
+ await render()
2164
+ 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 }) } }
2165
+ } catch (e) {
2166
+ toast(e.message, 4200)
2167
+ await refreshLive('cancel')
2168
+ await render()
2169
+ }
2170
+ }
2171
+
2105
2172
  async function liveSendClick(btn) {
2106
2173
  const box = btn.closest('.live-box')
2107
2174
  const ta = box && box.querySelector('.live-text')
@@ -2110,6 +2177,7 @@ async function liveSendClick(btn) {
2110
2177
  if (!text && !attachList(sid).length) { if (ta) ta.focus(); return }
2111
2178
  if (btn.disabled || btn.classList.contains('sending')) return
2112
2179
  const hadFocus = !!(ta && document.activeElement === ta)
2180
+ let sent = false // 0.9.9: went through: a phone's keyboard closes
2113
2181
  btn.classList.add('sending'); btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-label', 'Sending…')
2114
2182
  try {
2115
2183
  // 0.9.8: attachments still uploading go first; one that failed waits for you (retry or remove)
@@ -2123,6 +2191,7 @@ async function liveSendClick(btn) {
2123
2191
  if (!ta || ta.value.trim() === text) { if (state.drafts) delete state.drafts['live:' + sid]; if (ta) { ta.value = ''; fitComposer(ta) } }
2124
2192
  if (ids.length) { attachUi.by.set(sid, attachList(sid).filter((a) => !ids.includes(a.id))); paintChips(sid) }
2125
2193
  if (r.state === 'failed') toast('Didn’t arrive: ' + (r.error || 'the session isn’t listening'), 4200)
2194
+ else sent = true
2126
2195
  const ol = box && box.querySelector('.live-log')
2127
2196
  if (ol) ol.scrollTop = ol.scrollHeight // your own message: always follow it
2128
2197
  setTimeout(() => { if (state.route && state.route.name === 's') soon() }, 1500)
@@ -2131,7 +2200,11 @@ async function liveSendClick(btn) {
2131
2200
  const nb = document.querySelector('.live-box [data-action="live-send"]')
2132
2201
  if (nb) { nb.classList.remove('sending'); nb.removeAttribute('aria-busy'); nb.setAttribute('aria-label', 'Send') }
2133
2202
  const nt = document.querySelector('.live-box .composer-input')
2134
- if (nt) { fitComposer(nt); if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true }) }
2203
+ if (nt) {
2204
+ fitComposer(nt)
2205
+ if (!keepFocusAfterSend({ ok: sent, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document)
2206
+ else if (hadFocus && !nt.disabled && document.activeElement !== nt) nt.focus({ preventScroll: true })
2207
+ }
2135
2208
  }
2136
2209
  }
2137
2210
 
@@ -3385,7 +3458,7 @@ async function viewSettings() {
3385
3458
  const panes = {
3386
3459
  general: '<div class="section">Needs you</div>' +
3387
3460
  '<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">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
3461
+ '<div class="hint">They come from “Leftoff” on the Mac the session runs on; a click opens the session.</div>' +
3389
3462
  '<div class="full push-slot" data-push-slot hidden></div>' +
3390
3463
  '<div class="section">Appearance</div>' +
3391
3464
  '<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 +3509,13 @@ async function viewSettings() {
3436
3509
  SETTINGS.map((x) => '<section class="set-pane" data-sec="' + x.key + '" aria-label="' + esc(x.label) + '" hidden>' + panes[x.key] + '</section>').join('') +
3437
3510
  '<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
3511
  showSettingsSection(r)
3512
+ approvedUi.info = starts; approvedUi.err = ''
3513
+ fillApproved($app, starts) // approved folders: paths as text
3514
+ const ap = document.getElementById('f-start-approve')
3515
+ if (ap) {
3516
+ ap.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); approvedAdd() } })
3517
+ ap.addEventListener('input', () => { if (approvedUi.err) { approvedUi.err = ''; fillApproved($app, approvedUi.info) } })
3518
+ }
3439
3519
  paintSettingsDots()
3440
3520
  syncProviderFields()
3441
3521
  // Claim your name (0.6.1): Enter sends the link instead of saving the form; a link already out keeps polling.
@@ -4183,6 +4263,7 @@ async function render() {
4183
4263
 
4184
4264
  // this Mac's own browser, or the hosted board open on it (the direct door)
4185
4265
  function startViewerLocal(v) { return startViewerAt(v, state.viewerLocal !== false) }
4266
+ const modeBusy = new Map() // 0.9.9: a session whose Permissions switch is on its way: id -> mode
4186
4267
  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
4268
  async function viewNewSession() {
4188
4269
  try { startUi.data = await api('GET', '/v1/start') } catch (e) { if (!startUi.data) throw e }
@@ -4241,6 +4322,33 @@ document.addEventListener('click', (ev) => {
4241
4322
  ev.preventDefault()
4242
4323
  pill.classList.toggle('show')
4243
4324
  }, true)
4325
+ // 0.9.9 Settings → Approved folders: add (the Mac checks it: inside home, not
4326
+ // home itself, not the synced folder; an inline error otherwise, and what you
4327
+ // typed stays) and remove. This Mac only (the server checks).
4328
+ const approvedUi = { info: null, err: '', busy: false }
4329
+ async function approvedAdd() {
4330
+ const inp = document.getElementById('f-start-approve')
4331
+ if (!inp || inp.disabled || approvedUi.busy) return
4332
+ const want = typedPath(inp.value)
4333
+ const fail = (m) => { approvedUi.err = m; fillApproved($app, approvedUi.info, m); inp.focus() }
4334
+ if (!want) return fail('Type a folder, like ~/src.')
4335
+ approvedUi.busy = true
4336
+ try {
4337
+ const r = await api('POST', '/v1/starts/approved', { path: want })
4338
+ approvedUi.busy = false
4339
+ 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)
4340
+ await viewSettings()
4341
+ const again = document.getElementById('f-start-approve'); if (again) again.focus({ preventScroll: true })
4342
+ } catch (e) { approvedUi.busy = false; fail(e.message) }
4343
+ }
4344
+ async function approvedRemove(i) {
4345
+ const a = approvedUi.info && (approvedUi.info.approved || [])[i]
4346
+ if (!a || approvedUi.busy) return
4347
+ approvedUi.busy = true
4348
+ 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) }
4349
+ approvedUi.busy = false
4350
+ await viewSettings()
4351
+ }
4244
4352
  // 0.9.9: a setting that saves the moment you change it (no Save button) says
4245
4353
  // so beside it: "Saved ✓" for a moment, or what went wrong, inline.
4246
4354
  const savedTimers = new Map()
@@ -4275,8 +4383,9 @@ async function startSend(btn) {
4275
4383
  if (!pick.m || !pick.repo) return toast('Pick a Mac and a repo first')
4276
4384
  startUi.sending = true; btn.disabled = true; btn.classList.add('sending'); btn.setAttribute('aria-busy', 'true')
4277
4385
  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 })
4386
+ 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
4387
  if (state.drafts) delete state.drafts.start
4388
+ if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // 0.9.9: a phone's keyboard closes
4280
4389
  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
4390
  startUi.ask = { ...r, machine: r.machine || pick.m.machine }
4282
4391
  pollStartAsk()
@@ -5247,7 +5356,7 @@ window.addEventListener('hashchange', () => closeActionMenu())
5247
5356
  const tipsUi = wireTips(document) // 0.9.8: the ⓘ tooltips (Always allow here)
5248
5357
  window.addEventListener('hashchange', () => tipsUi.hide())
5249
5358
  // 0.8.9: a session page's title is a button too (Enter or Space renames it)
5250
- document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"]')) { e.preventDefault(); t.click() } })
5359
+ document.addEventListener('keydown', (e) => { const t = e.target; if ((e.key === 'Enter' || e.key === ' ') && t && t.matches && t.matches('.s-title-text[role="button"], .lv-q-long[role="button"]')) { e.preventDefault(); t.click() } })
5251
5360
  // 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
5252
5361
  document.addEventListener('keydown', (ev) => {
5253
5362
  const b = ev.target && ev.target.closest && ev.target.closest('[data-action="card-more"]')
@@ -5445,7 +5554,7 @@ document.addEventListener('click', async (ev) => {
5445
5554
  // 0.9.9 New session: the page's choices, the send, a yes or no for a start waiting here, Stop and Resume
5446
5555
  if (action === 'start-pick') {
5447
5556
  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'
5557
+ 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
5558
  return render()
5450
5559
  }
5451
5560
  // Other folder…: open the browser at ~, walk it, use a folder
@@ -5491,18 +5600,31 @@ document.addEventListener('click', async (ev) => {
5491
5600
  }
5492
5601
  if (action === 'start-again') { startUi.ask = null; return render() }
5493
5602
  if (action === 'start-send') return startSend(btn)
5494
- if (action === 'start-ask-confirm' || action === 'start-ask-decline') {
5603
+ if (action === 'start-approved-add') return approvedAdd()
5604
+ if (action === 'start-approved-remove') return approvedRemove(Number(btn.dataset.i))
5605
+ if (action === 'start-ask-confirm' || action === 'start-ask-decline' || action === 'start-ask-approve') {
5495
5606
  if (!id || startUi.busy.has(id)) return
5496
5607
  startUi.busy.add(id); render()
5497
5608
  try {
5498
- const r = await api('POST', '/v1/sessions/start/' + encodeURIComponent(id) + '/' + (action === 'start-ask-confirm' ? 'confirm' : 'decline'), {}, { timeoutMs: 60000 })
5499
- 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)
5609
+ // "Always allow in <folder>": approves that folder here, then starts this one
5610
+ const r = await api('POST', '/v1/sessions/start/' + encodeURIComponent(id) + '/' + (action === 'start-ask-decline' ? 'decline' : 'confirm'), action === 'start-ask-approve' ? { approve: true } : {}, { timeoutMs: 60000 })
5611
+ 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)
5612
+ 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
5613
  startUi.busy.delete(id)
5501
5614
  if (r.state === 'started' && r.sessionId) { location.hash = '#/s/' + encodeURIComponent(r.sessionId); return }
5502
5615
  } catch (e) { toast(e.message, 5000) }
5503
5616
  startUi.busy.delete(id)
5504
5617
  return render()
5505
5618
  }
5619
+ // 0.9.9 Permissions of a session Leftoff runs: switch it (or, from another device, ask that Mac)
5620
+ if (action === 'headless-mode') {
5621
+ if (!id || modeBusy.has(id) || btn.getAttribute('aria-checked') === 'true') return
5622
+ modeBusy.set(id, btn.dataset.mode); render()
5623
+ try { modeToast(await switchMode(id, btn.dataset.mode)) } catch (e) { toast(e.message, 6000) }
5624
+ modeBusy.delete(id)
5625
+ return render()
5626
+ }
5627
+ if (action === 'ask-auto') return autoRestClick(btn)
5506
5628
  if (action === 'headless-stop' || action === 'headless-run') {
5507
5629
  btn.disabled = true
5508
5630
  try {
@@ -5694,6 +5816,22 @@ document.addEventListener('click', async (ev) => {
5694
5816
  return paintDigestFilter()
5695
5817
  }
5696
5818
  // ---- Needs you (v0.4.4)
5819
+ // 0.9.9 the docked ask: open a long command in place; reply instead (the composer comes back); back to the card
5820
+ if (action === 'ask-expand') {
5821
+ const code = btn.closest('.ask-dock') && btn.closest('.ask-dock').querySelector('.ask-detail')
5822
+ const open = !askUi.open.has(btn.dataset.ask)
5823
+ if (open) askUi.open.add(btn.dataset.ask); else askUi.open.delete(btn.dataset.ask)
5824
+ if (code) code.classList.toggle('open', open)
5825
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.textContent = open ? 'Show less' : 'Show all'
5826
+ return
5827
+ }
5828
+ if (action === 'ask-reply-instead' || action === 'ask-back') {
5829
+ if (action === 'ask-reply-instead') askUi.reply.add(btn.dataset.ask); else askUi.reply.delete(btn.dataset.ask)
5830
+ await render()
5831
+ if (action === 'ask-reply-instead') { const ta = document.querySelector('.live-box .composer-input'); if (ta && !ta.disabled) ta.focus({ preventScroll: true }) }
5832
+ 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 }) }
5833
+ return
5834
+ }
5697
5835
  if (action === 'ask-deny-open' || action === 'ask-deny-close') {
5698
5836
  const need = btn.closest('.need')
5699
5837
  const form = need && need.querySelector('.deny-form')
@@ -5709,6 +5847,9 @@ document.addEventListener('click', async (ev) => {
5709
5847
  if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
5710
5848
  if (action === 'reply-send') return replyClick(btn)
5711
5849
  if (action === 'live-send') return liveSendClick(btn)
5850
+ if (action === 'lv-queued-cancel' || action === 'lv-queued-edit') return queuedClick(btn, action === 'lv-queued-edit')
5851
+ if (action === 'lv-queued-now') return sendNowClick(btn)
5852
+ if (action === 'lv-queued-more') { if (ev.target.closest('a[href]')) return; const open = !btn.classList.contains('open'); btn.classList.toggle('open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); return }
5712
5853
  if (action === 'attach') { const inp = btn.closest('.composer') && btn.closest('.composer').querySelector('.att-input'); if (inp && !inp.disabled) inp.click(); return }
5713
5854
  if (action === 'att-remove') return removeAttachment(btn.dataset.lid)
5714
5855
  if (action === 'att-retry') return retryAttachment(btn.dataset.lid)
@@ -5843,6 +5984,28 @@ document.addEventListener('click', async (ev) => {
5843
5984
 
5844
5985
  // Answer a permission prompt or a question from the board. The daemon hands it
5845
5986
  // to the waiting hook or mod; the terminal's own prompt may already have won.
5987
+ // 0.9.9 Permissions mid-session (src/session-start.js switchMode): switched, or waiting for a yes at that Mac.
5988
+ function switchMode(id, mode) { return api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/mode', { mode }, { timeoutMs: 30000 }) }
5989
+ function modeToast(r) {
5990
+ if (r && r.state === 'pending') toast('Asked ' + (r.machine || 'that Mac') + ': someone there says yes, then it switches', 5000)
5991
+ else toast('Permissions: ' + modeLabel(r && r.mode) + (r && r.running === false ? ' (when it runs again)' : ''), 3000)
5992
+ }
5993
+ // "Auto-approve the rest" on a permission card: allow this call, and switch the session to Auto.
5994
+ async function autoRestClick(btn) {
5995
+ const need = btn.closest('.need')
5996
+ if (!need || !need.dataset.ask) return
5997
+ const sid = need.dataset.id
5998
+ need.querySelectorAll('button, input, textarea').forEach((el) => { if (!el.classList.contains('go')) el.disabled = true })
5999
+ btn.textContent = 'Switching…'
6000
+ let sw = null
6001
+ try { sw = await switchMode(sid, 'auto') } catch (e) { toast(e.message, 6000); render(); return }
6002
+ try {
6003
+ await api('POST', '/v1/asks/' + encodeURIComponent(need.dataset.ask) + '/answer', { action: 'allow', auto: true }) // 0.9.9: the thread's row says Auto-approved
6004
+ 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)
6005
+ } catch (e) { toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200) }
6006
+ render()
6007
+ }
6008
+
5846
6009
  async function answerClick(btn, action) {
5847
6010
  const need = btn.closest('.need, .ql-item') // 0.9.8: Quick look's Allow / Deny are the same buttons
5848
6011
  if (!need) return
@@ -5873,6 +6036,7 @@ async function answerClick(btn, action) {
5873
6036
  try {
5874
6037
  const r = await api('POST', '/v1/asks/' + encodeURIComponent(askId) + '/answer', body)
5875
6038
  toast(r.summary + (r.delivered ? ' · Claude Code has it' : ' · waiting for Claude Code to pick it up'), 3600)
6039
+ if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // a typed answer sent: a phone's keyboard closes
5876
6040
  for (const k of Object.keys(state.drafts)) if (k.includes(askId)) delete state.drafts[k]
5877
6041
  } catch (e) {
5878
6042
  toast(e.code === 'not_waiting' ? 'Already handled: ' + e.message : e.message, 4200)
@@ -5896,6 +6060,7 @@ async function replyClick(btn) {
5896
6060
  const r = await api('POST', '/v1/sessions/' + encodeURIComponent(need.dataset.id) + '/reply', { text })
5897
6061
  delete state.drafts['reply:' + need.dataset.id]
5898
6062
  toast(r.queuedBehindTurn ? 'Sent · Claude reads it when the current turn ends' : 'Sent · Claude has it as its next prompt', 3600)
6063
+ if (!keepFocusAfterSend({ ok: true, touch: isTouchUi(window.matchMedia) })) letGoOfKeyboard(document) // a phone's keyboard closes
5899
6064
  } catch (e) { toast(e.message, 4200) } finally { render() }
5900
6065
  }
5901
6066
 
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.