@leftoff/cli 0.9.17-beta.6 → 0.9.17-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/bin/leftoff.js +445 -440
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +6 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +105 -6
- package/ui/app.js +110 -12
- package/ui/live-view.js +1 -0
- package/ui/needs.js +66 -20
- package/ui/prs.js +53 -8
- package/ui/scroll-keep.js +247 -0
- package/ui/walk-pop.js +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.17-beta.
|
|
3
|
+
"version": "0.9.17-beta.7",
|
|
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
|
@@ -683,7 +683,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
683
683
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
684
684
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
685
685
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
686
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom', 'startProject', 'backgroundList', 'doneAt', 'recap', 'reviewed', 'wtLook']
|
|
686
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive', 'headless', 'startedFrom', 'startProject', 'backgroundList', 'doneAt', 'recap', 'reviewed', 'wtLook', 'prShort']
|
|
687
687
|
|
|
688
688
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
689
689
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
|
@@ -1622,8 +1622,13 @@ export const NOTHING_TO_CHECKPOINT = 'Nothing to checkpoint yet — no messages
|
|
|
1622
1622
|
// clip). The board and the daemon use ui/cp-text.js stripChannel; this copy is
|
|
1623
1623
|
// here because the mod ships on its own. A test holds the two to the same answers.
|
|
1624
1624
|
const CH_PAIR = /<channel\b[^>]*>([\s\S]*?)<\/channel\s*>/g
|
|
1625
|
+
// 2026-10-29: Claude Code's wrapper for a prompt our plugin submits between turns
|
|
1626
|
+
// ("The leftoff plugin sent a message:\n<words>\n\nThis is how Claude Code surfaces a prompt …").
|
|
1627
|
+
const PLUGIN_WRAP = /^\s*The leftoff plugin sent a message:\s*\n([\s\S]*?)(?:\n\s*\nThis is how Claude Code surfaces a prompt[\s\S]*)?$/i
|
|
1625
1628
|
export function stripChannel(s) {
|
|
1626
1629
|
let t = String(s == null ? '' : s)
|
|
1630
|
+
const pw = PLUGIN_WRAP.exec(t)
|
|
1631
|
+
if (pw) t = pw[1]
|
|
1627
1632
|
if (!/<\/?channel\b/i.test(t)) return t.trim()
|
|
1628
1633
|
for (let i = 0; i < 8 && CH_PAIR.test(t); i++) { CH_PAIR.lastIndex = 0; t = t.replace(CH_PAIR, '$1') }
|
|
1629
1634
|
CH_PAIR.lastIndex = 0
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.17-beta.
|
|
31
|
+
const MOD_VERSION = '0.9.17-beta.7' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.17-beta.
|
|
3
|
+
"version": "0.9.17-beta.7",
|
|
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
|
@@ -4384,12 +4384,59 @@ html.lo-reordering, html.lo-reordering * { cursor: grabbing !important; user-sel
|
|
|
4384
4384
|
html:not(.live-full) .fs-side { display: none; }
|
|
4385
4385
|
.fs-side-in { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 10px 16px 16px; container: fsside / inline-size; }
|
|
4386
4386
|
.fs-side-in > * { max-width: 100%; }
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
.fs-head
|
|
4391
|
-
.fs-head
|
|
4392
|
-
.fs-head
|
|
4387
|
+
/* 2026-10-28 the panel's head: one row (› hide, the title, then ✎ and ⋯ in one cluster, all one size,
|
|
4388
|
+
centred on the title's FIRST line), the chips on one baseline under it, then the folder line.
|
|
4389
|
+
--fsh-b: the buttons' size (32px with a mouse, 44px on touch); --fsh-lh: one title line. */
|
|
4390
|
+
.fs-head { display: block; margin: 0 0 4px; padding: 0; --fsh-b: 32px; --fsh-lh: 22px; }
|
|
4391
|
+
@media (pointer: coarse) { .fs-head { --fsh-b: 44px; } }
|
|
4392
|
+
.fs-head-row { display: flex; align-items: flex-start; gap: 4px; margin: 2px 0 6px; min-width: 0; }
|
|
4393
|
+
.fs-head .s-title { flex: 1 1 auto; min-width: 0; margin: 0; display: block; }
|
|
4394
|
+
.fs-head h1 { position: relative; font-size: 17px; line-height: var(--fsh-lh); margin: calc((var(--fsh-b) - var(--fsh-lh)) / 2) 0 0; overflow-wrap: anywhere; }
|
|
4395
|
+
.fs-head .s-title-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; text-overflow: ellipsis; }
|
|
4396
|
+
.fs-head .s-title input { margin: calc((var(--fsh-b) - 34px) / 2) 0 0; font-size: 17px; width: 100%; box-sizing: border-box; }
|
|
4397
|
+
.fs-hacts { flex: none; display: flex; align-items: center; gap: 2px; height: var(--fsh-b); }
|
|
4398
|
+
.fs-hacts .more-wrap { flex: none; display: inline-flex; }
|
|
4399
|
+
.fs-head .fs-side-x, .fs-hacts .fs-hact, .fs-hacts .btn.icon-btn.more { flex: none; position: relative; display: inline-flex; align-items: center; justify-content: center; width: var(--fsh-b); height: var(--fsh-b); margin: 0; padding: 0; border: 0; border-radius: 8px; background: none; box-shadow: none; color: var(--text2); cursor: pointer; }
|
|
4400
|
+
.fs-head .fs-side-x .ic, .fs-hacts .fs-hact svg, .fs-hacts .btn.icon-btn.more .ic { width: 16px; height: 16px; margin: 0; }
|
|
4401
|
+
.fs-head .fs-side-x { margin-left: -6px; }
|
|
4402
|
+
@media (hover: hover) { .fs-head .fs-side-x:hover, .fs-hacts .fs-hact:hover, .fs-hacts .btn.icon-btn.more:hover { background: var(--card2); color: var(--text); border-color: transparent; } }
|
|
4403
|
+
.fs-hacts .btn.icon-btn.more[aria-expanded="true"] { background: var(--card2); color: var(--text); }
|
|
4404
|
+
.fs-hacts .fs-hact:focus-visible, .fs-hacts .btn.icon-btn.more:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
4405
|
+
.fs-hacts .retitle.is-busy { color: var(--accent); cursor: progress; }
|
|
4406
|
+
/* their tips hang below, right-aligned (the panel's edge is near) */
|
|
4407
|
+
.fs-side .fs-hacts [data-tip]::after { right: 0; left: auto; top: calc(100% + 4px); transform: none; }
|
|
4408
|
+
@media (hover: hover) and (pointer: fine) { .fs-hacts .fs-hact[data-tip]:hover::after, .fs-hacts .btn.more[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
|
|
4409
|
+
.fs-hacts .fs-hact[data-tip]:focus-visible::after, .fs-hacts .btn.more[data-tip]:focus-visible::after { display: block; }
|
|
4410
|
+
.fs-hacts .btn.more[aria-expanded="true"]::after { display: none !important; }
|
|
4411
|
+
/* the session's PRs: none found says why in one quiet line; Link a PR… focuses the box under it */
|
|
4412
|
+
.sprs .pr-none { position: relative; margin: 0 0 8px; font-size: 13px; line-height: 1.4; color: var(--muted); }
|
|
4413
|
+
.sprs .pr-none-link { cursor: pointer; color: var(--accent); }
|
|
4414
|
+
@media (hover: hover) { .sprs .pr-none-link:hover { text-decoration: underline; } }
|
|
4415
|
+
.fs-side .sprs .pr-none[data-tip]::after { left: 0; right: auto; top: calc(100% + 4px); transform: none; white-space: normal; max-width: 100%; }
|
|
4416
|
+
@media (hover: hover) and (pointer: fine) { .sprs .pr-none[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .6s both; } }
|
|
4417
|
+
/* 2026-10-29 state by shape plus the word; a ref that couldn't be checked is a quiet dashed chip, why in its tip */
|
|
4418
|
+
.pr-state { display: inline-flex; align-items: center; gap: 3px; line-height: 1; }
|
|
4419
|
+
.pr-st-ic { flex: none; width: 13px; height: 13px; position: relative; top: .5px; }
|
|
4420
|
+
.sprs .pr-unlist { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; }
|
|
4421
|
+
.sprs .pr-unchecked { position: relative; display: inline-flex; align-items: center; gap: 5px; height: 26px; line-height: 1; padding: 0 9px; border: 1px dashed var(--line-strong); border-radius: 999px; font-size: 12.5px; color: var(--muted); background: transparent; max-width: 100%; min-width: 0; cursor: default; }
|
|
4422
|
+
.sprs .pr-unchecked .pr-un-ref { font-family: var(--mono); font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; position: relative; top: .5px; }
|
|
4423
|
+
.sprs .pr-unchecked .pr-un-word { flex: none; position: relative; top: .5px; }
|
|
4424
|
+
.sprs .pr-unchecked .pr-st-ic { color: var(--muted); }
|
|
4425
|
+
.sprs .pr-unchecked:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
4426
|
+
@media (pointer: coarse) { .sprs .pr-unchecked { height: 32px; } .sprs .pr-unlist { gap: 8px; } }
|
|
4427
|
+
.fs-side .sprs .pr-unchecked[data-tip]::after, .sprs .pr-unchecked[data-tip]::after { left: 0; right: auto; top: calc(100% + 4px); transform: none; white-space: normal; width: 260px; max-width: 70vw; font-family: var(--sans, inherit); font-weight: 400; line-height: 1.35; z-index: 30; }
|
|
4428
|
+
@media (hover: hover) and (pointer: fine) { .sprs .pr-unchecked[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .35s both; } }
|
|
4429
|
+
.sprs .pr-unchecked[data-tip]:focus-visible::after { display: block; }
|
|
4430
|
+
/* the whole title, when two lines cut it short */
|
|
4431
|
+
.fs-side .fs-head h1[data-tip]::after { left: 0; right: auto; top: calc(100% + 4px); transform: none; white-space: normal; width: max-content; max-width: 100%; font-size: 12px; line-height: 1.35; }
|
|
4432
|
+
@media (hover: hover) and (pointer: fine) { .fs-head h1[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .6s both; } }
|
|
4433
|
+
.fs-head .sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 0 0 6px; }
|
|
4434
|
+
.fs-head .sub > .pill, .fs-head .sub > .chip, .fs-head .sub > .s-tsrc, .fs-head .sub > .head-sg { display: inline-flex; align-items: center; box-sizing: border-box; height: 22px; line-height: 1; margin: 0; padding-top: 1px; padding-bottom: 0; vertical-align: middle; }
|
|
4435
|
+
.fs-head .sub > .s-tsrc { margin-left: 2px; font-size: 12px; color: var(--muted); }
|
|
4436
|
+
.fs-repo { position: relative; }
|
|
4437
|
+
.fs-side .fs-repo[data-tip]::after { left: 0; right: auto; top: calc(100% + 4px); transform: none; white-space: normal; max-width: 100%; overflow-wrap: anywhere; }
|
|
4438
|
+
@media (hover: hover) and (pointer: fine) { .fs-repo[data-tip]:hover::after { display: block; animation: tip-in .12s ease-out .5s both; } }
|
|
4439
|
+
@media (prefers-reduced-motion: reduce) { .fs-hacts [data-tip]::after, .fs-head h1[data-tip]::after, .fs-repo[data-tip]::after { animation: none !important; } }
|
|
4393
4440
|
.fs-where { display: flex; align-items: center; gap: 6px; margin: 2px 0; font-size: 13px; color: var(--text2); min-width: 0; }
|
|
4394
4441
|
.fs-where span:not(.faint) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
4395
4442
|
.fs-where .ic { flex: none; width: 14px; height: 14px; opacity: .7; }
|
|
@@ -5003,3 +5050,55 @@ html.nav-drawer-open, html.nav-drawer-open body { overflow: hidden; }
|
|
|
5003
5050
|
.nd-cur { flex: none; font-size: 12px; font-weight: 500; color: var(--muted); }
|
|
5004
5051
|
.nd-n { flex: none; display: inline-flex; align-items: center; justify-content: center; height: 20px; min-width: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 10px; background: var(--red); color: #fff; font-size: 11.5px; font-weight: 650; line-height: 1; }
|
|
5005
5052
|
.nd-item:focus-visible, .nd-x:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
5053
|
+
|
|
5054
|
+
/* 2026-10-29 Needs you on a phone (beta feedback: "statically positioned so it's hard to read the message
|
|
5055
|
+
before it"). In full screen it sits between the thread and the composer: at most 45% of the height you can
|
|
5056
|
+
see (the visual viewport, so the keyboard counts; 40vh measured the whole layout height on iOS), folds to a
|
|
5057
|
+
one-line bar, and on a phone it leaves the question and Claude's last lines to the thread right above it. */
|
|
5058
|
+
.live-box.full .lf-needs { max-height: min(40vh, calc(var(--lf-h, 100dvh) * .45)); overscroll-behavior: contain; }
|
|
5059
|
+
.needs-strip .needs-fold, .needs-strip .needs-peek { display: none; }
|
|
5060
|
+
.lf-needs .needs-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
5061
|
+
.lf-needs .needs-head h2 { flex: none; }
|
|
5062
|
+
.lf-needs .needs-strip .needs-fold { display: inline-flex; align-items: center; justify-content: center; flex: none; margin-left: auto; width: 44px; height: 44px; margin-top: -10px; margin-bottom: -10px; margin-right: -8px; padding: 0; border: 0; border-radius: 10px; background: transparent; color: var(--text2); cursor: pointer; }
|
|
5063
|
+
.lf-needs .needs-fold .ic { width: 18px; height: 18px; }
|
|
5064
|
+
@media (hover: hover) and (pointer: fine) { .lf-needs .needs-strip .needs-fold { width: 32px; height: 32px; margin-top: -6px; margin-bottom: -6px; margin-right: -4px; } .lf-needs .needs-fold:hover { background: var(--subtle); color: var(--text); } }
|
|
5065
|
+
.lf-needs .needs-fold:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
5066
|
+
.lf-needs .needs-strip.folded .needs-fold .ic { transform: rotate(180deg); }
|
|
5067
|
+
.lf-needs .needs-strip.folded .needs-list { display: none; }
|
|
5068
|
+
.lf-needs .needs-strip.folded .needs-peek { display: block; flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text2); font-size: 13.5px; }
|
|
5069
|
+
.lf-needs .needs-strip.folded { padding-top: 8px; padding-bottom: 8px; }
|
|
5070
|
+
@media (max-width: 600px) {
|
|
5071
|
+
.live-box.full .lf-needs .needs-strip { margin: 6px 0 0; padding: 8px 10px 10px; }
|
|
5072
|
+
.live-box.full .lf-needs .needs-strip.folded { padding-top: 6px; padding-bottom: 6px; }
|
|
5073
|
+
.live-box.full .lf-needs .need-question, .live-box.full .lf-needs .need-ctx, .live-box.full .lf-needs .need-head { display: none; } /* the thread right above shows them */
|
|
5074
|
+
.live-box.full .lf-needs .need { padding: 0; border: 0; background: none; box-shadow: none; }
|
|
5075
|
+
.live-box.full .lf-needs .quick-replies { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); padding-right: 24px; } /* one row that scrolls; the fade says there's more */
|
|
5076
|
+
.live-box.full .lf-needs .quick-replies .qr { flex: none; max-width: 80vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
5077
|
+
.live-box.full .lf-needs .need-actions { display: none; } /* Go opens the Mac's terminal; the bar's >_ is the phone's way there */
|
|
5078
|
+
/* answering in Needs you with the keyboard up: one box, not two */
|
|
5079
|
+
html.lf-kb.ny-typing .live-box.full > .composer { display: none; } /* ny-typing: app.js, kept a beat after the box loses focus so a tap lands where it was aimed */
|
|
5080
|
+
}
|
|
5081
|
+
@media (prefers-reduced-motion: no-preference) { .lf-needs .needs-fold .ic { transition: transform .15s ease; } }
|
|
5082
|
+
|
|
5083
|
+
/* 2026-10-29 the composer's suggestion on a touch screen (iOS has no Tab key; a long one was cut off):
|
|
5084
|
+
a chip above the box. "↳ Use suggestion" + its words (2 lines; ⌄ or a long-press shows all), ✕ dismisses. */
|
|
5085
|
+
.sug-chip { display: flex; align-items: stretch; gap: 2px; margin-top: 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); min-height: 44px; box-sizing: border-box; }
|
|
5086
|
+
.sug-chip button { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
|
5087
|
+
.sug-chip button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
5088
|
+
.sug-use { flex: 1 1 auto; min-width: 0; min-height: 44px; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 5px 4px 5px 12px; text-align: left; border-radius: 11px 0 0 11px; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; }
|
|
5089
|
+
.sug-l { font-size: 11.5px; line-height: 1.2; font-weight: 600; color: var(--accent); }
|
|
5090
|
+
.sug-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; font-size: 14px; line-height: 1.3; color: var(--text2); overflow-wrap: anywhere; }
|
|
5091
|
+
.sug-chip.open .sug-text { display: block; -webkit-line-clamp: unset; line-clamp: unset; overflow: visible; }
|
|
5092
|
+
.sug-more, .sug-x { flex: none; width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; color: var(--text2); }
|
|
5093
|
+
.sug-x { border-radius: 0 11px 11px 0; }
|
|
5094
|
+
.sug-more[hidden] { display: none; }
|
|
5095
|
+
.sug-more .ic, .sug-x .ic { width: 18px; height: 18px; }
|
|
5096
|
+
.sug-chip.open .sug-more .ic { transform: rotate(180deg); }
|
|
5097
|
+
@media (hover: hover) and (pointer: fine) { .sug-use:hover, .sug-more:hover, .sug-x:hover { background: var(--subtle); } }
|
|
5098
|
+
@media (prefers-reduced-motion: no-preference) { .sug-more .ic { transition: transform .15s ease; } }
|
|
5099
|
+
/* desktop: the words cut short, the Tab key always shown; a long one gets ⋯ with all of it in its tip */
|
|
5100
|
+
.ghost { display: flex; align-items: flex-start; gap: 0; }
|
|
5101
|
+
.ghost .ghost-t { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
5102
|
+
.ghost .ghost-k { flex: none; }
|
|
5103
|
+
.ghost .ghost-all { flex: none; margin-left: 4px; padding: 0 5px; border-radius: 5px; pointer-events: auto; cursor: help; line-height: 16px; font-size: 12px; border: 1px solid var(--line-strong); align-self: center; }
|
|
5104
|
+
.ghost .ghost-k { align-self: center; }
|
package/ui/app.js
CHANGED
|
@@ -34,6 +34,7 @@ import { boardEmptyHtml, boardIsBrandNew, timelineEmptyHtml, digestFirstHtml, cp
|
|
|
34
34
|
import { copyIconClick } from './copy-icon.js' // 0.9.15 Copy branch name // 0.9.9 the session page's info cards
|
|
35
35
|
import { mountUpdateBar, takeCarry } from './update-bar.js' // 0.9.8 "Leftoff updated · Refresh"
|
|
36
36
|
import { paintKeyed, forgetPaint, UNITS as PAINT_UNITS } from './keyed-paint.js'
|
|
37
|
+
import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box keeps its place through redraws
|
|
37
38
|
import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
|
|
38
39
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
39
40
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
@@ -275,6 +276,10 @@ document.addEventListener('touchend', (e) => {
|
|
|
275
276
|
applyNav()
|
|
276
277
|
|
|
277
278
|
const $app = document.getElementById('app')
|
|
279
|
+
// 0.9.18 (ui/scroll-keep.js): a redraw anywhere (a live update, a poll) puts every scroll box, the focus
|
|
280
|
+
// and an open <details> back where you had them; the app's own positioning (stick to bottom, a new page) wins
|
|
281
|
+
const scrollKeep = createScrollKeep()
|
|
282
|
+
scrollKeep.start()
|
|
278
283
|
// 0.9.17 Walk me through it: which session shows it, and its view (this page only; the open one survives a reload)
|
|
279
284
|
const wtUi = { sid: null, scope: new Map(), view: 'story', filter: 'all', data: new Map(), loading: new Set(), error: new Map(), cur: new Map(), code: new Set(), groups: new Set(), pending: [], ask: '', node: null, sig: '', restored: false, fellBack: new Set() }
|
|
280
285
|
// 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
|
|
@@ -2523,16 +2528,25 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
|
|
|
2523
2528
|
// 0.9.17 Walk me through it: in place of the chat (the bar and tabs stay); ‹ Chat goes back
|
|
2524
2529
|
if (wtUi.sid === s.id && isFullLive(s.id) && !s.agent) center = liveOpenHtml(s, liveV, '') + '<div class="wt-mount" id="wtMount" data-sid="' + esc(s.id) + '"></div></section>'
|
|
2525
2530
|
// ---- the sidebar
|
|
2531
|
+
// 2026-10-28 one aligned head row: › hide, then the title (two lines at most, the whole
|
|
2532
|
+
// title in its tip), then the actions in one cluster on the first line: ✎ Rename and ⋯
|
|
2533
|
+
// (Regenerate title is the ⋯'s first row; the ↻ beside the chat's own title stays). While a new
|
|
2534
|
+
// title is being made, its spinner sits in the cluster.
|
|
2535
|
+
const fullTitle = sessionTitle(s)
|
|
2526
2536
|
const title = canRename
|
|
2527
|
-
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '"
|
|
2528
|
-
: '<div class="title s-title"><h1>' + titleHtml(sessionTitle(s), esc) + '</h1></div>'
|
|
2537
|
+
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="' + esc('Rename: ' + fullTitle) + '">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
|
|
2538
|
+
: '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
|
|
2529
2539
|
const menu = fsMenuItems(s, mine)
|
|
2540
|
+
const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
|
|
2530
2541
|
const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
|
|
2542
|
+
const acts0 = busyTitle + (canRename ? '<button type="button" class="rename-pen fs-hact" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" data-tip="Rename">' + icon('pencil') + '</button>' : '') + more
|
|
2531
2543
|
const where = [s.repoLabel || s.repoName || (s.repo && s.repo.name) || '', branch || ''].filter(Boolean)
|
|
2532
|
-
const
|
|
2533
|
-
|
|
2544
|
+
const wherePath = (s.repo && (s.repo.worktree || s.repo.root)) || s.cwd || ''
|
|
2545
|
+
const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
|
|
2546
|
+
'<button type="button" class="fs-side-x" data-action="fs-side" aria-expanded="true" aria-controls="fsSide" aria-label="Hide the session panel" data-tip="Hide panel (⌘\\)">' + icon('chevron-right') + '</button>' +
|
|
2547
|
+
title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
|
|
2534
2548
|
'<div class="sub">' + glyphFor(s, { cls: 'head-sg' }) + statusPill(s) + liveBadge(s) + (isDone(s) ? doneChipHtml(s, { icon, esc }) : archivedChipHtml(s)) + projChip(s) + wtChips(s) + (s.titleSource ? '<span class="s-tsrc">' + esc(s.titleSource === 'prompt' ? 'named from the first prompt' : s.titleSource === 'fallback' ? 'named from your prompt' : s.titleSource + ' title') + '</span>' : '') + '</div>' +
|
|
2535
|
-
(where.length ? '<div class="fs-where">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
|
|
2549
|
+
(where.length ? '<div class="fs-where fs-repo" data-tip="' + esc(wherePath ? wherePath + (branch ? ' · ' + branch : '') : where.join(' · ')) + '">' + icon('folder') + '<span>' + esc(where.join(' · ')) + '</span></div>' : '') +
|
|
2536
2550
|
'<div class="fs-where">' + icon('monitor') + '<span>' + esc(mEmoji(s.machine) + ' ' + s.machine) + '</span><span class="faint">· ' + esc(ago(s.lastActivityAt)) + '</span></div>' + '</div>'
|
|
2537
2551
|
// the prominent actions: Catch me up (after time away, "Away 3h · Catch me up"), Checkpoint now
|
|
2538
2552
|
noteOpenAway(s)
|
|
@@ -2608,6 +2622,8 @@ function fsMenuItems(s, mine) {
|
|
|
2608
2622
|
if (reachOf(s) === 'away') return awayMenuItems(s)
|
|
2609
2623
|
const pinned = !!s.pinned
|
|
2610
2624
|
return [
|
|
2625
|
+
// 2026-10-28: Regenerate title is the ⋯'s first row (out of the head row, so ✎ and ⋯ line up; the chat's own ↻ stays)
|
|
2626
|
+
mine && !ownerOffline(s) ? { action: 'title-auto', label: 'Regenerate title', icon: 'refresh-cw', attrs: { 'data-id': s.id, 'data-tip': RETITLE_TIP } } : null,
|
|
2611
2627
|
{ action: 'fs-timeline', label: 'Open in Timeline', icon: 'history', attrs: { 'data-id': s.id } },
|
|
2612
2628
|
{ action: 'resume', label: 'Copy resume command', icon: 'copy', attrs: { 'data-id': s.id } },
|
|
2613
2629
|
s.liveResumeCommand ? { action: 'copy-cmd', label: 'Copy Live resume', icon: 'copy', attrs: { 'data-cmd': s.liveResumeCommand } } : null,
|
|
@@ -2629,17 +2645,63 @@ function paintGhost(sid) {
|
|
|
2629
2645
|
const s = state.sessions.find((x) => x.id === sid)
|
|
2630
2646
|
const g = ghostUi.get(sid)
|
|
2631
2647
|
const show = ghostShows(g, { value: ta.value, idle: ghostIdle(s) }) && !ta.disabled
|
|
2648
|
+
// 2026-10-29 (beta feedback: iOS has no Tab key, and a long suggestion was cut off): on a touch
|
|
2649
|
+
// screen it is a chip above the box instead of grey text in it (paintSugChip)
|
|
2650
|
+
const touch = isTouchUi(window.matchMedia)
|
|
2651
|
+
paintSugChip(box, sid, show && touch ? g : null)
|
|
2632
2652
|
let el = box.querySelector('.ghost')
|
|
2633
|
-
if (!show) { if (el) el.hidden = true; box.classList.remove('has-ghost'); return }
|
|
2653
|
+
if (!show || touch) { if (el) el.hidden = true; box.classList.remove('has-ghost'); if (!show) ta.removeAttribute('aria-description'); return }
|
|
2634
2654
|
if (!el) { el = document.createElement('div'); el.className = 'ghost'; el.setAttribute('aria-hidden', 'true'); box.appendChild(el) }
|
|
2635
2655
|
const cs = getComputedStyle(ta)
|
|
2636
2656
|
Object.assign(el.style, { left: ta.offsetLeft + 'px', top: ta.offsetTop + 'px', width: ta.offsetWidth + 'px', height: ta.offsetHeight + 'px', padding: cs.padding, font: cs.font, lineHeight: cs.lineHeight, inset: 'auto' })
|
|
2637
|
-
el.textContent =
|
|
2657
|
+
el.textContent = ''
|
|
2658
|
+
// the words, then the Tab key (always visible: the words are cut, not the key); a long one gets ⋯ with all of it in its tip
|
|
2659
|
+
const t = document.createElement('span'); t.className = 'ghost-t'; t.textContent = g.text; el.appendChild(t)
|
|
2638
2660
|
const k = document.createElement('span'); k.className = 'ghost-k'; k.textContent = 'Tab'; el.appendChild(k)
|
|
2639
2661
|
el.hidden = false
|
|
2640
2662
|
box.classList.add('has-ghost')
|
|
2663
|
+
if (t.scrollWidth > t.clientWidth + 1) {
|
|
2664
|
+
const all = document.createElement('span'); all.className = 'ghost-all'; all.textContent = '⋯'; all.dataset.tip = g.text
|
|
2665
|
+
el.insertBefore(all, k)
|
|
2666
|
+
}
|
|
2641
2667
|
ta.setAttribute('aria-description', 'Suggested: ' + g.text + '. Press Tab to use it.')
|
|
2642
2668
|
}
|
|
2669
|
+
// The suggestion on a touch screen: a row above the composer. "↳ Use suggestion" and its words
|
|
2670
|
+
// (two lines; ⌄ or a long-press shows all of it) put it in the box to edit; ✕ dismisses it.
|
|
2671
|
+
// 44px targets. Never sent on its own.
|
|
2672
|
+
function paintSugChip(box, sid, g) {
|
|
2673
|
+
const host = box.parentNode
|
|
2674
|
+
let chip = host && host.querySelector(':scope > .sug-chip[data-sid="' + CSS.escape(sid) + '"]')
|
|
2675
|
+
if (!g) { if (chip) chip.remove(); return }
|
|
2676
|
+
if (chip && chip.dataset.text === g.text) return
|
|
2677
|
+
if (chip) chip.remove()
|
|
2678
|
+
chip = document.createElement('div')
|
|
2679
|
+
chip.className = 'sug-chip'; chip.dataset.sid = sid; chip.dataset.text = g.text
|
|
2680
|
+
chip.setAttribute('role', 'group'); chip.setAttribute('aria-label', 'Suggested message')
|
|
2681
|
+
chip.innerHTML = '<button type="button" class="sug-use"><span class="sug-l"><span aria-hidden="true">↳ </span>Use suggestion</span><span class="sug-text"></span></button>' +
|
|
2682
|
+
'<button type="button" class="sug-more" aria-expanded="false" aria-label="Show all of the suggestion" data-tip="Show all">' + icon('chevron-down') + '</button>' +
|
|
2683
|
+
'<button type="button" class="sug-x" aria-label="Dismiss suggestion" data-tip="Dismiss">' + icon('x') + '</button>'
|
|
2684
|
+
chip.querySelector('.sug-text').textContent = g.text
|
|
2685
|
+
host.insertBefore(chip, box)
|
|
2686
|
+
const text = chip.querySelector('.sug-text'), more = chip.querySelector('.sug-more'), use = chip.querySelector('.sug-use')
|
|
2687
|
+
more.hidden = text.scrollHeight <= text.clientHeight + 1
|
|
2688
|
+
const expand = (on) => { chip.classList.toggle('open', on); more.setAttribute('aria-expanded', String(on)); more.setAttribute('aria-label', on ? 'Show less of the suggestion' : 'Show all of the suggestion'); more.dataset.tip = on ? 'Show less' : 'Show all' }
|
|
2689
|
+
more.addEventListener('click', () => expand(!chip.classList.contains('open')))
|
|
2690
|
+
let press = 0, long = false
|
|
2691
|
+
use.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') return; long = false; clearTimeout(press); press = setTimeout(() => { long = true; if (!more.hidden) expand(true) }, 500) })
|
|
2692
|
+
for (const t of ['pointerup', 'pointercancel', 'pointerleave']) use.addEventListener(t, () => clearTimeout(press))
|
|
2693
|
+
use.addEventListener('contextmenu', (e) => e.preventDefault())
|
|
2694
|
+
use.addEventListener('click', (e) => {
|
|
2695
|
+
if (long) { long = false; e.preventDefault(); return } // a long-press shows it, it doesn't use it
|
|
2696
|
+
const ta = box.querySelector('textarea.composer-input'), cur = ghostUi.get(sid)
|
|
2697
|
+
if (!ta || !cur) return
|
|
2698
|
+
ta.value = cur.text
|
|
2699
|
+
cur.cleared = true
|
|
2700
|
+
ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft is kept, the box fits, the ↑ lights
|
|
2701
|
+
paintGhost(sid)
|
|
2702
|
+
})
|
|
2703
|
+
chip.querySelector('.sug-x').addEventListener('click', () => { const cur = ghostUi.get(sid); if (cur) cur.cleared = true; paintGhost(sid) })
|
|
2704
|
+
}
|
|
2643
2705
|
async function loadGhost(s, v) {
|
|
2644
2706
|
if (!s || s.agent || !isFullLive(s.id) || state.mayAnswer === false) return
|
|
2645
2707
|
const last = lastReply(v && !v.error ? v.messages : null)
|
|
@@ -2698,7 +2760,40 @@ const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
|
|
|
2698
2760
|
function sessionNeedsStrip(s) {
|
|
2699
2761
|
// 0.9.14: an asleep Mac's ask isn't live: what it was, muted, and no Allow / Deny
|
|
2700
2762
|
if (ownerOffline(s)) return '<section class="needs-strip one off-was" aria-label="Was waiting on you"><p class="faint">' + icon('wifi-off') + ' Was waiting on you when ' + esc(s.machine) + ' went offline' + (needsLine(s.needsYou) ? ': “' + esc(needsLine(s.needsYou)) + '”' : '') + '. You can answer once it’s back.</p></section>'
|
|
2701
|
-
|
|
2763
|
+
// 2026-10-29 (beta feedback: on a phone the block covered the message you're answering): it folds to a one-line bar
|
|
2764
|
+
// (the question's words) and back; the choice is kept per session through repaints (needsFold)
|
|
2765
|
+
const folded = needsFold.has(s.id)
|
|
2766
|
+
const peek = needsLine(s.needsYou) || ''
|
|
2767
|
+
return '<section class="needs-strip one' + (folded ? ' folded' : '') + '" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2>' + (peek ? '<span class="needs-peek" aria-hidden="' + !folded + '">' + esc(peek) + '</span>' : '') +
|
|
2768
|
+
'<button type="button" class="needs-fold" data-action="needs-fold" data-id="' + esc(s.id) + '" aria-expanded="' + !folded + '" aria-label="' + (folded ? 'Show Needs you' : 'Fold Needs you to a bar') + '" data-tip="' + (folded ? 'Show' : 'Fold to a bar') + '">' + icon('chevron-down') + '</button></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>' }
|
|
2769
|
+
const needsFold = new Set() // session ids whose Needs-you block is folded to its bar (this page only)
|
|
2770
|
+
// Typing a reply in full screen's Needs you with the keyboard up: the Live composer under it steps aside (one box,
|
|
2771
|
+
// not two; app.css). It comes back a beat after the box loses focus, so the layout doesn't move under the tap that took focus away.
|
|
2772
|
+
{
|
|
2773
|
+
let t = 0
|
|
2774
|
+
document.addEventListener('focusin', (e) => { if (e.target && e.target.closest && e.target.closest('.live-box.full .lf-needs textarea')) { clearTimeout(t); document.documentElement.classList.add('ny-typing') } })
|
|
2775
|
+
document.addEventListener('focusout', (e) => { if (e.target && e.target.closest && e.target.closest('.live-box.full .lf-needs textarea')) { clearTimeout(t); t = setTimeout(() => { if (!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.live-box.full .lf-needs textarea'))) document.documentElement.classList.remove('ny-typing') }, 400) } })
|
|
2776
|
+
}
|
|
2777
|
+
function needsFoldClick(btn) {
|
|
2778
|
+
const sid = btn.dataset.id
|
|
2779
|
+
const on = !needsFold.has(sid)
|
|
2780
|
+
if (on) needsFold.add(sid); else needsFold.delete(sid)
|
|
2781
|
+
const strip = btn.closest('.needs-strip')
|
|
2782
|
+
if (strip) strip.classList.toggle('folded', on)
|
|
2783
|
+
btn.setAttribute('aria-expanded', String(!on))
|
|
2784
|
+
btn.setAttribute('aria-label', on ? 'Show Needs you' : 'Fold Needs you to a bar')
|
|
2785
|
+
btn.dataset.tip = on ? 'Show' : 'Fold to a bar'
|
|
2786
|
+
const peek = strip && strip.querySelector('.needs-peek'); if (peek) peek.setAttribute('aria-hidden', String(!on))
|
|
2787
|
+
}
|
|
2788
|
+
// 2026-10-29: the full-screen thread keeps its end in view when the room under it changes (the keyboard,
|
|
2789
|
+
// Needs you folding or growing): a thread that was at its bottom stays there; one you scrolled up is left alone.
|
|
2790
|
+
function pinLogEnd(ol) {
|
|
2791
|
+
if (!ol || ol._pinEnd || typeof ResizeObserver !== 'function') return
|
|
2792
|
+
const atEnd = () => ol.scrollHeight - ol.clientHeight - ol.scrollTop <= 24
|
|
2793
|
+
ol._pinEnd = { at: atEnd() }
|
|
2794
|
+
ol.addEventListener('scroll', () => { ol._pinEnd.at = atEnd() }, { passive: true })
|
|
2795
|
+
new ResizeObserver(() => { if (ol._pinEnd.at && ol.isConnected) ol.scrollTop = ol.scrollHeight }).observe(ol)
|
|
2796
|
+
}
|
|
2702
2797
|
// 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
|
|
2703
2798
|
// bottom); Claude's answer is the session's own (markdown, like the terminal).
|
|
2704
2799
|
const liveKeeper = createLiveKeeper()
|
|
@@ -2964,6 +3059,7 @@ function fullAfterRender(sid) {
|
|
|
2964
3059
|
paintLfTabs()
|
|
2965
3060
|
const ol = box.querySelector('.live-log')
|
|
2966
3061
|
if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
|
|
3062
|
+
pinLogEnd(ol)
|
|
2967
3063
|
} else if (fullUi.back === sid) {
|
|
2968
3064
|
fullUi.back = null
|
|
2969
3065
|
const b = document.getElementById('live')
|
|
@@ -3560,6 +3656,7 @@ async function refreshLive(reason) {
|
|
|
3560
3656
|
const r = liveKeeper.after(box)
|
|
3561
3657
|
hydrateAttThumbs()
|
|
3562
3658
|
if (r) liveJumpButton(r.thread, liveKeeper.unseen)
|
|
3659
|
+
if (box.classList.contains('full')) pinLogEnd(fresh.querySelector('.live-log'))
|
|
3563
3660
|
}
|
|
3564
3661
|
}
|
|
3565
3662
|
const c = sessionCache.get(id); if (c) c.live = v
|
|
@@ -7366,13 +7463,13 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
|
|
|
7366
7463
|
tip.classList.add('on')
|
|
7367
7464
|
}, delay)
|
|
7368
7465
|
}
|
|
7369
|
-
const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
|
|
7466
|
+
const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip], #tabPager [data-tip], #lfTabs [data-tip], .sg[data-tip], .lo-act-x[data-tip], .repos.projects [data-tip], .lo-sheet-x[data-tip], .proj-edit[data-tip], .feed-why[data-tip], .term-menu [data-tip], #wt [data-tip], .ghost-all[data-tip], .sug-chip [data-tip], .lf-needs .needs-fold[data-tip]') // 0.9.18 everything in the walkthrough (its header's icon buttons, the slide nav, file status glyphs); before: the file map's eye, step number and long folders; a step header's files and (cut short) title the file map's eye, step number and long folders; a step header's files and (cut short) title // 0.9.12: the project chips, the sheet's ×, a project page's Edit
|
|
7370
7467
|
document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
|
|
7371
7468
|
document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
|
|
7372
7469
|
document.addEventListener('focusin', (e) => { if (wtRefocusing) return; const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
|
|
7373
7470
|
document.addEventListener('focusout', (e) => { if (tipped(e) === cur) hide() })
|
|
7374
7471
|
for (const ev of ['pointerdown', 'hashchange', 'resize']) window.addEventListener(ev, hide, true)
|
|
7375
|
-
window.addEventListener('scroll', () => { if (performance.now() >= wtQuietUntil) hide() }, true) // 0.9.18 not for a redraw putting the walkthrough's scroll back
|
|
7472
|
+
window.addEventListener('scroll', () => { if (performance.now() >= wtQuietUntil && !scrollKeep.quiet()) hide() }, true) // 0.9.18 not for a redraw putting the walkthrough's scroll back
|
|
7376
7473
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) hide() })
|
|
7377
7474
|
// 0.9.18 touch: a long press shows the tip (the tap it ends with doesn't follow the link)
|
|
7378
7475
|
let lp = 0, lpd = false
|
|
@@ -7390,7 +7487,7 @@ function wtStuck(sc) {
|
|
|
7390
7487
|
}
|
|
7391
7488
|
document.addEventListener('scroll', (e) => { const t = e.target; if (t && t.classList && t.classList.contains('wt-scroll')) wtStuck(t) }, true)
|
|
7392
7489
|
// 0.9.18 Walk me through it and Catch me up explain themselves (ui/walk-pop.js): hover, focus, long-press, the (i)
|
|
7393
|
-
loPop = initPops({ esc, icon, contentFor: (kind, sid, el) => popContent(kind, { size: kind === 'wt' ? (wtSize.get(sid) || {}).data || null : null, away: !!(el && el.dataset.away) }) })
|
|
7490
|
+
loPop = initPops({ esc, icon, quiet: () => scrollKeep.quiet(), contentFor: (kind, sid, el) => popContent(kind, { size: kind === 'wt' ? (wtSize.get(sid) || {}).data || null : null, away: !!(el && el.dataset.away) }) })
|
|
7394
7491
|
// Working in a session keeps its tab: a Live message, typing there, answering Claude there
|
|
7395
7492
|
const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
|
|
7396
7493
|
for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
|
|
@@ -8647,6 +8744,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8647
8744
|
if (action === 'live-earlier') return liveEarlierClick(btn)
|
|
8648
8745
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
8649
8746
|
if (action === 'need-more') return needMoreClick(btn)
|
|
8747
|
+
if (action === 'needs-fold') return needsFoldClick(btn)
|
|
8650
8748
|
if (action === 'need-dismiss') return needDismissClick(btn)
|
|
8651
8749
|
// ---- To do and Files (0.7.0)
|
|
8652
8750
|
if (action === 'todo-restore' || action === 'todo-restore-all') {
|
|
@@ -8749,7 +8847,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
8749
8847
|
if (action === 'title-auto') toast('Naming it from the latest messages…', 20000)
|
|
8750
8848
|
await runRetitle(id, { api, busy: retitling, render, toast })
|
|
8751
8849
|
} else if (action === 'rename') {
|
|
8752
|
-
const box = btn.closest('.title')
|
|
8850
|
+
const box = btn.closest('.title') || (btn.closest('.fs-head') && btn.closest('.fs-head').querySelector('.title')) // the panel's ✎ sits in the head's action cluster
|
|
8753
8851
|
if (!box || box.querySelector('input')) return
|
|
8754
8852
|
const current = s ? s.title || '' : btn.dataset.title || ''
|
|
8755
8853
|
box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title" maxlength="120" enterkeyhint="done">' +
|
package/ui/live-view.js
CHANGED
|
@@ -161,6 +161,7 @@ export function senderLabel(m) {
|
|
|
161
161
|
if (m.from === 'claude') return m.agentName || 'Claude' // 0.9.17: an ended Codex session's answers say Codex
|
|
162
162
|
if (m.from === 'event') return 'Event' + (m.source ? ' · ' + m.source : '')
|
|
163
163
|
if (m.from === 'terminal') return 'You · terminal' // typed in Claude Code itself (from its transcript)
|
|
164
|
+
if (m.origin === 'needs') return 'You · from Needs you' // 2026-10-29: a reply sent from a Needs-you card (its own words, not the transcript's form)
|
|
164
165
|
if (m.history && !m.by) return 'You · board'
|
|
165
166
|
const by = m.by || {}
|
|
166
167
|
let device = by.device || null
|
package/ui/needs.js
CHANGED
|
@@ -434,9 +434,6 @@ export function prCardHtml(pr) {
|
|
|
434
434
|
: '<div class="need-pr">' + inner + '</div>'
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
-
export const GENERIC_REPLIES = ['Yes, go ahead', 'No', 'Show me the diff first']
|
|
438
|
-
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1)
|
|
439
|
-
|
|
440
437
|
// "Should I also add Google sign-in, or keep it email-only?" -> the two choices.
|
|
441
438
|
export function orChoices(question) {
|
|
442
439
|
const q = String(question || '').replace(/\s+/g, ' ').trim()
|
|
@@ -462,29 +459,78 @@ export function listChoices(question) {
|
|
|
462
459
|
return items.map(cap)
|
|
463
460
|
}
|
|
464
461
|
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
462
|
+
// 2026-10-29 (beta feedback: "a lot of the time the suggestions on Needs you are
|
|
463
|
+
// bad"): a suggestion is either one of the question's own choices, word for word,
|
|
464
|
+
// or a plain answer to a yes/no question. Anything else gets no suggestions: the
|
|
465
|
+
// old fallback ("Yes, go ahead" / "No" / "Show me the diff first") showed up under
|
|
466
|
+
// questions that need a specific answer ("What should the timeout be?"), and
|
|
467
|
+
// "Show me the diff first" was added to every either/or question.
|
|
468
|
+
export const GENERIC_REPLIES = ['Yes, go ahead', 'No'] // a yes/no question's answers (the name is kept for older imports)
|
|
469
|
+
export const YES_NO = GENERIC_REPLIES
|
|
470
|
+
export const REPLY_MAX_CHARS = 100 // a suggestion longer than this isn't a quick reply
|
|
471
|
+
export const REPLY_MAX = 6 // at most this many (a numbered menu can have six)
|
|
472
|
+
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1)
|
|
473
|
+
|
|
474
|
+
// The sentence that asks: the last one ending in "?" (markup off, one space).
|
|
475
|
+
export function askingSentence(question) {
|
|
476
|
+
const parts = String(question || '').replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s+(?=\S)/)
|
|
477
|
+
return (parts.filter((p) => /\?\s*$/.test(p)).pop() || '').trim()
|
|
478
|
+
}
|
|
479
|
+
// What kind of answer a question wants: 'yesno' (Should I…? Want me to…? Does
|
|
480
|
+
// this plan look good? Ready to merge? (y/n)), 'open' (What / Which / How… with
|
|
481
|
+
// no choices to pick from), or 'unknown'. Either/or questions are neither: their
|
|
482
|
+
// choices are the answers (orChoices / listChoices).
|
|
483
|
+
const OPEN_RE = /^(?:so,?\s+|and\s+|also,?\s+|ok(?:ay)?,?\s+)?(?:what|which|how|where|when|who|whom|whose|why|any\s+(?:idea|preference|thoughts)|anything\s+else|is\s+there\s+anything)\b/i
|
|
484
|
+
const YESNO_START = /^(?:should|shall|do|does|did|is|are|was|were|can|could|may|might|would|will|want|ready|ok(?:ay)?|sound|sounds|look|looks|happy|good|proceed|continue|go\s+ahead|all\s+good|fine|alright|cool|safe|mind\s+if)\b/i
|
|
485
|
+
const YESNO_ANY = /\b(?:want me to|should i|shall i|ok(?:ay)? (?:if i|to)|good to go|go ahead|look(?:s)? good|sound(?:s)? good|look(?:s)? right|approve|proceed|ready (?:to|for)|(?:do|would) you like me to)\b/i
|
|
486
|
+
export function questionShape(question) {
|
|
487
|
+
const raw = String(question || '')
|
|
488
|
+
if (/\(\s*y\s*\/\s*n\s*\)|\byes or no\b|\[y\/n\]/i.test(raw)) return 'yesno'
|
|
489
|
+
const q = askingSentence(raw)
|
|
490
|
+
if (!q) return 'unknown'
|
|
491
|
+
// the clause that asks ("If the tests pass, want me to push?" -> "want me to push?")
|
|
492
|
+
const clause = q.split(/,\s+|;\s+|\s[—–-]\s/).pop().trim()
|
|
493
|
+
if (OPEN_RE.test(q) || OPEN_RE.test(clause)) return 'open'
|
|
494
|
+
if (/\bor\b/i.test(clause)) return 'unknown' // either/or: yes isn't an answer
|
|
495
|
+
if (YESNO_START.test(q) || YESNO_START.test(clause) || YESNO_ANY.test(clause)) return 'yesno'
|
|
496
|
+
return 'unknown'
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Kept to quick-reply size, duplicates out, at most REPLY_MAX.
|
|
500
|
+
function tidyReplies(list) {
|
|
501
|
+
const out = []
|
|
502
|
+
for (const t of list || []) {
|
|
503
|
+
const v = String(t == null ? '' : t).replace(/\s+/g, ' ').trim()
|
|
504
|
+
if (!v || v.length > REPLY_MAX_CHARS || out.includes(v)) continue
|
|
505
|
+
out.push(v)
|
|
506
|
+
}
|
|
507
|
+
return out.slice(0, REPLY_MAX)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// The suggestions for a waiting question. A question's own choices first, word
|
|
511
|
+
// for word (AskUserQuestion's options, "…: A, B, or C?", a numbered menu the
|
|
512
|
+
// question follows, "X, or Y?"); then Yes / No for a yes/no question; else none.
|
|
513
|
+
// A pick fills the answer box; you send it.
|
|
468
514
|
export function quickReplies(n) {
|
|
469
515
|
if (!n) return []
|
|
516
|
+
let question = ''
|
|
470
517
|
if (n.kind === 'question' && n.ask) {
|
|
471
518
|
const qs = n.ask.questions || []
|
|
472
519
|
if (qs.length !== 1) return []
|
|
473
520
|
const q = qs[0]
|
|
474
|
-
if (q.options && q.options.length) return q.multiSelect ? [] : q.options.map((o) => o.label).slice(0, 4)
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (
|
|
486
|
-
|
|
487
|
-
return GENERIC_REPLIES
|
|
521
|
+
if (q.options && q.options.length) return q.multiSelect ? [] : tidyReplies(q.options.map((o) => o.label)).slice(0, 4)
|
|
522
|
+
question = q.question
|
|
523
|
+
} else if (n.kind === 'reply') {
|
|
524
|
+
question = n.question
|
|
525
|
+
} else return []
|
|
526
|
+
const inl = listChoices(question) // the question's own list (a peer's older daemon may still send a list's items)
|
|
527
|
+
if (inl.length) return tidyReplies(inl)
|
|
528
|
+
const listed = n.kind === 'reply' ? ((n.context && n.context.options) || []) : []
|
|
529
|
+
if (listed.length >= 2) return tidyReplies(listed)
|
|
530
|
+
const alt = orChoices(question)
|
|
531
|
+
if (alt.length) return tidyReplies(alt)
|
|
532
|
+
if (questionShape(question) === 'yesno') return /\(\s*y\s*\/\s*n\s*\)|\[y\/n\]/i.test(String(question || '')) ? ['Yes', 'No'] : YES_NO.slice()
|
|
533
|
+
return []
|
|
488
534
|
}
|
|
489
535
|
|
|
490
536
|
// The draft a card's quick replies fill (the Reply box, or a question's "Other").
|