@leftoff/cli 0.9.17-beta.6 → 0.9.17-beta.8

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.8",
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.8' // 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.8",
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,85 @@ 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; }
5105
+
5106
+ /* 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board; state by shape + word */
5107
+ .hostedby { min-width: 0; }
5108
+ .hosts { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
5109
+ .host-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; min-height: 44px; box-sizing: border-box; }
5110
+ .host-row + .host-row { border-top: 1px solid var(--line); }
5111
+ .host-row.is-host { background: var(--bg2); }
5112
+ .host-shape { width: 16px; text-align: center; flex: none; font-size: 14px; line-height: 1; color: var(--text2); }
5113
+ .host-row.on .host-shape { color: var(--text); }
5114
+ .host-main { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; }
5115
+ .host-sub { flex-basis: 100%; font-size: 12.5px; }
5116
+ .host-chip { font-weight: 600; }
5117
+ .pref-chip { border-style: dashed; }
5118
+ .host-make { flex: none; min-height: 44px; }
5119
+ .host-pref { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; }
5120
+ .hostedby > .hint { margin-top: 8px; }
5121
+ .host-pref-label { font-size: 12.5px; color: var(--text2); margin-right: 4px; }
5122
+ .hp-opt { border: 1px solid var(--line); background: none; color: var(--text2); border-radius: 8px; padding: 0 12px; min-height: 44px; font: inherit; font-size: 13px; cursor: pointer; }
5123
+ .hp-opt.on { border-color: var(--accent); color: var(--text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent); }
5124
+ .hp-opt.on::before { content: '✓ '; }
5125
+ .hp-opt:disabled { opacity: .55; cursor: default; }
5126
+ .hp-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
5127
+ @media (pointer: fine) { .host-row { min-height: 36px; padding: 6px 12px; } .host-make, .hp-opt { min-height: 30px; } }
5128
+ .relay-hosts { list-style: none; margin: 14px 0 0; padding: 0; text-align: left; }
5129
+ .relay-host { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 8px 0; min-height: 44px; box-sizing: border-box; }
5130
+ .relay-host + .relay-host { border-top: 1px solid var(--line); }
5131
+ .relay-host-shape { width: 16px; text-align: center; flex: none; }
5132
+ .relay-host-word { color: var(--text2); font-size: 13px; flex: 1 0 auto; }
5133
+ .relay-use { min-height: 44px; margin-left: auto; }
5134
+ @media (pointer: fine) { .relay-host { min-height: 36px; } .relay-use { min-height: 30px; } }
package/ui/app.js CHANGED
@@ -34,6 +34,8 @@ 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 { hostedByHtml } from './hosted-by.js' // 0.9.18 Settings → Use from anywhere → Hosted by
38
+ import { createScrollKeep } from './scroll-keep.js' // 0.9.18 every scroll box keeps its place through redraws
37
39
  import { needsLine, needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml, askDocked, askDockHtml, askSlimHtml, askRows, askBottom } from './needs.js'
38
40
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
39
41
  import { mdInline, renderSection } from './changelog-md.js'
@@ -275,6 +277,10 @@ document.addEventListener('touchend', (e) => {
275
277
  applyNav()
276
278
 
277
279
  const $app = document.getElementById('app')
280
+ // 0.9.18 (ui/scroll-keep.js): a redraw anywhere (a live update, a poll) puts every scroll box, the focus
281
+ // and an open <details> back where you had them; the app's own positioning (stick to bottom, a new page) wins
282
+ const scrollKeep = createScrollKeep()
283
+ scrollKeep.start()
278
284
  // 0.9.17 Walk me through it: which session shows it, and its view (this page only; the open one survives a reload)
279
285
  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
286
  // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
@@ -2376,6 +2382,10 @@ async function viewSession(id) {
2376
2382
  // 0.9.15 (ui/back-line.js): how long you'd been away when this visit first opened
2377
2383
  // each session, read before caughtUp folds its card; a new visit starts over.
2378
2384
  const openAway = new Map()
2385
+ // 2026-10-29 (Joel: a 30-minute-old session said "Away 12h"): a session that started
2386
+ // while you were away reads at least "day" depth (ui/away.js), but its label says
2387
+ // how long it has really gone unseen: never more than the session's own age.
2388
+ function awayShownMs(s, ms) { const st = Date.parse((s && s.startedAt) || ''); return Number.isFinite(st) && ms != null ? Math.max(60000, Math.min(ms, Date.now() - st)) : ms }
2379
2389
  function noteOpenAway(s) { if (s && !openAway.has(s.id)) openAway.set(s.id, presence.forSession(s).ms) }
2380
2390
  function backDismissed(id) { try { return sessionStorage.getItem(BACK_PREFIX + id) } catch { return null } }
2381
2391
  function backHtml(s) { noteOpenAway(s); return backLineHtml(s, backLine(s, { awayMs: openAway.get(s.id), dismissed: backDismissed(s.id) }), { esc, icon, ago }) }
@@ -2523,16 +2533,25 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2523
2533
  // 0.9.17 Walk me through it: in place of the chat (the bar and tabs stay); ‹ Chat goes back
2524
2534
  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
2535
  // ---- the sidebar
2536
+ // 2026-10-28 one aligned head row: › hide, then the title (two lines at most, the whole
2537
+ // title in its tip), then the actions in one cluster on the first line: ✎ Rename and ⋯
2538
+ // (Regenerate title is the ⋯'s first row; the ↻ beside the chat's own title stays). While a new
2539
+ // title is being made, its spinner sits in the cluster.
2540
+ const fullTitle = sessionTitle(s)
2526
2541
  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>'
2542
+ ? '<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>'
2543
+ : '<div class="title s-title"><h1 data-tip="' + esc(fullTitle) + '"><span class="s-title-text">' + titleHtml(sessionTitle(s), esc) + '</span></h1></div>'
2529
2544
  const menu = fsMenuItems(s, mine)
2545
+ const busyTitle = canRename && retitling.has(s.id) ? retitleButtonHtml(s, { esc, icon, reach: reachOf(s), busy: true, cls: 'fs-hact' }) : ''
2530
2546
  const more = menu ? '<span class="more-wrap s-more">' + moreMenu(s, menu, { project: false, title: s.title || 'This session', tip: true }) + '</span>' : ''
2547
+ 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
2548
  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>' +
2549
+ const wherePath = (s.repo && (s.repo.worktree || s.repo.root)) || s.cwd || ''
2550
+ const head = '<div class="pagehead s-head fs-head" data-sid="' + esc(s.id) + '"><div class="fs-head-row">' +
2551
+ '<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>' +
2552
+ title + (acts0 ? '<div class="fs-hacts">' + acts0 + '</div>' : '') + '</div>' +
2534
2553
  '<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>' : '') +
2554
+ (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
2555
  '<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
2556
  // the prominent actions: Catch me up (after time away, "Away 3h · Catch me up"), Checkpoint now
2538
2557
  noteOpenAway(s)
@@ -2541,7 +2560,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2541
2560
  const cu = catchupButtonHtml(s, { esc, icon, busy: !!scuOf(s.id).loading })
2542
2561
  // 0.9.17: after time away the label is "Away 7h · Catch me up" on one line where the panel has room,
2543
2562
  // else "Catch me up" with "Away 7h" as a small line under it (app.css, the fsside container); never clipped
2544
- const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayMs))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
2563
+ const cuBtn = cu ? cu.replace('class="btn scu-btn"', 'class="btn ' + (away ? 'primary ' : '') + 'scu-btn fs-cu" data-pop="cu"' + (away ? ' data-away="1"' : '')).replace('Catch me up</button>', '<span class="fs-cu-l">' + (away ? '<span class="fs-away">' + esc(shortAway(awayWords(awayShownMs(s, awayMs)))) + '<span class="fs-away-dot"> · </span></span>' : '') + '<span class="fs-cu-v">Catch me up</span></span></button>') : ''
2545
2564
  const wtOn = wtUi.sid === s.id
2546
2565
  const wtBtn = s.agent ? '' : walkButtonHtml(s, { esc, icon, on: wtOn, size: wtSizeOf(s.id) }) // 0.9.17; 0.9.18: a diff glyph, the size under it, a popover (ui/walk-pop.js)
2547
2566
  // 0.9.17: Catch me up and Walk me through it side by side; Checkpoint now under them
@@ -2583,7 +2602,7 @@ function sessionPageHtml(s, { spv = null, live: liveV = null, lists = null } = {
2583
2602
  side += answeredList(s)
2584
2603
  const foot = '<div class="fs-foot"><button type="button" class="btn fs-term" data-term-toggle aria-label="Terminal" data-tip="Terminal (⌃`)">' + icon('terminal') + 'Terminal</button>' + termTabButtonHtml(s) + '</div>' // Open terminal (a window on the Mac): the head's ⋯
2585
2604
  const label = fsPeekWords(s)
2586
- return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayMs)) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
2605
+ return center + fsSideHtml({ id: s.id, side, foot, label, away: away ? shortAway(awayWords(awayShownMs(s, awayMs))) : '', cpCount: s.checkpointCount || 0, cu: !!cu, wt: !s.agent, look: s.lookCount || 0, collapsed: fsSide.collapsed(), sheet: fsSheetUi.open === s.id, width: fsWidth.shown(fsViewport()), viewport: fsViewport() }, { esc, icon })
2587
2606
  }
2588
2607
  // The middle for a session with no Live thread: the same full-screen frame and bar, and what it can say there.
2589
2608
  // "Away 3 hours" → "Away 3h", so Catch me up and Checkpoint now share one row
@@ -2608,6 +2627,8 @@ function fsMenuItems(s, mine) {
2608
2627
  if (reachOf(s) === 'away') return awayMenuItems(s)
2609
2628
  const pinned = !!s.pinned
2610
2629
  return [
2630
+ // 2026-10-28: Regenerate title is the ⋯'s first row (out of the head row, so ✎ and ⋯ line up; the chat's own ↻ stays)
2631
+ mine && !ownerOffline(s) ? { action: 'title-auto', label: 'Regenerate title', icon: 'refresh-cw', attrs: { 'data-id': s.id, 'data-tip': RETITLE_TIP } } : null,
2611
2632
  { action: 'fs-timeline', label: 'Open in Timeline', icon: 'history', attrs: { 'data-id': s.id } },
2612
2633
  { action: 'resume', label: 'Copy resume command', icon: 'copy', attrs: { 'data-id': s.id } },
2613
2634
  s.liveResumeCommand ? { action: 'copy-cmd', label: 'Copy Live resume', icon: 'copy', attrs: { 'data-cmd': s.liveResumeCommand } } : null,
@@ -2629,17 +2650,63 @@ function paintGhost(sid) {
2629
2650
  const s = state.sessions.find((x) => x.id === sid)
2630
2651
  const g = ghostUi.get(sid)
2631
2652
  const show = ghostShows(g, { value: ta.value, idle: ghostIdle(s) }) && !ta.disabled
2653
+ // 2026-10-29 (beta feedback: iOS has no Tab key, and a long suggestion was cut off): on a touch
2654
+ // screen it is a chip above the box instead of grey text in it (paintSugChip)
2655
+ const touch = isTouchUi(window.matchMedia)
2656
+ paintSugChip(box, sid, show && touch ? g : null)
2632
2657
  let el = box.querySelector('.ghost')
2633
- if (!show) { if (el) el.hidden = true; box.classList.remove('has-ghost'); return }
2658
+ if (!show || touch) { if (el) el.hidden = true; box.classList.remove('has-ghost'); if (!show) ta.removeAttribute('aria-description'); return }
2634
2659
  if (!el) { el = document.createElement('div'); el.className = 'ghost'; el.setAttribute('aria-hidden', 'true'); box.appendChild(el) }
2635
2660
  const cs = getComputedStyle(ta)
2636
2661
  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
2662
+ el.textContent = ''
2663
+ // 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
2664
+ const t = document.createElement('span'); t.className = 'ghost-t'; t.textContent = g.text; el.appendChild(t)
2638
2665
  const k = document.createElement('span'); k.className = 'ghost-k'; k.textContent = 'Tab'; el.appendChild(k)
2639
2666
  el.hidden = false
2640
2667
  box.classList.add('has-ghost')
2668
+ if (t.scrollWidth > t.clientWidth + 1) {
2669
+ const all = document.createElement('span'); all.className = 'ghost-all'; all.textContent = '⋯'; all.dataset.tip = g.text
2670
+ el.insertBefore(all, k)
2671
+ }
2641
2672
  ta.setAttribute('aria-description', 'Suggested: ' + g.text + '. Press Tab to use it.')
2642
2673
  }
2674
+ // The suggestion on a touch screen: a row above the composer. "↳ Use suggestion" and its words
2675
+ // (two lines; ⌄ or a long-press shows all of it) put it in the box to edit; ✕ dismisses it.
2676
+ // 44px targets. Never sent on its own.
2677
+ function paintSugChip(box, sid, g) {
2678
+ const host = box.parentNode
2679
+ let chip = host && host.querySelector(':scope > .sug-chip[data-sid="' + CSS.escape(sid) + '"]')
2680
+ if (!g) { if (chip) chip.remove(); return }
2681
+ if (chip && chip.dataset.text === g.text) return
2682
+ if (chip) chip.remove()
2683
+ chip = document.createElement('div')
2684
+ chip.className = 'sug-chip'; chip.dataset.sid = sid; chip.dataset.text = g.text
2685
+ chip.setAttribute('role', 'group'); chip.setAttribute('aria-label', 'Suggested message')
2686
+ 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>' +
2687
+ '<button type="button" class="sug-more" aria-expanded="false" aria-label="Show all of the suggestion" data-tip="Show all">' + icon('chevron-down') + '</button>' +
2688
+ '<button type="button" class="sug-x" aria-label="Dismiss suggestion" data-tip="Dismiss">' + icon('x') + '</button>'
2689
+ chip.querySelector('.sug-text').textContent = g.text
2690
+ host.insertBefore(chip, box)
2691
+ const text = chip.querySelector('.sug-text'), more = chip.querySelector('.sug-more'), use = chip.querySelector('.sug-use')
2692
+ more.hidden = text.scrollHeight <= text.clientHeight + 1
2693
+ 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' }
2694
+ more.addEventListener('click', () => expand(!chip.classList.contains('open')))
2695
+ let press = 0, long = false
2696
+ use.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') return; long = false; clearTimeout(press); press = setTimeout(() => { long = true; if (!more.hidden) expand(true) }, 500) })
2697
+ for (const t of ['pointerup', 'pointercancel', 'pointerleave']) use.addEventListener(t, () => clearTimeout(press))
2698
+ use.addEventListener('contextmenu', (e) => e.preventDefault())
2699
+ use.addEventListener('click', (e) => {
2700
+ if (long) { long = false; e.preventDefault(); return } // a long-press shows it, it doesn't use it
2701
+ const ta = box.querySelector('textarea.composer-input'), cur = ghostUi.get(sid)
2702
+ if (!ta || !cur) return
2703
+ ta.value = cur.text
2704
+ cur.cleared = true
2705
+ ta.dispatchEvent(new Event('input', { bubbles: true })) // the draft is kept, the box fits, the ↑ lights
2706
+ paintGhost(sid)
2707
+ })
2708
+ chip.querySelector('.sug-x').addEventListener('click', () => { const cur = ghostUi.get(sid); if (cur) cur.cleared = true; paintGhost(sid) })
2709
+ }
2643
2710
  async function loadGhost(s, v) {
2644
2711
  if (!s || s.agent || !isFullLive(s.id) || state.mayAnswer === false) return
2645
2712
  const last = lastReply(v && !v.error ? v.messages : null)
@@ -2698,7 +2765,40 @@ const isLive = (s) => !!(s && s.liveLink && s.liveLink.on)
2698
2765
  function sessionNeedsStrip(s) {
2699
2766
  // 0.9.14: an asleep Mac's ask isn't live: what it was, muted, and no Allow / Deny
2700
2767
  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>' }
2768
+ // 2026-10-29 (beta feedback: on a phone the block covered the message you're answering): it folds to a one-line bar
2769
+ // (the question's words) and back; the choice is kept per session through repaints (needsFold)
2770
+ const folded = needsFold.has(s.id)
2771
+ const peek = needsLine(s.needsYou) || ''
2772
+ 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>' : '') +
2773
+ '<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>' }
2774
+ const needsFold = new Set() // session ids whose Needs-you block is folded to its bar (this page only)
2775
+ // Typing a reply in full screen's Needs you with the keyboard up: the Live composer under it steps aside (one box,
2776
+ // 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.
2777
+ {
2778
+ let t = 0
2779
+ 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') } })
2780
+ 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) } })
2781
+ }
2782
+ function needsFoldClick(btn) {
2783
+ const sid = btn.dataset.id
2784
+ const on = !needsFold.has(sid)
2785
+ if (on) needsFold.add(sid); else needsFold.delete(sid)
2786
+ const strip = btn.closest('.needs-strip')
2787
+ if (strip) strip.classList.toggle('folded', on)
2788
+ btn.setAttribute('aria-expanded', String(!on))
2789
+ btn.setAttribute('aria-label', on ? 'Show Needs you' : 'Fold Needs you to a bar')
2790
+ btn.dataset.tip = on ? 'Show' : 'Fold to a bar'
2791
+ const peek = strip && strip.querySelector('.needs-peek'); if (peek) peek.setAttribute('aria-hidden', String(!on))
2792
+ }
2793
+ // 2026-10-29: the full-screen thread keeps its end in view when the room under it changes (the keyboard,
2794
+ // Needs you folding or growing): a thread that was at its bottom stays there; one you scrolled up is left alone.
2795
+ function pinLogEnd(ol) {
2796
+ if (!ol || ol._pinEnd || typeof ResizeObserver !== 'function') return
2797
+ const atEnd = () => ol.scrollHeight - ol.clientHeight - ol.scrollTop <= 24
2798
+ ol._pinEnd = { at: atEnd() }
2799
+ ol.addEventListener('scroll', () => { ol._pinEnd.at = atEnd() }, { passive: true })
2800
+ new ResizeObserver(() => { if (ol._pinEnd.at && ol.isConnected) ol.scrollTop = ol.scrollHeight }).observe(ol)
2801
+ }
2702
2802
  // 0.9.8 polish: the thread is ui/live-view.js (entries, status, sticking to the
2703
2803
  // bottom); Claude's answer is the session's own (markdown, like the terminal).
2704
2804
  const liveKeeper = createLiveKeeper()
@@ -2964,6 +3064,7 @@ function fullAfterRender(sid) {
2964
3064
  paintLfTabs()
2965
3065
  const ol = box.querySelector('.live-log')
2966
3066
  if (ol && ol._stick !== false) ol.scrollTop = ol.scrollHeight
3067
+ pinLogEnd(ol)
2967
3068
  } else if (fullUi.back === sid) {
2968
3069
  fullUi.back = null
2969
3070
  const b = document.getElementById('live')
@@ -3560,6 +3661,7 @@ async function refreshLive(reason) {
3560
3661
  const r = liveKeeper.after(box)
3561
3662
  hydrateAttThumbs()
3562
3663
  if (r) liveJumpButton(r.thread, liveKeeper.unseen)
3664
+ if (box.classList.contains('full')) pinLogEnd(fresh.querySelector('.live-log'))
3563
3665
  }
3564
3666
  }
3565
3667
  const c = sessionCache.get(id); if (c) c.live = v
@@ -4889,6 +4991,8 @@ function anywhereSection(r, adv = []) {
4889
4991
  if (r.enabled) {
4890
4992
  const st = r.connected ? '<span class="chip ok">Connected' + (r.latencyMs != null ? ' · ' + esc(r.latencyMs) + ' ms' : '') + '</span>' : '<span class="chip warn">' + (r.connecting && !r.problem ? 'Connecting…' : 'Not connected') + '</span>'
4891
4993
  h += '<label>Relay</label><div class="relaystat">' + st + (r.problem ? '<div class="hint warnish">' + esc(r.problem) + '</div>' : '') + '</div>'
4994
+ // 0.9.18 Hosted by (ui/hosted-by.js): which machine serves the board, and swapping it
4995
+ h += hostedByHtml(r.hosting, { esc, canChange: local || !!v.relay || !!v.device, viewing: window.leftoffRelay ? window.leftoffRelay.mac : null })
4892
4996
  if (r.boardUrl) h += '<label>Address</label><div class="remote-urls"><span class="chip mono">' + esc(r.boardUrl) + '</span><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(r.boardUrl) + '">Copy</button></div>'
4893
4997
  // Pairing from a paired phone follows Trust my paired devices (0.6.6).
4894
4998
  const mayPair = v.mayPair != null ? !!v.mayPair : local
@@ -7366,13 +7470,13 @@ document.addEventListener('keydown', (e) => { const b = e.target.closest && e.ta
7366
7470
  tip.classList.add('on')
7367
7471
  }, delay)
7368
7472
  }
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
7473
+ 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
7474
  document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
7371
7475
  document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
7372
7476
  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
7477
  document.addEventListener('focusout', (e) => { if (tipped(e) === cur) hide() })
7374
7478
  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
7479
+ 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
7480
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) hide() })
7377
7481
  // 0.9.18 touch: a long press shows the tip (the tap it ends with doesn't follow the link)
7378
7482
  let lp = 0, lpd = false
@@ -7390,7 +7494,7 @@ function wtStuck(sc) {
7390
7494
  }
7391
7495
  document.addEventListener('scroll', (e) => { const t = e.target; if (t && t.classList && t.classList.contains('wt-scroll')) wtStuck(t) }, true)
7392
7496
  // 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) }) })
7497
+ 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
7498
  // Working in a session keeps its tab: a Live message, typing there, answering Claude there
7395
7499
  const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
7396
7500
  for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
@@ -8168,6 +8272,18 @@ document.addEventListener('click', async (ev) => {
8168
8272
  refreshHealth().catch(() => {})
8169
8273
  return render()
8170
8274
  }
8275
+ // 0.9.18 Hosted by: Make host (now) and Preferred (Automatic or a machine)
8276
+ if (action === 'relay-host' || action === 'relay-host-pref') {
8277
+ const machine = btn.dataset.machine || null
8278
+ const prefer = action === 'relay-host-pref'
8279
+ btn.disabled = true
8280
+ let hz
8281
+ try { hz = await api('POST', '/v1/relay/host', { machine, prefer }) } catch (e) { btn.disabled = false; toast(e.message, 5000); return }
8282
+ const now = hz && hz.host && hz.host.machine
8283
+ toast(prefer ? (machine ? machine + ' is your preferred host' : 'Hosting is automatic') + (now ? ' · ' + now + ' hosts now' : '') : machine + ' hosts the board now')
8284
+ if (window.leftoffRelay && window.leftoffRelay.useHost && now) window.leftoffRelay.useHost(now)
8285
+ return render()
8286
+ }
8171
8287
  if (action === 'relay-user') {
8172
8288
  const user = (document.getElementById('f-anywhere-user').value || '').trim().toLowerCase()
8173
8289
  await api('POST', '/v1/relay/enable', { user }); toast('Saved: ' + user + '.leftoff.dev'); return render()
@@ -8647,6 +8763,7 @@ document.addEventListener('click', async (ev) => {
8647
8763
  if (action === 'live-earlier') return liveEarlierClick(btn)
8648
8764
  if (action === 'quick-reply') return quickReplyClick(btn)
8649
8765
  if (action === 'need-more') return needMoreClick(btn)
8766
+ if (action === 'needs-fold') return needsFoldClick(btn)
8650
8767
  if (action === 'need-dismiss') return needDismissClick(btn)
8651
8768
  // ---- To do and Files (0.7.0)
8652
8769
  if (action === 'todo-restore' || action === 'todo-restore-all') {
@@ -8749,7 +8866,7 @@ document.addEventListener('click', async (ev) => {
8749
8866
  if (action === 'title-auto') toast('Naming it from the latest messages…', 20000)
8750
8867
  await runRetitle(id, { api, busy: retitling, render, toast })
8751
8868
  } else if (action === 'rename') {
8752
- const box = btn.closest('.title')
8869
+ 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
8870
  if (!box || box.querySelector('input')) return
8754
8871
  const current = s ? s.title || '' : btn.dataset.title || ''
8755
8872
  box.innerHTML = '<input value="' + esc(current) + '" aria-label="Session title" maxlength="120" enterkeyhint="done">' +
@@ -0,0 +1,50 @@
1
+ // 0.9.18 Hosted by (Settings → Use from anywhere): which of your machines serves
2
+ // <user>.leftoff.dev. The relay decides (relay/src/hosting.js): your preferred host
3
+ // whenever it's online, else the one already hosting, else an online one (Macs
4
+ // first). Here: each machine with its state by shape and word, the host marked,
5
+ // "Make host" per online machine, and Preferred (Automatic or a machine).
6
+ // Pure (no DOM), so the check and the unit tests render it directly.
7
+ const STATE = {
8
+ online: ['●', 'Online'],
9
+ not_answering: ['◐', 'Not answering'],
10
+ reconnecting: ['◌', 'Reconnecting'],
11
+ asleep: ['○', 'Offline'],
12
+ }
13
+ export function hostState(m) { return STATE[m && m.state] || (m && m.online ? STATE.online : STATE.asleep) }
14
+ const kind = (p) => (p === 'darwin' ? 'Mac' : p === 'linux' ? 'Linux' : p ? p : '')
15
+
16
+ export function hostedByHtml(h, { esc = (s) => String(s == null ? '' : s), canChange = true, viewing = null } = {}) {
17
+ if (!h || !Array.isArray(h.macs) || !h.macs.length) return ''
18
+ let out = '<label>Hosted by</label><div class="hostedby" id="hostedBy">'
19
+ if (!h.supported) {
20
+ out += '<div class="hint nomt">Your relay serves the board from the machine that connected last. Choosing a host needs a relay update.</div></div>'
21
+ return out
22
+ }
23
+ const host = h.host || {}
24
+ const pref = host.preferred || null
25
+ out += '<ul class="hosts" role="list">'
26
+ for (const m of h.macs) {
27
+ const [shape, word] = hostState(m)
28
+ const isHost = m.machine === host.machine
29
+ const k = kind(m.platform)
30
+ out += '<li class="host-row' + (isHost ? ' is-host' : '') + (m.online ? ' on' : ' off') + '" data-machine="' + esc(m.machine) + '">' +
31
+ '<span class="host-shape" aria-hidden="true">' + shape + '</span>' +
32
+ '<span class="host-main"><b>' + esc(m.machine) + '</b>' + (isHost ? ' <span class="chip host-chip">Host</span>' : '') + (m.machine === pref ? ' <span class="chip pref-chip">Preferred</span>' : '') +
33
+ '<span class="faint host-sub">' + word + (k ? ' · ' + esc(k) : '') + (viewing && viewing === m.machine && !isHost ? ' · this browser' : '') + (m.serves === 0 ? ' · can’t open your paired browsers’ keys' : '') + '</span></span>'
34
+ if (canChange && !isHost) {
35
+ out += '<button type="button" class="btn sm host-make" data-action="relay-host" data-machine="' + esc(m.machine) + '"' + (m.online ? '' : ' disabled data-tip="' + esc(m.machine) + ' is offline: it can host once it’s online"') + '>Make host</button>'
36
+ }
37
+ out += '</li>'
38
+ }
39
+ out += '</ul>'
40
+ if (host.pending) out += '<div class="hint nomt host-pending">' + esc(host.pending.machine) + ' is back and takes over in a few seconds.</div>'
41
+ if (!host.machine) out += '<div class="hint nomt warnish">None of your machines is online. The first one to connect hosts.</div>'
42
+ // Preferred: a segmented choice, Automatic first
43
+ out += '<div class="host-pref" role="radiogroup" aria-label="Preferred host"><span class="host-pref-label">Preferred</span>'
44
+ const opt = (val, label, tip) => '<button type="button" role="radio" class="hp-opt' + ((pref || '') === val ? ' on' : '') + '" aria-checked="' + ((pref || '') === val ? 'true' : 'false') + '" data-action="relay-host-pref" data-machine="' + esc(val) + '" data-tip="' + esc(tip) + '"' + (canChange ? '' : ' disabled') + '>' + esc(label) + '</button>'
45
+ out += opt('', 'Automatic', 'Any online machine; a Mac before Linux, and the host stays put while it’s online')
46
+ for (const m of h.macs) out += opt(m.machine, m.machine, m.machine + ' hosts whenever it’s online; when it’s closed another machine takes over, and it takes back when it returns')
47
+ out += '</div>'
48
+ out += '<div class="hint">' + (pref ? esc(pref) + ' hosts whenever it’s online. ' : '') + 'When the host closes or loses its network, another online machine takes over within about half a minute. Phones and browsers stay paired.</div>'
49
+ return out + '</div>'
50
+ }
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