@leftoff/cli 0.9.18-beta.10 → 0.9.18-beta.11

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
@@ -94,7 +94,7 @@ A relayed request is replayed against the Mac's own board from inside the daemon
94
94
 
95
95
  When your hosted board is open on a Mac that runs Leftoff, the page sends the same sealed requests straight to that Mac's daemon at `http://127.0.0.1:7429/v1/relay/direct` instead of to the relay. Nothing new is trusted: the daemon opens them with the same per-device key (and the same replay and time checks), runs them the same way (a remote device, the rules above), and seals the answer. The page only switches after the daemon says it's on your account and a sealed round trip through it works, so another Leftoff on that port (someone else's account) can't serve your board: it can't open the request or forge the answer. The door is loopback only, never through a proxy, off when Use from anywhere is off, and answers CORS for exactly one origin, `https://<your name>.leftoff.dev` (with Chrome's Private Network Access header); any other page gets a 403 it can't read. If the direct path fails, the request goes through the relay once more and the board stays on the relay until a later check finds the direct path again.
96
96
 
97
- Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
97
+ Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback, or since 0.9.18 another app's Caddy on port 443 that imports Leftoff's site file, which turns away any client not on loopback by its address; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
98
98
 
99
99
  ### Live link (0.9.8 beta)
100
100
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.18-beta.10",
3
+ "version": "0.9.18-beta.11",
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": "One Silly App"
package/mod/hooks/core.js CHANGED
@@ -1397,7 +1397,7 @@ const CHECKPOINT_BASE = [
1397
1397
  const DECIDED_SECTION = [
1398
1398
  'Only if the transcript shows a decision (a choice between real alternatives that the developer made or approved, a plan they approved, a correction they gave such as "no, do X instead", or a trade-off the agent chose and said so), add after those lines a "Decided:" heading and one "- " line per decision, at most 4:',
1399
1399
  '- <what was decided, in the words the transcript uses> · why: <the reason given, one line> · over: <alternative>; <alternative> · from: <asked | plan | correction | agent>',
1400
- 'A task done, a file edited or a routine step is not a decision. Never invent a choice or a reason the transcript does not show. When unsure, or when there is none, leave the Decided heading out entirely. Skip decisions listed as already recorded.',
1400
+ 'A task done, a file edited or a routine step is not a decision, and neither is a one-off choice about this task right now (who does a step, whether to commit, push or deploy now). Never invent a choice or a reason the transcript does not show. When unsure, or when there is none, leave the Decided heading out entirely. Skip decisions listed as already recorded.',
1401
1401
  ]
1402
1402
  const INFORMATIVE_SECTIONS = [
1403
1403
  'Then these sections, each a heading line followed by "- " bullets (write "- none" when there is nothing; never invent):',
@@ -1577,12 +1577,23 @@ function planHeadline(plan) {
1577
1577
  return h.replace(/^#{1,6}\s+/, '').replace(/^(?:plan|implementation plan)\s*[:\-–—]\s*/i, '').replace(/\*\*/g, '').trim()
1578
1578
  }
1579
1579
 
1580
- // The decisions a transcript states outright, with no model: a question you
1581
- // answered from its options (AskUserQuestion, the board's Needs you), its other
1582
- // options as the alternatives; a plan you approved (ExitPlanMode). Messages are
1583
- // src/transcript.js's shape, which is also $.session.messages()'s.
1584
- export function decisionsFromMessages(messages) {
1585
- const out = []
1580
+ // 2026-11-05: a question about one task right now isn't a decision for the repo: who does it ("You update
1581
+ // it" / "I'll handle it"), or whether to go ahead with a commit, push, deploy and the like. Never recorded.
1582
+ const ACTOR_OPT = /^(?:you|i|i'll|i will|i'd|me|let me|we|we'll|leave it|someone)\b/i
1583
+ const NOW_Q = /\b(?:go ahead|proceed|right now|commit|push|merge|deploy|ship|publish|release|restart|open (?:a|the) pr|create (?:a|the) pr|who (?:should|will|does))\b/i
1584
+ const YESNO_OPT = /^(?:yes|no|y|n|ok|okay|sure|go ahead|do it|proceed|not now|later|skip|wait|stop|hold off|cancel|continue)\b/i
1585
+ export function isOneOffQuestion(q) {
1586
+ const labels = (Array.isArray(q && q.options) ? q.options : []).map((o) => String((o && o.label) || o || '').replace(/\s*\(Recommended\)\s*/i, '').trim()).filter(Boolean)
1587
+ if (labels.length < 2) return false
1588
+ if (labels.every((l) => ACTOR_OPT.test(l))) return true
1589
+ return NOW_Q.test(String((q && q.question) || '')) && labels.every((l) => YESNO_OPT.test(l))
1590
+ }
1591
+
1592
+ // The transcript's own decisions, oldest first, and the answers it later changed:
1593
+ // a question asked again (same header, else the same question) and answered
1594
+ // differently keeps only the last answer; the earlier ones are `reversed` (texts).
1595
+ function readStated(messages) {
1596
+ const out = [], at = new Map(), replaced = new Set()
1586
1597
  for (const m of messages || []) {
1587
1598
  if (!m || m.role !== 'assistant') continue
1588
1599
  for (const u of m.toolUses || []) {
@@ -1593,17 +1604,22 @@ export function decisionsFromMessages(messages) {
1593
1604
  const ans = questionAnswers(u.text, qs.map((q) => String(q.question)))
1594
1605
  for (const q of qs) {
1595
1606
  const a = ans[q.question]
1596
- if (!a) continue
1607
+ if (!a || isOneOffQuestion(q)) continue
1597
1608
  const labels = q.options.map((o) => String((o && o.label) || o || '').trim()).filter(Boolean)
1598
1609
  const picked = labels.filter((l) => a === l || a.split(/\s*,\s*/).includes(l))
1599
1610
  const chosen = picked.length ? picked.join(', ') : a
1600
1611
  const opt = q.options.find((o) => o && o.label === picked[0])
1601
- out.push({
1612
+ const d = {
1602
1613
  text: clip(String(q.header ? q.header + ': ' : '') + chosen.replace(/\s*\(Recommended\)\s*/i, ''), 240),
1603
1614
  why: opt && opt.description ? opt.description : '',
1604
1615
  alternatives: labels.filter((l) => !picked.includes(l)).map((l) => l.replace(/\s*\(Recommended\)\s*/i, '')),
1605
1616
  source: 'asked',
1606
- })
1617
+ }
1618
+ const topic = 'q:' + decisionNorm(q.header || q.question)
1619
+ const was = at.get(topic)
1620
+ if (was != null && decisionNorm(out[was].text) !== decisionNorm(d.text)) { replaced.add(out[was].text); out[was] = null }
1621
+ if (was != null && out[was]) continue // the same answer again
1622
+ at.set(topic, out.length); out.push(d)
1607
1623
  }
1608
1624
  }
1609
1625
  if (name === 'ExitPlanMode' && u.text && PLAN_OK.test(u.text)) {
@@ -1612,7 +1628,29 @@ export function decisionsFromMessages(messages) {
1612
1628
  }
1613
1629
  }
1614
1630
  }
1615
- return cleanDecided(out)
1631
+ const found = out.filter(Boolean)
1632
+ const kept = new Set(found.map((d) => decisionNorm(d.text)))
1633
+ return { found, reversed: [...replaced].filter((t) => !kept.has(decisionNorm(t))) }
1634
+ }
1635
+
1636
+ // The decisions a transcript states outright, with no model: a question you
1637
+ // answered from its options (AskUserQuestion, the board's Needs you), its other
1638
+ // options as the alternatives, only its last answer; a plan you approved
1639
+ // (ExitPlanMode). Messages are src/transcript.js's shape, which is also
1640
+ // $.session.messages()'s.
1641
+ export function decisionsFromMessages(messages) {
1642
+ return cleanDecided(readStated(messages).found)
1643
+ }
1644
+
1645
+ // 2026-11-05: what the log settles on its own (src/decisions.js settleStated): the stated decisions,
1646
+ // redacted as checkpoints save them, and the texts of answers changed later in the session.
1647
+ export function statedDecisions(messages, patterns = []) {
1648
+ const red = (t) => redact(String(t || ''), patterns || [])
1649
+ const { found, reversed } = readStated(messages)
1650
+ return {
1651
+ found: cleanDecided(found).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) })),
1652
+ reversed: reversed.map((t) => red(clip(oneLine(t), 240))),
1653
+ }
1616
1654
  }
1617
1655
 
1618
1656
  // The checkpoint's decisions: what the transcript states outright first, then the model's.
@@ -1910,7 +1948,7 @@ export const TITLE_LANGUAGE = 'Write it in the same language as the user\'s own
1910
1948
  export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. ' + TITLE_LANGUAGE + ' Reply with the title only, no quotes, no punctuation at the end.'
1911
1949
 
1912
1950
  export function cleanTitle(text) {
1913
- const t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').split('\n').map((l) => l.trim()).find(Boolean) || ''
1951
+ const t = stripChannel(String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '')).split('\n').map((l) => l.trim()).find(Boolean) || '' // 0.9.18: a model that echoes the Live wrapper
1914
1952
  return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
1915
1953
  }
1916
1954
 
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
32
32
  const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
33
33
 
34
34
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
35
- const MOD_VERSION = '0.9.18-beta.10' // keep in step with .claude-plugin/plugin.json
35
+ const MOD_VERSION = '0.9.18-beta.11' // keep in step with .claude-plugin/plugin.json
36
36
 
37
37
  const st = {
38
38
  internal: false,
@@ -1107,14 +1107,22 @@ function startReplyLoop($) {
1107
1107
  let backoff = 0
1108
1108
  while (st.replyLoop && st.sessionId) {
1109
1109
  const sid = st.sessionId
1110
- const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
1110
+ const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?cmds=1&ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
1111
1111
  if (!r || !r.ok) {
1112
1112
  // Daemon down: try again on the heartbeat rather than spinning.
1113
1113
  st.replyLoop = false
1114
1114
  return
1115
1115
  }
1116
1116
  const reply = r.json && r.json.reply
1117
- if (reply && reply.text && !String(reply.text).startsWith('/') && sid === st.sessionId) {
1117
+ // a slash command from the board (the daemon checked it against its list):
1118
+ // Claude Code runs it as if typed, queued behind a running turn (as /rename)
1119
+ if (reply && reply.command && typeof reply.command.name === 'string' && /^[a-z][\w-]{0,30}$/.test(reply.command.name) && sid === st.sessionId) {
1120
+ try {
1121
+ await $.command.run({ command: reply.command.name, args: String(reply.command.args || '').replace(/[\r\n]+/g, ' ').slice(0, 500) })
1122
+ } catch (err) {
1123
+ try { $.ui.toast('Leftoff: couldn’t run the board’s /' + reply.command.name + ': ' + String((err && err.message) || err).slice(0, 120)) } catch {}
1124
+ }
1125
+ } else if (reply && reply.text && !String(reply.text).startsWith('/') && sid === st.sessionId) {
1118
1126
  try {
1119
1127
  await $.prompt.submit({ text: String(reply.text) })
1120
1128
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.18-beta.10",
3
+ "version": "0.9.18-beta.11",
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
@@ -2491,6 +2491,20 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2491
2491
 
2492
2492
  /* 0.9.8 attachments: a paperclip beside ↑, chips above the words inside the box, thumbnails in your bubble */
2493
2493
  .composer { flex-wrap: wrap; }
2494
+ /* "/" commands (ui/slash.js): a list above the composer; options 44px tall */
2495
+ .composer .slash-menu { position: absolute; left: 0; right: 0; bottom: calc(100% + 6px); z-index: 30; max-height: min(320px, 50vh); overflow-y: auto; overscroll-behavior: contain; padding: 4px; border: 1px solid var(--line-strong); border-radius: 14px; background: var(--card); box-shadow: 0 8px 24px rgba(0,0,0,.22); cursor: default; }
2496
+ .slash-h { padding: 6px 10px 2px; font: 600 11.5px/1.4 var(--mono); color: var(--muted); }
2497
+ .slash-opt { display: flex; align-items: baseline; gap: 10px; min-height: 44px; box-sizing: border-box; padding: 11px 10px; border-radius: 10px; cursor: pointer; }
2498
+ .slash-opt[aria-selected="true"] { background: var(--bg2); }
2499
+ @media (hover: hover) { .slash-opt:hover { background: var(--bg2); } }
2500
+ .slash-opt.off { cursor: not-allowed; opacity: .6; }
2501
+ .slash-l { flex: none; font: 500 13.5px/1.5 var(--mono); color: var(--text); }
2502
+ .slash-hint { color: var(--muted); font-weight: 400; }
2503
+ .slash-w { min-width: 0; font-size: 13px; line-height: 1.5; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2504
+ /* a slash command's bubble: the command in mono, then what Claude Code printed */
2505
+ .lv-cmd code { font-size: 13.5px; background: none; border: 0; padding: 0; }
2506
+ .lv-cmd-out { margin: 6px 0 0; padding: 0; font: 12.5px/1.5 var(--mono); color: var(--text2); white-space: pre-wrap; overflow-wrap: anywhere; background: none; border: 0; max-height: 240px; overflow-y: auto; }
2507
+ .lv-cmd-done { font-family: inherit; color: var(--muted); }
2494
2508
  .composer .att-chips { flex: 0 0 100%; order: -1; display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 8px 2px 0; margin-left: -6px; }
2495
2509
  .composer .att-chips[hidden] { display: none; }
2496
2510
  .composer.att-drop { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); background: color-mix(in srgb, var(--accent) 7%, var(--card2)); }
@@ -2842,7 +2856,18 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2842
2856
  .tab:hover { color: var(--text); }
2843
2857
  .tab:focus-visible, .tab-x:focus-visible, .tab-all:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2844
2858
  .tab-board { flex: none; border-right: 1px solid var(--line); }
2845
- .tab-cell { position: relative; flex: 0 1 200px; min-width: 120px; display: flex; align-items: stretch; border-right: 1px solid var(--line); }
2859
+ .tab-cell { position: relative; flex: 0 1 200px; min-width: 140px; display: flex; align-items: stretch; border-right: 1px solid var(--line); }
2860
+ /* 0.9.18 a busy strip: the tab you're on keeps its full width (only the others shrink), the rest
2861
+ keep 140px and the strip scrolls with a fade at the edge that has more (ui/chip-fade.js);
2862
+ a pinned tab is its pin and its status shape, the name in its tip and label. */
2863
+ .tab-cell.on { flex-shrink: 0; }
2864
+ .tab-cell.pinned { flex: none; min-width: 0; }
2865
+ .tab-cell.pinned .tab-s { padding-right: 12px; }
2866
+ .tab-cell.pinned .tab-t { display: none; }
2867
+ .tab-cell.pinned .tab-pin { margin-right: 0; }
2868
+ #tabStrip .tab-scroll.fade-r { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent); }
2869
+ #tabStrip .tab-scroll.fade-l { -webkit-mask-image: linear-gradient(to left, #000 calc(100% - 32px), transparent); mask-image: linear-gradient(to left, #000 calc(100% - 32px), transparent); }
2870
+ #tabStrip .tab-scroll.fade-l.fade-r { -webkit-mask-image: linear-gradient(to right, transparent, #000 32px, #000 calc(100% - 32px), transparent); mask-image: linear-gradient(to right, transparent, #000 32px, #000 calc(100% - 32px), transparent); }
2846
2871
  .tab-s { flex: 1; min-width: 0; padding-right: 0; }
2847
2872
  .tab.on, .tab-cell.on { color: var(--text); background: var(--card); }
2848
2873
  .tab-board.on, .tab-cell.on { box-shadow: inset 0 -2px 0 var(--accent); }
@@ -4518,15 +4543,32 @@ html:not(.live-full) .fs-side { display: none; }
4518
4543
  html.live-full.fs-on .live-box.full { height: calc(var(--lf-h, 100vh) - var(--fs-peek, 56px)); height: calc(var(--lf-h, 100dvh) - 56px - env(safe-area-inset-bottom)); padding-bottom: 6px; }
4519
4544
  html.live-full.lf-kb .fs-side { display: none; }
4520
4545
  html.live-full.lf-kb.fs-on .live-box.full { height: var(--lf-h, 100dvh); }
4546
+ /* 2026-11-06 typing outside the sheet: it steps aside whether or not a keyboard's height was measured (app.js fitSheetAside) */
4547
+ html.live-full.lf-typing .fs-side { display: none; }
4548
+ html.live-full.lf-typing.fs-on .live-box.full { height: var(--lf-h, 100dvh); }
4549
+ /* the sheet follows a finger (app.js); its peek is the handle, its content scrolls natively */
4550
+ .fs-side.dragging { transition: none; }
4551
+ .fs-peek { touch-action: none; }
4521
4552
  }
4522
4553
  @media (prefers-reduced-motion: reduce) { .fs-side { transition: none; } }
4523
4554
  /* the tab strip: pinned tabs at the left (a pin), each tab's status as a shape AND a word, + New session */
4524
- .tab-cell.pinned .tab-t { max-width: 10em; }
4525
4555
  .tab-pin { display: inline-flex; flex: none; margin-right: 5px; color: var(--text2); }
4526
4556
  .tab-pin .ic { width: 12px; height: 12px; }
4527
4557
  .lf-tab .tab-pin { margin-right: 2px; }
4528
4558
  .tab-st { display: inline-flex; align-items: center; gap: 3px; flex: none; margin-left: 6px; }
4529
4559
  .tab-w { font-size: 11.5px; font-weight: 550; color: var(--text2); white-space: nowrap; }
4560
+ /* 0.9.18 the strip: a closable tab's shape sits in the ×'s place; the × on hover or keyboard focus.
4561
+ A pinned tab and a touch screen (no hover) keep the shape beside the title. */
4562
+ .tab-st:empty, .tab-st-touch { display: none; }
4563
+ .tab-x .tab-xsg { position: absolute; left: 50%; top: 50%; margin: -7px 0 0 -7px; }
4564
+ .tab-x.has-st .ic { opacity: 0; }
4565
+ .tab-cell:hover .tab-x.has-st .ic, .tab-x.has-st:focus-visible .ic { opacity: 1; }
4566
+ .tab-cell:hover .tab-x .tab-xsg, .tab-x:focus-visible .tab-xsg { visibility: hidden; }
4567
+ @media (hover: none), (pointer: coarse) {
4568
+ .tab-st-touch:not(:empty) { display: inline-flex; }
4569
+ .tab-x .tab-xsg { display: none; }
4570
+ .tab-x.has-st .ic { opacity: 1; }
4571
+ }
4530
4572
  .tab-new { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 44px; color: var(--text2); border-radius: 8px; }
4531
4573
  .tab-new .ic { width: 16px; height: 16px; }
4532
4574
  @media (hover: hover) { .tab-new:hover { color: var(--text); background: var(--card2); } }
@@ -5470,6 +5512,7 @@ a.dec-rc:hover .dec-rc-w { text-decoration: underline; }
5470
5512
  .dec-row-h .dec-st { margin-left: 0; font-size: 11.5px; color: var(--text); }
5471
5513
  .dec-row-h .dec-id { font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text2); opacity: .85; }
5472
5514
  .dec-row-h .dec-src { margin-top: 0; font-size: 11.5px; }
5515
+ .dec-auto { font-size: 11.5px; color: var(--text2); }
5473
5516
  .dec-dot { color: var(--text2); opacity: .6; }
5474
5517
  .dec-list .dec-text { font-size: 14px; font-weight: 500; line-height: 1.4; margin-top: 2px; overflow-wrap: anywhere; }
5475
5518
  .dec-row-rejected .dec-text { text-decoration: line-through; text-decoration-thickness: 1px; color: var(--text2); }
package/ui/app.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // /v1 API on the same origin (auth rides the cookie set at GET /).
3
3
  import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
4
  import { icon } from './icons.js'
5
- import { fullViewportFit, isTypingTarget, outOfView, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
5
+ import { fullViewportFit, isTypingTarget, outOfView, sheetAside, SHEET_BACK_AFTER_BLUR_MS, SETTLE_AFTER_BLUR_MS, SETTLE_WHILE_TYPING_MS } from './viewport-fit.js' // 2026-11-03: no gap above full screen after the keyboard closes; 2026-11-04: nor a hidden composer after switching keyboards
6
6
  import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey, openNameSheet, openProjectSheet, refreshActionMenu } from './picker.js'
7
7
  import { sheetRepos, sheetPlan, runSheetPlan, askScopeItems } from './project-sheet.js' // 0.9.12: New project with several repos
8
8
  import { watchChipFades } from './chip-fade.js' // 0.9.12: chip rows fade only where chips run off
@@ -54,7 +54,7 @@ import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason,
54
54
  import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
55
55
  import { createViewStore, viewRouteStep, fullHrefWith, notifyHash } from './full-mem.js' // 0.9.15 a session opens the way you left it, on this device
56
56
  import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir, fullTabsHtml, tabTarget, moveTabBefore, syncPins, legacyPins, isPinned } from './tabs.js'
57
- import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN } from './fs-side.js'
57
+ import { fsSideHtml, createSideStore, railHtml, createWidthStore, clampSideWidth, sideMax, SIDE_W_STEP, SIDE_W_DEFAULT, WIDE_MIN, sheetDragStart, sheetDragOffset, sheetDragFlips } from './fs-side.js'
58
58
  import { fileReach, gateFileRefs } from './file-reach.js' // 2026-10-29 a path links only when its file can be read
59
59
  import { createFoldStore, foldHtml, relPath, rootsOf, filesTouchedHtml, dropJunkCps, statusStripHtml, answeredHtml, whereWords } from './side-fold.js' // 2026-10-29 a calmer session panel
60
60
  import { walkthroughHtml, hydrateWalkthrough, fmToggle, eyeCountHtml, reviewedSet, stepAfter, visibleSteps, stepPos, toMarkdown, toHtmlDoc, exportName, revNextState, revNextLabel, REVNEXT_TIP, REVFINISH_TIP, sfState, sfSet, patchFor, readingOrder, uniqueStepIds, SPLIT_MIN, RAIL_MIN as WT_SIDE_MIN, RAIL_MAX as WT_SIDE_MAX, RAIL_DEFAULT as WT_SIDE_DEFAULT, fileScopeStore, scopeAskLine } from './semdiff.js' // 0.9.17 Walk me through it
@@ -62,6 +62,7 @@ import { rowsOf, pickLine, dragTo, describePicks, refsPrompt } from './walk-line
62
62
  import { spotsFor, nextSpotIndex, spotCountWords } from './walk-spots.js' // 2026-10-30 Next spot
63
63
  import { walkButtonHtml, sizeLineHtml, popContent, initPops } from './walk-pop.js' // 0.9.18 Walk me through it's size line and popover
64
64
  import { lastReply, ghostShows } from './ghost.js'
65
+ import { slashTarget, slashMenu, slashMove, slashKey, slashMenuHtml } from './slash.js' // slash commands from the board
65
66
  import { digestHeadline, todayHidden, hideToday, todayCardHtml } from './today.js' // 0.9.16 the board's Today card // 0.9.16 the suggested next message // 0.9.16 the session panel beside full screen
66
67
  import { machinesListHtml, pendingCardsHtml, machineDialogHtml, stateOf as machineStateOf } from './machine-life.js' // 0.9.16 rename, re-pair, retire, merge
67
68
  import { reorderable, reordering } from './reorder.js' // 0.9.16 drag tabs to reorder // 0.9.8 tabs for sessions
@@ -3406,10 +3407,64 @@ document.addEventListener('scroll', (e) => {
3406
3407
  const el = e.target
3407
3408
  if (!el || !el.classList || !el.classList.contains('fs-side-in')) return
3408
3409
  const side = el.closest('.fs-side')
3410
+ // 2026-11-06 not on a phone's sheet: there the head isn't pinned, so slimming it as you scroll moved the
3411
+ // content under your finger by the head's change in height, which flipped it back (the sheet jumped)
3412
+ if (sheetMq.matches) { if (side && side.classList.contains('is-scrolled')) side.classList.remove('is-scrolled'); fsScrolled = null; return }
3409
3413
  fsScrolled = side && el.scrollTop > 8 ? side.dataset.sid : null
3410
3414
  if (side) side.classList.toggle('is-scrolled', !!fsScrolled)
3411
3415
  }, true)
3412
3416
 
3417
+ // 2026-11-06 the phone's sheet follows a finger (ui/fs-side.js sheetDragStart): drag the peek up to
3418
+ // raise it, drag the raised sheet down from its peek, or from its content scrolled to the top, to lower
3419
+ // it. Anything else is the content's own native scroll, untouched.
3420
+ const sheetMq = window.matchMedia('(max-width: 600px)')
3421
+ {
3422
+ let d = null
3423
+ const travel = (side) => Math.max(0, side.getBoundingClientRect().height - (side.querySelector('.fs-peek') || side).getBoundingClientRect().height)
3424
+ document.addEventListener('touchstart', (e) => {
3425
+ d = null
3426
+ if (!sheetMq.matches || e.touches.length !== 1) return
3427
+ const side = e.target.closest && e.target.closest('#fsSide')
3428
+ if (!side || side.classList.contains('collapsed') || !document.documentElement.classList.contains('live-full')) return
3429
+ const fromPeek = !!e.target.closest('.fs-peek')
3430
+ const inner = side.querySelector('.fs-side-in')
3431
+ if (!fromPeek && e.target.closest('input, textarea, select, [contenteditable], pre, table, .scroll-x')) return
3432
+ // a scroller of its own inside the content (a long list) that's scrolled: that's a scroll
3433
+ for (let n = e.target; n && n !== inner && n !== side; n = n.parentElement) if (n.scrollTop > 0) return
3434
+ const p = e.touches[0]
3435
+ d = { side, x: p.clientX, y: p.clientY, at: Date.now(), up: side.classList.contains('sheet-up'), fromPeek, scrollTop: inner ? inner.scrollTop : 0, mode: null, travel: 0 }
3436
+ }, { passive: true })
3437
+ document.addEventListener('touchmove', (e) => {
3438
+ if (!d || e.touches.length !== 1) return
3439
+ const p = e.touches[0], dy = p.clientY - d.y, dx = p.clientX - d.x
3440
+ if (!d.mode) {
3441
+ d.mode = sheetDragStart({ up: d.up, fromPeek: d.fromPeek, scrollTop: d.scrollTop, dy, dx })
3442
+ if (!d.mode) return
3443
+ if (d.mode === 'scroll') { d = null; return } // the content's own scroll from here on
3444
+ d.travel = travel(d.side)
3445
+ d.side.classList.add('dragging')
3446
+ }
3447
+ if (e.cancelable) e.preventDefault() // the sheet moves, not the content's rubber band
3448
+ const off = sheetDragOffset({ up: d.up, dy, travel: d.travel })
3449
+ d.side.style.transform = 'translateY(' + ((d.up ? 0 : d.travel) + off) + 'px)'
3450
+ }, { passive: false })
3451
+ const end = (e, cancel) => {
3452
+ const g = d
3453
+ d = null
3454
+ if (!g || g.mode !== 'drag') return
3455
+ g.side.classList.remove('dragging')
3456
+ g.side.style.transform = ''
3457
+ const p = e.changedTouches && e.changedTouches[0]
3458
+ if (cancel || !p) return
3459
+ if (sheetDragFlips({ up: g.up, dy: p.clientY - g.y, ms: Date.now() - g.at })) {
3460
+ toggleFsSheet()
3461
+ sheetUi.ignoreClick = Date.now() // the click iOS sends after the drag isn't a tap on the peek
3462
+ }
3463
+ }
3464
+ document.addEventListener('touchend', (e) => end(e, false), { passive: true })
3465
+ document.addEventListener('touchcancel', (e) => end(e, true), { passive: true })
3466
+ }
3467
+ const sheetUi = { ignoreClick: 0 }
3413
3468
  function toggleFsSheet() {
3414
3469
  const side = document.getElementById('fsSide')
3415
3470
  if (!side) return
@@ -3464,10 +3519,19 @@ function setFullClass(on) {
3464
3519
  // and the composer stays just above the keyboard. When the keyboard closes the
3465
3520
  // layout goes back exactly to where it was (ui/viewport-fit.js: iOS can leave
3466
3521
  // its pan behind after a send, which put full screen ~300px down the screen).
3467
- const lfKb = { scroll: null, settle: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard)
3522
+ const lfKb = { scroll: null, settle: 0, blurAt: 0, back: 0 } // scroll: window.scrollY as a text box took focus (iOS may scroll the page under the keyboard); blurAt: when a text box outside the sheet let go
3523
+ // 2026-11-06 the phone's session sheet steps aside while you type outside it (ui/viewport-fit.js sheetAside)
3524
+ function fitSheetAside() {
3525
+ const html = document.documentElement, a = document.activeElement
3526
+ const typing = isTypingTarget(a)
3527
+ const inSheet = !!(typing && a.closest && a.closest('#fsSide'))
3528
+ const aside = html.classList.contains('live-full') && sheetAside({ typing, inSheet, blurredMs: lfKb.blurAt ? Date.now() - lfKb.blurAt : null })
3529
+ html.classList.toggle('lf-typing', aside)
3530
+ }
3468
3531
  function fitFullViewport() {
3469
3532
  const vv = window.visualViewport
3470
3533
  const html = document.documentElement, st = html.style
3534
+ fitSheetAside()
3471
3535
  if (!html.classList.contains('live-full')) { st.removeProperty('--lf-h'); st.removeProperty('--lf-top'); st.removeProperty('--lf-bar'); html.classList.remove('lf-kb'); lfKb.scroll = null; return }
3472
3536
  // 0.9.14: from 700px full screen starts under the board's own tab bar (pinned to the top, the rail hidden)
3473
3537
  const strip = document.getElementById('tabStrip')
@@ -3520,6 +3584,8 @@ document.addEventListener('visibilitychange', () => { if (document.visibilitySta
3520
3584
  document.addEventListener('focusin', (e) => {
3521
3585
  if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3522
3586
  if (lfKb.scroll == null) lfKb.scroll = window.scrollY
3587
+ clearTimeout(lfKb.back); lfKb.blurAt = 0
3588
+ fitSheetAside() // the sheet steps aside the moment the box takes focus, before the keyboard has said anything
3523
3589
  // 2026-11-04: a third-party keyboard (Raycast, Gboard, SwiftKey) can come up late, or change the
3524
3590
  // visual viewport without a resize event of its own: measure again as it opens, as on the way down
3525
3591
  settleWhileTyping(0)
@@ -3529,6 +3595,11 @@ document.addEventListener('focusin', (e) => {
3529
3595
  document.addEventListener('focusout', (e) => {
3530
3596
  if (!isTypingTarget(e.target) || !document.documentElement.classList.contains('live-full')) return
3531
3597
  clearTimeout(lfKb.settle)
3598
+ if (!(e.target.closest && e.target.closest('#fsSide'))) { // the sheet comes back a beat after the box lets go
3599
+ lfKb.blurAt = Date.now()
3600
+ clearTimeout(lfKb.back)
3601
+ lfKb.back = setTimeout(() => { lfKb.blurAt = 0; fitSheetAside() }, SHEET_BACK_AFTER_BLUR_MS)
3602
+ }
3532
3603
  let i = 0
3533
3604
  const step = () => {
3534
3605
  fitFullViewport()
@@ -4011,6 +4082,7 @@ document.addEventListener('pointerdown', (ev) => { const a = document.activeElem
4011
4082
  function liveAfterRender(sid) {
4012
4083
  for (const ta of $app.querySelectorAll('.composer-input')) fitComposer(ta)
4013
4084
  bangAfterRender()
4085
+ slashAfterRender()
4014
4086
  const r = liveKeeper.after($app)
4015
4087
  hydrateAttThumbs(); barWidths($app)
4016
4088
  fullAfterRender(sid)
@@ -4434,6 +4506,91 @@ function bangAfterRender() {
4434
4506
  if (w && w.text === ta.value) { const row = box.querySelector('.composer-mode'); if (row && !row.classList.contains('why')) { row.hidden = false; row.classList.add('why'); const t = row.querySelector('.composer-mode-t'); if (t) t.textContent = 'Not run: ' + w.why } }
4435
4507
  }
4436
4508
  }
4509
+ // ------------------------------------------------------------ "/" commands (ui/slash.js, ui/slash-commands.js)
4510
+ // The menu lives in the Live composer's box, drawn from the text: a repaint draws it again (slashAfterRender).
4511
+ const slashUi = { active: -1, closedFor: null } // closedFor: the text Esc closed it on (it stays closed until the text changes)
4512
+ function slashFor(ta) {
4513
+ const box = ta && ta.closest('.live-box .composer')
4514
+ if (!box || ta.disabled) return null
4515
+ if (slashUi.closedFor != null && slashUi.closedFor === ta.value) return null
4516
+ return slashMenu(ta.value, { target: slashTarget(wtSession(box.dataset.sid)) })
4517
+ }
4518
+ function paintSlash(ta, { keep = false } = {}) {
4519
+ const box = ta && ta.closest('.composer')
4520
+ if (!box) return null
4521
+ const menu = slashFor(ta)
4522
+ let el = box.querySelector('.slash-menu')
4523
+ if (!menu) {
4524
+ if (el) el.remove()
4525
+ box.classList.remove('has-slash'); ta.removeAttribute('aria-activedescendant'); ta.setAttribute('aria-expanded', 'false')
4526
+ slashUi.active = -1
4527
+ return null
4528
+ }
4529
+ if (!keep || slashUi.active >= menu.items.length || (menu.items[slashUi.active] || {}).off) slashUi.active = slashMove(menu, -1, 'first')
4530
+ const id = 'slashMenu-' + box.dataset.sid
4531
+ const html = slashMenuHtml(menu, { active: slashUi.active, id, esc })
4532
+ if (el) el.outerHTML = html; else box.insertAdjacentHTML('afterbegin', html)
4533
+ box.classList.add('has-slash')
4534
+ ta.setAttribute('aria-controls', id); ta.setAttribute('aria-expanded', 'true')
4535
+ if (slashUi.active >= 0) ta.setAttribute('aria-activedescendant', id + '-' + slashUi.active); else ta.removeAttribute('aria-activedescendant')
4536
+ const on = box.querySelector('.slash-opt[aria-selected="true"]')
4537
+ if (on && on.scrollIntoView) on.scrollIntoView({ block: 'nearest' })
4538
+ return menu
4539
+ }
4540
+ function pickSlash(ta, value) {
4541
+ ta.value = value
4542
+ try { ta.setSelectionRange(value.length, value.length) } catch {}
4543
+ ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft, the size, the send button, and the next menu
4544
+ ta.focus({ preventScroll: true })
4545
+ }
4546
+ document.addEventListener('input', (ev) => {
4547
+ const ta = ev.target
4548
+ if (!ta || !ta.classList || !ta.classList.contains('composer-input') || !ta.closest('.live-box')) return
4549
+ if (slashUi.closedFor != null && slashUi.closedFor !== ta.value) slashUi.closedFor = null
4550
+ paintSlash(ta)
4551
+ })
4552
+ // before the composer's own Enter (send) and Esc: with the menu open, the keys are the menu's
4553
+ window.addEventListener('keydown', (ev) => {
4554
+ const ta = ev.target
4555
+ if (ev.defaultPrevented || !ta || !ta.classList || !ta.classList.contains('composer-input')) return
4556
+ const box = ta.closest('.live-box .composer')
4557
+ if (!box || !box.querySelector('.slash-menu')) return
4558
+ const k = slashKey(ev)
4559
+ if (!k) return
4560
+ const menu = slashFor(ta)
4561
+ if (!menu) return
4562
+ ev.preventDefault(); ev.stopPropagation()
4563
+ if (k === 'move') { slashUi.active = slashMove(menu, slashUi.active, ev.key); paintSlash(ta, { keep: true }); return }
4564
+ if (k === 'close') { slashUi.closedFor = ta.value; paintSlash(ta); return }
4565
+ const it = menu.items[slashUi.active]
4566
+ if (it && !it.off) pickSlash(ta, it.value)
4567
+ }, true)
4568
+ // a click or a tap picks; the textarea keeps focus (and a phone its keyboard). WebKit sends no click
4569
+ // for a tap here, so a tap picks on touchend (a drag that scrolls the list doesn't)
4570
+ function pickSlashOpt(o) {
4571
+ if (!o || o.getAttribute('aria-disabled') === 'true') return false
4572
+ const ta = o.closest('.composer') && o.closest('.composer').querySelector('.composer-input')
4573
+ if (ta) pickSlash(ta, o.dataset.value)
4574
+ return !!ta
4575
+ }
4576
+ let slashTap = null
4577
+ document.addEventListener('mousedown', (ev) => { if (ev.target && ev.target.closest && ev.target.closest('.slash-opt')) ev.preventDefault() })
4578
+ document.addEventListener('click', (ev) => { pickSlashOpt(ev.target && ev.target.closest && ev.target.closest('.slash-opt')) })
4579
+ document.addEventListener('touchstart', (ev) => {
4580
+ const o = ev.target && ev.target.closest && ev.target.closest('.slash-opt')
4581
+ slashTap = o && ev.touches.length === 1 ? { o, x: ev.touches[0].clientX, y: ev.touches[0].clientY } : null
4582
+ }, { passive: true })
4583
+ document.addEventListener('touchend', (ev) => {
4584
+ const t = slashTap
4585
+ slashTap = null
4586
+ const p = ev.changedTouches && ev.changedTouches[0]
4587
+ if (!t || !p || Math.abs(p.clientX - t.x) > 10 || Math.abs(p.clientY - t.y) > 10) return
4588
+ if (pickSlashOpt(t.o)) ev.preventDefault() // no click after it, and the keyboard stays
4589
+ }, { passive: false })
4590
+ function slashAfterRender() {
4591
+ const ta = document.activeElement
4592
+ if (ta && ta.classList && ta.classList.contains('composer-input') && ta.closest('.live-box')) paintSlash(ta, { keep: true })
4593
+ }
4437
4594
  async function liveSendClick(btn) {
4438
4595
  const box = btn.closest('.live-box')
4439
4596
  const ta = box && box.querySelector('.live-text')
@@ -8932,7 +9089,7 @@ document.addEventListener('click', async (ev) => {
8932
9089
  // 0.9.16 the session panel (ui/fs-side.js)
8933
9090
  if (action === 'fs-side') return toggleFsSide()
8934
9091
  if (action === 'fs-rail') return fsRailGo(btn.dataset.go)
8935
- if (action === 'fs-sheet') return toggleFsSheet()
9092
+ if (action === 'fs-sheet') { if (Date.now() - sheetUi.ignoreClick < 500) return; return toggleFsSheet() }
8936
9093
  if (action === 'fs-timeline') { location.hash = '#/timeline?session=' + encodeURIComponent(id); return }
8937
9094
  if (action === 'tab-pin') return togglePin(id)
8938
9095
  // 0.9.17 Walk me through it
package/ui/chip-fade.js CHANGED
@@ -18,7 +18,7 @@ export function paintFade(track) {
18
18
  return s
19
19
  }
20
20
 
21
- const SEL = '.repos .track'
21
+ const SEL = '.repos .track, #tabStrip .tab-scroll' // 0.9.18: the tab strip fades where tabs run off too
22
22
  // Keeps every chip row's fade right: on first paint (any re-render adds new
23
23
  // tracks), on its own scroll, and when its width or its chips' widths change.
24
24
  export function watchChipFades(doc = document) {
package/ui/decisions.js CHANGED
@@ -10,6 +10,15 @@ export const SOURCE_WORDS = { asked: 'You answered', plan: 'Plan approved', corr
10
10
  export const STATUS_SHAPE = { proposed: '◌', accepted: '●', rejected: '✕', superseded: '↻' }
11
11
  export const STATUS_WORD = { proposed: 'Proposed', accepted: 'Accepted', rejected: 'Rejected', superseded: 'Superseded' }
12
12
 
13
+ // 2026-11-05: a status Leftoff set on its own (statusAuto), in words beside the source, and why in the tip.
14
+ export const AUTO_WORDS = { asked: 'automatically', plan: 'automatically', reversed: 'you changed it later' }
15
+ export const AUTO_TIPS = {
16
+ asked: 'Accepted on its own: you picked this answer in the session. Reject keeps it out of new sessions.',
17
+ plan: 'Accepted on its own: you approved this plan in the session. Reject keeps it out of new sessions.',
18
+ reversed: 'Rejected on its own: later in the session you answered the same question differently.',
19
+ }
20
+ const autoOf = (d) => (d && ((d.status === 'accepted' && (d.statusAuto === 'asked' || d.statusAuto === 'plan')) || (d.status === 'rejected' && d.statusAuto === 'reversed')) ? d.statusAuto : null)
21
+ const autoBit = (d, esc) => { const a = autoOf(d); return a ? '<span class="dec-auto" data-tip="' + esc(AUTO_TIPS[a]) + '">' + esc(AUTO_WORDS[a]) + '</span>' : '' }
13
22
  const normD = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
14
23
  const byNewest = (a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || ''))
15
24
 
@@ -61,11 +70,12 @@ export function decidedListHtml(decided, { esc = escD, log = null, repoId = null
61
70
  const rp = (hit && (hit.repo || repoId)) || ''
62
71
  const btn = (act, label, cls) => '<button type="button" class="btn' + cls + '" data-action="dec-act" data-act="' + act + '" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>' + label + '</button>'
63
72
  const acts = !readOnly && hit && hit.status === 'proposed' && may ? '<div class="dec-acts dec-acts-panel">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject', '') +
64
- '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>' : ''
73
+ '<button type="button" class="btn" data-action="dec-edit" data-id="' + esc(hit.id) + '" data-repo="' + esc(rp) + '"' + offAttrs(trusted, esc) + '>Edit</button></div>'
74
+ : !readOnly && hit && may && autoOf(hit) && hit.status === 'accepted' ? '<div class="dec-acts dec-acts-panel dec-acts-quiet">' + btn('reject', 'Reject', '') + '</div>' : ''
65
75
  const why = (hit && hit.why) || d.why, alts = (hit && hit.alternatives && hit.alternatives.length ? hit.alternatives : d.alternatives) || []
66
76
  // a quiet line first (status · id · where it came from), then the decision in words, then why / over
67
77
  const src = '<span class="dec-src">' + esc(SOURCE_WORDS[(hit && hit.source) || d.source] || SOURCE_WORDS.agent) + '</span>'
68
- const head = [st, hit ? '<span class="dec-id">' + esc(hit.id) + '</span>' : '', src].filter(Boolean).join('<span class="dec-dot" aria-hidden="true"> · </span>')
78
+ const head = [st, hit ? '<span class="dec-id">' + esc(hit.id) + '</span>' : '', src, autoBit(hit, esc)].filter(Boolean).join('<span class="dec-dot" aria-hidden="true"> · </span>')
69
79
  return '<li class="dec-row' + (hit ? ' dec-row-' + esc(hit.status) : '') + '"' + (hit && !readOnly ? ' id="decp-' + esc(hit.id) + '"' : '') + '><div class="dec-row-h">' + head + '</div>' +
70
80
  '<div class="dec-text">' + esc(hit && (hit.edited || d.entry) ? hit.text : d.text) + '</div>' +
71
81
  (why ? '<div class="dec-why">' + esc(why) + '</div>' : '') +
@@ -155,13 +165,13 @@ export function decisionItemHtml(d, { esc = escD, byId = new Map(), may = true,
155
165
  const editBtn = (cls) => edit ? '<button type="button" class="' + cls + '" data-action="dec-edit" data-id="' + esc(d.id) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Edit</button>' : ''
156
166
  const link = replaces && !(d.supersedes || []).includes(replaces) ? '<button type="button" class="btn" data-action="dec-link" data-id="' + esc(d.id) + '" data-to="' + esc(replaces) + '" data-repo="' + esc(d.repo) + '"' + offAttrs(trusted, esc) + '>Confirm it replaces ' + esc(replaces) + '</button>' : ''
157
167
  const acts = !may && !link ? '' : !may ? '<div class="dec-acts">' + link + '</div>' : review ? '<div class="dec-acts">' + btn('accept', 'Accept', ' primary') + btn('reject', 'Reject') + editBtn('btn') + link + '</div>'
158
- : '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : editBtn('linkish')) + link + '</div>'
168
+ : '<div class="dec-acts dec-acts-quiet">' + (d.status === 'rejected' ? btn('reopen', 'Review again') : (autoOf(d) && d.status === 'accepted' ? btn('reject', 'Reject') : '') + editBtn('linkish')) + link + '</div>'
159
169
  return '<li class="dec-item dec-' + esc(d.status) + '" id="dec-' + esc(d.id) + '">' + head +
160
170
  '<div class="dec-text">' + esc(d.text) + (d.edited ? ' <span class="faint" data-tip="' + esc('Edited · was: ' + ((d.original && d.original.text) || '')) + '">(edited)</span>' : '') + '</div>' +
161
171
  (d.why ? '<div class="dec-why">' + esc(d.why) + '</div>' : '') +
162
172
  (d.alternatives && d.alternatives.length ? '<div class="dec-alt">Over: ' + d.alternatives.map(esc).join(' · ') + '</div>' : '') +
163
173
  chainBit(d, byId, esc) + sug +
164
- '<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
174
+ '<div class="dec-meta"><span class="dec-src">' + esc(SOURCE_WORDS[d.source] || SOURCE_WORDS.agent) + '</span>' + (autoBit(d, esc) ? ' · ' + autoBit(d, esc) : '') + (sessionBit(d, esc) ? ' · ' + sessionBit(d, esc) : '') + '</div>' + acts + '</li>'
165
175
  }
166
176
 
167
177
  // The storage choice (one per project). Off by default: the log stays in Leftoff's folder.
@@ -195,7 +205,7 @@ export function decisionsPageHtml(v, ui = {}, { esc = escD } = {}) {
195
205
  (multi ? '<p class="faint">' + v.repos.map((r) => esc(r.label)).join(' · ') + '</p>' : '') + '</header>'
196
206
  if (ui.trusted === false) h += '<p class="faint dec-trust"><span aria-hidden="true">◌</span> View only on this device. ' + esc(DEC_TRUST_TIP) + '</p>' // 1h: the reason in words too (a phone shows no tips)
197
207
  if (v && (v.project || sc.kind === 'repo')) h += '<section class="proj-sec">' + storageChoiceHtml(v.project, { esc, may: ui.may !== false, trusted: ui.trusted !== false }) + '</section>'
198
- if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
208
+ if (review.length) h += '<section class="proj-sec dec-review"><h2>To review <span class="count">' + review.length + '</span></h2><p class="faint">Proposed from checkpoints. Questions you answered and plans you approved are accepted on their own. Only accepted decisions reach new sessions, Ask and walkthroughs.</p><ul class="dec-tl">' + review.map((d) => decisionItemHtml(d, { ...opts, review: true })).join('') + '</ul></section>'
199
209
  const ctl = '<div class="dec-ctl"><button type="button" class="sask-opt" data-action="dec-all" aria-pressed="' + !!ui.all + '">' + (ui.all ? 'Showing superseded and rejected' : 'Include superseded and rejected' + (hidden ? ' (' + hidden + ')' : '')) + '</button>' +
200
210
  '<button type="button" class="btn" data-action="dec-copy">Copy as Markdown</button>' + testplanLinkHtml(sc, { esc, cls: 'btn' }) + '</div>'
201
211
  h += '<section class="proj-sec dec-timeline"><h2>Timeline <span class="count">' + rows.length + '</span></h2>' + ctl +
package/ui/fs-side.js CHANGED
@@ -80,3 +80,32 @@ export function fsSideHtml({ id, side = '', foot = '', label = '', away = '', cp
80
80
  return '<aside class="fs-side' + (collapsed ? ' collapsed' : '') + (sheet ? ' sheet-up' : '') + (scrolled ? ' is-scrolled' : '') + '" id="fsSide" data-sid="' + esc(id) + '" aria-label="This session">' +
81
81
  resizeHandleHtml({ width, viewport }, { esc }) + peek + '<div class="fs-side-in">' + side + '</div>' + foot + railHtml({ cpCount, cu, away }, { esc, icon }) + '</aside>'
82
82
  }
83
+
84
+ // 2026-11-06 The phone's sheet follows a finger: drag its peek up to raise it, and drag the raised
85
+ // sheet down to lower it, from the peek or from its content when that's scrolled to the very top.
86
+ // Everywhere else a finger scrolls the sheet's content natively, both ways (a drag never starts
87
+ // once the content has room above it, nor when the finger first heads the way that scrolls it).
88
+ export const SHEET_DRAG_SLOP = 8 // px a finger moves before it's a drag or a scroll
89
+ export const SHEET_DRAG_FLIP = 64 // px of drag that raises or lowers the sheet on letting go
90
+ // up: the sheet is raised; fromPeek: the finger started on the peek; scrollTop: the content's at the touch's start;
91
+ // dy, dx: the finger's travel so far (down and right positive) -> 'drag' | 'scroll' | null (not decided yet)
92
+ export function sheetDragStart({ up = false, fromPeek = false, scrollTop = 0, dy = 0, dx = 0 } = {}) {
93
+ const ay = Math.abs(dy), ax = Math.abs(dx)
94
+ if (ay < SHEET_DRAG_SLOP && ax < SHEET_DRAG_SLOP) return null
95
+ if (ax > ay) return 'scroll' // sideways: a table or code block, never the sheet
96
+ if (!up) return fromPeek && dy < 0 ? 'drag' : 'scroll' // lowered: only the peek, only upwards
97
+ if (dy <= 0) return 'scroll' // raised, finger heading up: the content scrolls down
98
+ return fromPeek || (Number(scrollTop) || 0) <= 0 ? 'drag' : 'scroll'
99
+ }
100
+ // How far the sheet sits from where it rests while dragged (px, down positive; never past either end).
101
+ // travel: the px between raised and lowered.
102
+ export function sheetDragOffset({ up = false, dy = 0, travel = 0 } = {}) {
103
+ const t = Math.max(0, Number(travel) || 0)
104
+ return up ? Math.round(Math.max(0, Math.min(t, dy))) : Math.round(Math.max(-t, Math.min(0, dy)))
105
+ }
106
+ // Letting go: true when the sheet should change (raise or lower), from the travel or a quick flick.
107
+ export function sheetDragFlips({ up = false, dy = 0, ms = 1000 } = {}) {
108
+ const d = up ? dy : -dy
109
+ if (d >= SHEET_DRAG_FLIP) return true
110
+ return d >= 24 && ms > 0 && d / ms > 0.5 // a flick: 0.5 px/ms
111
+ }