@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/CHANGELOG.md +7 -0
- package/bin/leftoff-statusline.js +4 -4
- package/bin/leftoff.js +556 -536
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +50 -12
- package/mod/hooks/register.js +11 -3
- package/package.json +1 -1
- package/ui/app.css +45 -2
- package/ui/app.js +161 -4
- package/ui/chip-fade.js +1 -1
- package/ui/decisions.js +15 -5
- package/ui/fs-side.js +29 -0
- package/ui/live-view.js +16 -2
- package/ui/pull.js +14 -1
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/tabs.js +10 -3
- package/ui/viewport-fit.js +14 -0
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.
|
|
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
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
+
}
|