@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.17-beta.6",
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
@@ -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.6' // keep in step with .claude-plugin/plugin.json
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.6",
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
- .fs-head { display: block; margin: 0 0 4px; padding: 0; }
4388
- .fs-head-row { display: flex; align-items: flex-start; gap: 2px; }
4389
- .fs-head .s-title { flex: 1 1 auto; min-width: 0; margin: 0; }
4390
- .fs-head h1 { font-size: 17px; line-height: 1.3; margin: 11px 0 4px; overflow-wrap: anywhere; }
4391
- .fs-head .sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 2px 0 6px; }
4392
- .fs-head .more-wrap { flex: none; }
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 || '') + '" data-tip="Rename">' + titleHtml(sessionTitle(s), esc) + '</span></h1>' + retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: retitling.has(s.id) }) + '<button type="button" class="rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" data-tip="Rename">' + icon('pencil') + '</button></div>'
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 head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' + title + more +
2533
- '<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></div>' +
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 = g.text
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
- return '<section class="needs-strip one" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>' }
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
- // 3–4 replies to pick from: a question's own options when it has them
466
- // (AskUserQuestion choices, "…: A, B, or C?", numbered options, "X, or Y?"),
467
- // else generic ones. A pick fills the answer box; you send it.
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
- const inl = listChoices(q.question)
476
- if (inl.length) return inl.slice(0, 4)
477
- return orChoices(q.question).length ? [...orChoices(q.question), GENERIC_REPLIES[2]] : GENERIC_REPLIES
478
- }
479
- if (n.kind !== 'reply') return []
480
- const inl = listChoices(n.question) // a peer's older daemon may still send a list's items
481
- if (inl.length) return inl.slice(0, 4)
482
- const listed = (n.context && n.context.options) || []
483
- if (listed.length >= 2) return listed.slice(0, 4)
484
- const alt = orChoices(n.question)
485
- if (alt.length) return [...alt, GENERIC_REPLIES[2]]
486
- if (/\(\s*y\s*\/\s*n\s*\)|\byes or no\b/i.test(String(n.question || ''))) return ['Yes', 'No']
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").