@leftoff/cli 0.9.8-beta.6 → 0.9.8-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.
@@ -111,6 +111,8 @@ Only Macs registered for that user can connect: the first with `leftoff relay re
111
111
 
112
112
  Every few seconds (`peers.pollMs`, default 4 s) each daemon asks every machine that accepts for `needs.list`, so another Mac's permission prompts and questions show on this board with a ⌂ machine chip. Answer, reply and Go on another machine's session go out as envelopes. When that isn't possible, the button is grey and says why: not allowed on that machine, unreachable, key changed, no key yet (that Mac needs 0.5.2), or no route. Answering from a phone keeps its own rules (Settings → Privacy & security), and Go stays a local-browser action.
113
113
 
114
+ Phone notifications (0.9.8) ride the same envelopes: `push.sync` carries the Web Push subscriptions a browser turned on (endpoint, keys, a device label) with the VAPID keys they were made with, plus 30-day removal tombstones; each side merges (newer word per endpoint wins) and answers with its own, so every Mac can push for its own sessions and only those. It is answered even when a Mac doesn't allow incoming commands, because it runs nothing there. Kept on each Mac in `~/.config/checkpoints/push/` (0600), never in the synced folder. Shared when a device subscribes or unsubscribes, at start, and every 20 minutes.
115
+
114
116
  ## Pairing through Syncthing (0.5.3)
115
117
 
116
118
  Before two Macs share a folder they have no channel to each other, so pairing rides on Syncthing itself. Leftoff never opens a port for it.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8-beta.6",
3
+ "version": "0.9.8-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": "Joel"
@@ -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.8-beta.6' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.8-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.8-beta.6",
3
+ "version": "0.9.8-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
@@ -2141,10 +2141,10 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2141
2141
  }
2142
2142
 
2143
2143
  /* ------------------------------------------------------------ the sidebar rail (0.9.8)
2144
- From 820px up: a narrow rail on the left. The Leftoff mark at the top, then
2144
+ From 700px up (0.9.8 tabs: was 820): a narrow rail on the left. The Leftoff mark at the top, then
2145
2145
  the sections as an icon over a small label, the current one in a soft pill,
2146
2146
  a red count on Needs you; search, What's new, Settings and this Mac's status
2147
- (the Mac switcher) at the bottom. Under 820px: the mark, search and status in
2147
+ (the Mac switcher) at the bottom; 700–1099px it shows icons only. Under 700px: the mark, search and status in
2148
2148
  a slim top row, and a bottom tab bar (Board, Needs you, Lists, PRs, More). */
2149
2149
  :root { --rail-w: 84px; --rail-on: color-mix(in srgb, var(--accent) 13%, transparent); --bar-h: 58px; }
2150
2150
  .tab-more { display: none; }
@@ -2153,7 +2153,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2153
2153
  .moredot[hidden] { display: none; }
2154
2154
  .iconbtn .lbl { display: none; }
2155
2155
  .tabs a, .tab-more { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
2156
- @media (min-width: 820px) {
2156
+ @media (min-width: 700px) {
2157
2157
  body { padding-left: calc(var(--rail-w) + env(safe-area-inset-left)); }
2158
2158
  .top { position: fixed !important; top: 0; bottom: 0; left: 0; width: calc(var(--rail-w) + env(safe-area-inset-left)); z-index: 20; /* !important: the rail never joins the page flow */
2159
2159
  flex-direction: column; flex-wrap: nowrap; align-items: stretch; justify-content: flex-start; gap: 6px; min-height: 0;
@@ -2181,11 +2181,11 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2181
2181
  .newdot, .topdot { top: 8px; right: calc(50% - 15px); box-shadow: 0 0 0 2px var(--bg); }
2182
2182
  .set-nav { top: 24px; }
2183
2183
  }
2184
- @media (min-width: 820px) and (max-height: 640px) {
2184
+ @media (min-width: 700px) and (max-height: 640px) {
2185
2185
  .tabs a, .topright .searchbtn, .topright .iconbtn, .topright .health { min-height: 46px; padding: 4px 2px; gap: 2px; }
2186
2186
  .brand { padding-bottom: 4px; }
2187
2187
  }
2188
- @media (max-width: 819.98px) {
2188
+ @media (max-width: 699.98px) {
2189
2189
  .top { flex-wrap: nowrap; gap: 8px; min-height: 52px; padding-top: max(6px, env(safe-area-inset-top)); padding-bottom: 6px; background: var(--bg); -webkit-backdrop-filter: none; backdrop-filter: none; }
2190
2190
  .brand { order: 1; }
2191
2191
  .topright { order: 2; margin-left: auto; gap: 2px; }
@@ -2224,7 +2224,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2224
2224
  .moremenu a.on { background: var(--rail-on); }
2225
2225
  .moremenu a.on .ic { color: var(--accent); }
2226
2226
  .moremenu .newdot, .moremenu .topdot { position: static; margin-left: auto; box-shadow: none; }
2227
- @media (min-width: 820px) { .moremenu { display: none !important; } }
2227
+ @media (min-width: 700px) { .moremenu { display: none !important; } }
2228
2228
  /* Quick look on Needs you (0.9.8) */
2229
2229
  .quicklook { position: fixed; z-index: 45; width: 360px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); overflow-y: auto; overscroll-behavior: contain;
2230
2230
  background: var(--card); color: var(--text); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 16px 40px rgba(15, 20, 35, .18), 0 2px 6px rgba(15, 20, 35, .08); padding: 12px 12px 8px; font-size: 13.5px; animation: lo-in .12s ease-out; }
@@ -2254,8 +2254,8 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2254
2254
  .ql-foot { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-height: 44px; padding: 0 6px; margin-top: 2px; color: var(--accent); font-weight: 600; text-decoration: none; border-radius: 8px; }
2255
2255
  .ql-foot:hover { text-decoration: underline; }
2256
2256
  .ql-foot:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2257
- @media (pointer: coarse), (max-width: 819.98px) { .ql-actions .btn { min-height: 44px; min-width: 64px; } .ql-main { min-height: 44px; justify-content: center; } }
2258
- @media (max-width: 819.98px) { .quicklook { left: max(8px, env(safe-area-inset-left)); right: max(8px, env(safe-area-inset-right)); width: auto; max-width: none; bottom: calc(var(--bar-h) + 8px + env(safe-area-inset-bottom)); max-height: calc(100vh - var(--bar-h) - 80px); } }
2257
+ @media (pointer: coarse), (max-width: 699.98px) { .ql-actions .btn { min-height: 44px; min-width: 64px; } .ql-main { min-height: 44px; justify-content: center; } }
2258
+ @media (max-width: 699.98px) { .quicklook { left: max(8px, env(safe-area-inset-left)); right: max(8px, env(safe-area-inset-right)); width: auto; max-width: none; bottom: calc(var(--bar-h) + 8px + env(safe-area-inset-bottom)); max-height: calc(100vh - var(--bar-h) - 80px); } }
2259
2259
  @media (prefers-reduced-motion: reduce) { .quicklook, .moremenu { animation: none; } }
2260
2260
 
2261
2261
  /* Pull to refresh (phones, 0.9.8): the spinner comes down from under the top row */
@@ -2267,7 +2267,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2267
2267
  .ptr-ring svg { width: 19px; height: 19px; }
2268
2268
  .ptr.armed { color: var(--accent); }
2269
2269
  .ptr.busy .ptr-ring { animation: spin .8s linear infinite; }
2270
- @media (pointer: coarse) and (max-width: 819.98px) { html, body { overscroll-behavior-y: contain; } }
2270
+ @media (pointer: coarse) and (max-width: 699.98px) { html, body { overscroll-behavior-y: contain; } }
2271
2271
  @media (prefers-reduced-motion: reduce) { .ptr-ring { transform: none; } .ptr.busy .ptr-ring { animation: lo-pulse 1.2s ease-in-out infinite; } }
2272
2272
  @keyframes lo-pulse { 50% { opacity: .35; } }
2273
2273
 
@@ -2313,3 +2313,91 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2313
2313
  .card > .actions-row > .btn.icon-btn { width: 44px; }
2314
2314
  .card > .actions-row > .btn:not(.icon-btn) { min-width: 44px; }
2315
2315
  }
2316
+
2317
+ /* Phone notifications (0.9.8): the one per-device button, in More and Settings → General */
2318
+ .push-slot[hidden] { display: none; }
2319
+ .push-box { display: flex; gap: 10px; align-items: flex-start; }
2320
+ .push-box > .ic { width: 20px; height: 20px; flex: none; margin-top: 2px; color: var(--muted); }
2321
+ .push-main { min-width: 0; flex: 1; }
2322
+ .push-line { margin: 0; font-size: 14px; line-height: 1.4; color: var(--text2); }
2323
+ .push-line strong { color: var(--text); font-weight: 600; }
2324
+ .push-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
2325
+ .push-acts .btn { min-height: 44px; padding: 0 14px; font-size: 14px; white-space: nowrap; justify-content: center; }
2326
+ .push-note { margin: 8px 0 0; font-size: 13px; line-height: 1.4; color: var(--muted); }
2327
+ .push-note.bad { color: var(--red, #c0392b); }
2328
+ .moremenu .push-slot { border-top: 1px solid var(--line); margin-top: 6px; padding: 12px 8px 6px; }
2329
+ .moremenu:has(.push-slot:not([hidden])) { width: 270px; }
2330
+ .form .push-slot { grid-column: 1 / -1; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--card2, transparent); margin-top: 4px; }
2331
+
2332
+ /* ------------------------------------------------------------ tabs for sessions (0.9.8)
2333
+ By width, never by device. Under 700px: no strip; an Open row on the board
2334
+ and "‹ 2 of 4 ›" on a session page (swipe works too). 700–1099px: the strip,
2335
+ the rail as icons. From 1100px: the full rail and the strip. */
2336
+ .tabstrip, .openrow, .tabpager { display: none; }
2337
+ .tabstrip[hidden], .openrow[hidden], .tabpager[hidden] { display: none !important; }
2338
+ .tab-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2339
+ .preview .tab-t, .tab-s.preview .tab-t, .ochip.preview .tab-t { font-style: italic; }
2340
+ .tab-live, .tab-need { flex: none; width: 8px; height: 8px; border-radius: 50%; position: relative; }
2341
+ .tab-live { background: var(--green); animation: live-pulse 1.6s ease-in-out infinite; }
2342
+ .tab-need { background: var(--red); }
2343
+ @media (prefers-reduced-motion: reduce) { .tab-live { animation: none; } }
2344
+ @media (min-width: 700px) {
2345
+ .tabstrip { display: flex; position: sticky; top: 0; z-index: 15; align-items: stretch; height: 44px; background: var(--bg); border-bottom: 1px solid var(--line); padding-top: env(safe-area-inset-top); }
2346
+ .tabstrip-in { flex: 1; min-width: 0; display: flex; align-items: stretch; }
2347
+ .tab-scroll { flex: 1; min-width: 0; display: flex; align-items: stretch; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; overscroll-behavior-x: contain; }
2348
+ .tab-scroll::-webkit-scrollbar { display: none; }
2349
+ .tab { display: flex; align-items: center; gap: 7px; min-height: 44px; padding: 0 12px; color: var(--muted); text-decoration: none; font-size: 13px; font-weight: 500; white-space: nowrap; }
2350
+ .tab .ic { width: 16px; height: 16px; flex: none; }
2351
+ .tab:hover { color: var(--text); }
2352
+ .tab:focus-visible, .tab-x:focus-visible, .tab-all:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2353
+ .tab-board { flex: none; border-right: 1px solid var(--line); }
2354
+ .tab-cell { position: relative; flex: 0 1 200px; min-width: 120px; display: flex; align-items: stretch; border-right: 1px solid var(--line); }
2355
+ .tab-s { flex: 1; min-width: 0; padding-right: 0; }
2356
+ .tab.on, .tab-cell.on { color: var(--text); background: var(--card); }
2357
+ .tab-board.on, .tab-cell.on { box-shadow: inset 0 -2px 0 var(--accent); }
2358
+ .tab-x { flex: none; width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0; border-radius: 8px; }
2359
+ .tab-x .ic { width: 14px; height: 14px; }
2360
+ .tab-x:hover { color: var(--text); background: var(--bg2); }
2361
+ .tab-keep { opacity: 0; }
2362
+ .tab-cell:hover .tab-keep, .tab-keep:focus-visible, .tab-cell.on .tab-keep { opacity: 1; }
2363
+ .tab-cell.dragging { opacity: .5; }
2364
+ .tab-cell.drop-before { box-shadow: inset 2px 0 0 var(--accent); }
2365
+ .tab-all { flex: none; width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; border: 0; border-left: 1px solid var(--line); background: var(--bg); color: var(--muted); cursor: pointer; }
2366
+ .tab-all .ic { width: 18px; height: 18px; }
2367
+ .tab-all:hover, .tab-all[aria-expanded="true"] { color: var(--text); background: var(--bg2); }
2368
+ .tab-all[hidden] { display: none; }
2369
+ }
2370
+ @media (min-width: 700px) and (pointer: coarse) { .tab-keep { opacity: 1; } }
2371
+ /* the rail as icons (700–1099px): the labels stay for screen readers */
2372
+ @media (min-width: 700px) and (max-width: 1099.98px) {
2373
+ :root { --rail-w: 64px; }
2374
+ .tabs a .lbl, .topright .lbl, .topright .sb-lbl, .topright .health .label { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
2375
+ .tabs a, .topright .searchbtn, .topright .iconbtn, .topright .health { min-height: 48px; padding: 6px 2px; }
2376
+ .navcount { left: calc(50% + 2px); }
2377
+ }
2378
+ /* phones (under 700px): the Open row on the board, ‹ N of M › on a session page */
2379
+ @media (max-width: 699.98px) {
2380
+ body[data-route="board"] .openrow { display: block; }
2381
+ body[data-route="s"] .tabpager { display: block; }
2382
+ .openrow { padding: 8px 0 0; }
2383
+ .openrow-h { display: flex; gap: 8px; align-items: baseline; padding: 0 max(16px, env(safe-area-inset-left)); font-size: 12px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase; color: var(--muted); }
2384
+ .openrow-chips { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; padding: 8px max(16px, env(safe-area-inset-left)) 6px; scroll-snap-type: x proximity; overscroll-behavior-x: contain; }
2385
+ .openrow-chips::-webkit-scrollbar { display: none; }
2386
+ .ochip-cell { flex: none; display: flex; align-items: stretch; max-width: 62vw; border: 1px solid var(--line); border-radius: 22px; background: var(--card); scroll-snap-align: start; }
2387
+ .ochip-cell.preview { border-style: dashed; }
2388
+ .ochip { display: flex; align-items: center; gap: 7px; min-height: 44px; min-width: 44px; padding: 0 14px; color: var(--text); text-decoration: none; font-size: 15px; max-width: 100%; min-width: 0; }
2389
+ .ochip-cell:not(.preview) .ochip { padding-right: 2px; }
2390
+ .ochip-x { flex: none; width: 44px; min-height: 44px; border: 0; background: none; color: var(--muted); display: flex; align-items: center; justify-content: center; padding: 0; border-radius: 22px; }
2391
+ .ochip-x .ic { width: 15px; height: 15px; }
2392
+ .tabpager { padding: 4px max(8px, env(safe-area-inset-left)) 0; }
2393
+ .pager-in { display: flex; align-items: center; gap: 6px; }
2394
+ .pager-b { flex: none; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; border-radius: 22px; color: var(--text); text-decoration: none; }
2395
+ .pager-b .ic { width: 22px; height: 22px; }
2396
+ .pager-b.off { opacity: .25; }
2397
+ .pager-b:not(.off):active { background: var(--bg2); }
2398
+ .pager-n { flex: 1; text-align: center; font-size: 14px; color: var(--muted); font-variant-numeric: tabular-nums; }
2399
+ .pager-keep { flex: none; min-height: 44px; }
2400
+ .swipe-next main { animation: lo-page-fwd .18s ease-out; }
2401
+ .swipe-prev main { animation: lo-page-back .18s ease-out; }
2402
+ }
2403
+ @media (prefers-reduced-motion: reduce) { .swipe-next main, .swipe-prev main { animation: none !important; } }
package/ui/app.js CHANGED
@@ -19,8 +19,10 @@ import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, pr
19
19
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
20
20
  import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
21
21
  import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
22
- import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses } from './live-view.js' // 0.9.8 Live link polish
22
+ import { sessionTitle, titleIsWhere, titleHtml, cardPills, foldsAsEmpty, threadHtml, earlierHtml, createLiveKeeper, liveHref, parseLiveRoute, hasUnread, scrollDecision, atBottom, jumpView, liveBadgeHtml, lockPulses, liveWorking } from './live-view.js' // 0.9.8 Live link polish
23
23
  import { statusView, elapsedText } from './live-status.js'
24
+ import { mountPush, paintPush } from './push.js' // 0.9.8 phone notifications
25
+ import { TABS_KEY, layoutFor, parseStored, serialize, openTab, promote, remember, closeTab, moveTab, neighbours, indexOf, sessionOfHash, keepHash, tabView, stripHtml, allTabsItems, openRowHtml, pagerHtml, swipeDir } from './tabs.js' // 0.9.8 tabs for sessions
24
26
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
25
27
  const $app = document.getElementById('app')
26
28
  // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
@@ -2616,6 +2618,7 @@ async function viewSettings() {
2616
2618
  general: '<div class="section">Needs you</div>' +
2617
2619
  '<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a notification when a prompt or question has waited ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
2618
2620
  '<div class="hint">With <code>terminal-notifier</code>, a click jumps to the session.</div>' +
2621
+ '<div class="full push-slot" data-push-slot hidden></div>' +
2619
2622
  '<div class="section">Appearance</div>' +
2620
2623
  '<label>Theme</label><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div>' +
2621
2624
  advanced('general', '<label for="f-dormant">Dormant after (min)</label><input id="f-dormant" type="number" min="1" step="1" value="' + esc(c.dormantAfterMin) + '">' +
@@ -3378,6 +3381,7 @@ function parseRoute() {
3378
3381
  async function render() {
3379
3382
  const r = (state.route = parseRoute())
3380
3383
  paintNav(r)
3384
+ tabsOnRoute(r)
3381
3385
  try {
3382
3386
  if (r.name === 's' && r.arg) await viewSession(r.arg)
3383
3387
  else if (r.name === 'waiting') await viewWaiting()
@@ -3392,6 +3396,7 @@ async function render() {
3392
3396
  else if (r.name === 'search') await viewSearch(r.q || '')
3393
3397
  else if (r.name === 'setup') await viewSetup()
3394
3398
  else await viewBoard()
3399
+ tabsAfterRender()
3395
3400
  } catch (e) {
3396
3401
  if (e.code === 'login_required') return showLogin()
3397
3402
  $app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
@@ -3499,12 +3504,12 @@ function paintTopDot() {
3499
3504
  }
3500
3505
 
3501
3506
  // ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
3502
- // From 820px up the sections sit in a rail on the left; under it, in a bottom
3507
+ // From 700px up the sections sit in a rail on the left; under it, in a bottom
3503
3508
  // bar with the rest under More. Hover the Needs you item (or long-press it on a
3504
3509
  // phone, or press ↓/→ on it) for Quick look: the count and the five longest
3505
3510
  // waits, with Allow / Deny on permission prompts (the Needs you page's own
3506
3511
  // buttons, so the same checks apply and nothing is answered without a click).
3507
- const railMode = () => window.matchMedia('(min-width: 820px)').matches
3512
+ const railMode = () => window.matchMedia('(min-width: 700px)').matches
3508
3513
  function paintNav(r = state.route || parseRoute()) {
3509
3514
  const key = navKey(r.name)
3510
3515
  document.querySelectorAll('.tabs a[data-route], .topright a[data-route], #moreMenu a[data-route]').forEach((a) => { const on = a.dataset.route === key; a.classList.toggle('on', on); if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current') })
@@ -3533,6 +3538,7 @@ function openMore() {
3533
3538
  if (!m) return
3534
3539
  closeQuickLook(); toggleStatus(false)
3535
3540
  paintMoreDots()
3541
+ paintPush()
3536
3542
  m.hidden = false
3537
3543
  if (b) b.setAttribute('aria-expanded', 'true')
3538
3544
  const first = m.querySelector('a.on') || m.querySelector('a')
@@ -3661,6 +3667,214 @@ function closeQuickLook(focusTab = false) {
3661
3667
  window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
3662
3668
  }
3663
3669
 
3670
+ // ------------------------------------------------------------ tabs for sessions (0.9.8, ui/tabs.js)
3671
+ // The board is the first tab; a session opens as the one preview tab (italic)
3672
+ // that the next click replaces, and stays open once you send a Live message,
3673
+ // type there, choose Keep open or double-click its tab (⌘/Ctrl-click or a
3674
+ // middle-click opens it permanent straight away). Kept per device in
3675
+ // localStorage. By width: under 700px the Open row on the board and ‹ N of M ›
3676
+ // (or a swipe) on a session page; from 700px the strip. A width change (fold,
3677
+ // Split View, rotation) only changes the CSS: the open set, the session and
3678
+ // where you were on the page stay.
3679
+ const tabsUi = { m: parseStored(localStorage.getItem(TABS_KEY)), active: null, route: null, permanent: null, html: { strip: '', row: '', pager: '' }, anchor: null, logs: new WeakMap(), quietUntil: 0, fetchT: 0, fetchedAt: 0, scrolls: {} }
3680
+ window.leftoffTabs = { model: () => JSON.parse(serialize(tabsUi.m)) } // read by test:tabs-ui
3681
+ function setTabs(m) {
3682
+ if (m === tabsUi.m) return
3683
+ tabsUi.m = m
3684
+ try { localStorage.setItem(TABS_KEY, serialize(m)) } catch {}
3685
+ }
3686
+ const tabSession = (id) => state.sessions.find((s) => s.id === id)
3687
+ const tabViews = () => tabsUi.m.tabs.map((t) => tabView(t, tabSession(t.id), { title: sessionTitle, working: (s) => liveWorking(s) }))
3688
+ function tabsOnRoute(r) {
3689
+ const id = r.name === 's' && r.arg ? String(r.arg) : null
3690
+ if (tabsUi.active && tabsUi.active !== id) tabsUi.scrolls[tabsUi.active] = window.scrollY || 0
3691
+ if (id) {
3692
+ const permanent = tabsUi.permanent === id
3693
+ setTabs(openTab(tabsUi.m, id, { permanent, hash: keepHash(location.hash), title: (tabSession(id) && sessionTitle(tabSession(id))) || '' }))
3694
+ }
3695
+ tabsUi.permanent = null
3696
+ const changed = tabsUi.active !== id || tabsUi.route !== r.name
3697
+ tabsUi.active = id; tabsUi.route = r.name
3698
+ document.body.dataset.route = r.name
3699
+ paintTabs()
3700
+ if (changed) scrollActiveTab()
3701
+ }
3702
+ function tabsAfterRender() {
3703
+ let m = tabsUi.m
3704
+ for (const t of m.tabs) { const s = tabSession(t.id); if (s) m = remember(m, t.id, { title: sessionTitle(s) }) }
3705
+ setTabs(m)
3706
+ paintTabs()
3707
+ const back = tabsUi.active && tabsUi.restoreScroll === tabsUi.active ? tabsUi.scrolls[tabsUi.active] : null
3708
+ tabsUi.restoreScroll = null
3709
+ if (back != null) window.scrollTo(0, back)
3710
+ noteAnchor()
3711
+ // the other open tabs' pulse and dot: read the sessions again now and then while you're off the board
3712
+ if (tabsUi.route !== 'board' && tabsUi.m.tabs.some((t) => t.id !== tabsUi.active) && Date.now() - tabsUi.fetchedAt > 15000) refreshTabSessions()
3713
+ }
3714
+ async function refreshTabSessions() {
3715
+ tabsUi.fetchedAt = Date.now()
3716
+ try {
3717
+ const { sessions = [], parked = [] } = await api('GET', '/v1/sessions?limit=200')
3718
+ const fresh = new Map(sessions.concat(parked).map((s) => [s.id, s]))
3719
+ state.sessions = state.sessions.map((s) => (s.id !== tabsUi.active && fresh.has(s.id) ? fresh.get(s.id) : s)).concat([...fresh.values()].filter((s) => !state.sessions.some((x) => x.id === s.id)))
3720
+ paintTabs()
3721
+ } catch {}
3722
+ }
3723
+ function paintTabs() {
3724
+ const views = tabViews()
3725
+ const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
3726
+ if (!strip) return
3727
+ const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
3728
+ if (sh !== tabsUi.html.strip) {
3729
+ const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
3730
+ const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
3731
+ strip.innerHTML = sh; tabsUi.html.strip = sh
3732
+ const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
3733
+ if (had) { const el = strip.querySelector('[data-tab="' + CSS.escape(had) + '"], [data-tab-close="' + CSS.escape(had) + '"], [data-tab-keep="' + CSS.escape(had) + '"], #' + CSS.escape(had)); if (el) el.focus({ preventScroll: true }) }
3734
+ }
3735
+ strip.hidden = false
3736
+ const rh = openRowHtml(views, esc)
3737
+ if (rh !== tabsUi.html.row) { const c = row.querySelector('.openrow-chips'), l = c ? c.scrollLeft : 0; row.innerHTML = rh; tabsUi.html.row = rh; const nc = row.querySelector('.openrow-chips'); if (nc) nc.scrollLeft = l }
3738
+ row.hidden = !rh
3739
+ const ph = tabsUi.active ? pagerHtml(tabsUi.m, tabsUi.active, views, esc) : ''
3740
+ if (ph !== tabsUi.html.pager) { pager.innerHTML = ph; tabsUi.html.pager = ph }
3741
+ pager.hidden = !ph
3742
+ }
3743
+ function scrollActiveTab() {
3744
+ const el = document.querySelector('#tabStrip .tab-cell.on'), sc = document.querySelector('#tabStrip .tab-scroll')
3745
+ if (!el || !sc) return
3746
+ const r = el.getBoundingClientRect(), b = sc.getBoundingClientRect()
3747
+ if (r.left < b.left) sc.scrollLeft -= b.left - r.left + 8
3748
+ else if (r.right > b.right) sc.scrollLeft += r.right - b.right + 8
3749
+ }
3750
+ function keepTab(id) { setTabs(promote(tabsUi.m, id)); paintTabs() }
3751
+ function closeTabNow(id) {
3752
+ const { model, next } = closeTab(tabsUi.m, id, tabsUi.active)
3753
+ setTabs(model)
3754
+ delete tabsUi.scrolls[id]
3755
+ if (tabsUi.active === String(id)) {
3756
+ const t = next && model.tabs.find((x) => x.id === next)
3757
+ if (t) tabsUi.restoreScroll = t.id
3758
+ location.hash = t ? tabView(t).href : '#/'
3759
+ } else paintTabs()
3760
+ }
3761
+ function goTab(href) {
3762
+ const id = sessionOfHash(href)
3763
+ if (id) tabsUi.restoreScroll = id
3764
+ if (location.hash === href) render(); else location.hash = href
3765
+ }
3766
+ // ⌘/Ctrl-click or a middle-click on any session link: open it as a permanent tab (here, not a new browser tab)
3767
+ function sessionLinkId(a) { return a && a.getAttribute && sessionOfHash(a.getAttribute('href') || '') }
3768
+ document.addEventListener('click', (e) => {
3769
+ const a = e.target.closest && e.target.closest('a[href^="#/s/"]')
3770
+ if (!a || !(e.metaKey || e.ctrlKey) || e.button !== 0) return
3771
+ const id = sessionLinkId(a); if (!id) return
3772
+ e.preventDefault(); e.stopPropagation()
3773
+ if (indexOf(tabsUi.m, id) >= 0) setTabs(promote(tabsUi.m, id)); else tabsUi.permanent = id
3774
+ if (location.hash === a.getAttribute('href')) render(); else location.hash = a.getAttribute('href')
3775
+ }, true)
3776
+ document.addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest && e.target.closest('a[href^="#/s/"], #tabStrip .tab-cell')) e.preventDefault() }) // no autoscroll
3777
+ document.addEventListener('auxclick', (e) => {
3778
+ if (e.button !== 1 || !e.target.closest) return
3779
+ const cell = e.target.closest('#tabStrip .tab-cell')
3780
+ if (cell) { e.preventDefault(); closeTabNow(cell.dataset.tabId); return } // middle-click a tab: close it
3781
+ const a = e.target.closest('a[href^="#/s/"]')
3782
+ const id = sessionLinkId(a); if (!id) return
3783
+ e.preventDefault()
3784
+ if (indexOf(tabsUi.m, id) >= 0) { setTabs(promote(tabsUi.m, id)); paintTabs() }
3785
+ else { setTabs(openTab(tabsUi.m, id, { permanent: true })); paintTabs() } // opened in the background, the way a middle-click does
3786
+ })
3787
+ document.addEventListener('click', (e) => {
3788
+ if (!e.target.closest) return
3789
+ const close = e.target.closest('[data-tab-close]')
3790
+ if (close) { e.preventDefault(); return closeTabNow(close.dataset.tabClose) }
3791
+ const keep = e.target.closest('[data-tab-keep]')
3792
+ if (keep) { e.preventDefault(); return keepTab(keep.dataset.tabKeep) }
3793
+ const go = e.target.closest('[data-tab-go]')
3794
+ if (go) { e.preventDefault(); return goTab(go.dataset.tabGo) }
3795
+ const all = e.target.closest('#tabAll')
3796
+ if (all) { e.preventDefault(); return openActionMenu(all, allTabsItems(tabViews(), tabsUi.active), { title: 'Open sessions' }) }
3797
+ const t = e.target.closest('#tabStrip a[data-tab], #openRow a[data-tab], #tabPager a[data-tab]')
3798
+ if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
3799
+ })
3800
+ document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
3801
+ // Working in a session keeps its tab: a Live message, typing there, answering Claude there
3802
+ const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
3803
+ for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
3804
+ document.addEventListener('click', (e) => { if (e.target.closest && $app.contains(e.target) && e.target.closest('[data-action="live-send"], .live-box [data-action], .need [data-choice], [data-action="ask-answer"], [data-action="rename"]')) promoteActive() }, true)
3805
+ // Drag a tab to reorder
3806
+ {
3807
+ let dragId = null
3808
+ document.addEventListener('dragstart', (e) => { const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (!c) return; dragId = c.dataset.tabId; c.classList.add('dragging'); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragId) } catch {} })
3809
+ document.addEventListener('dragover', (e) => { if (!dragId) return; const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (!c) return; e.preventDefault(); document.querySelectorAll('#tabStrip .drop-before').forEach((x) => x.classList.remove('drop-before')); c.classList.add('drop-before') })
3810
+ document.addEventListener('drop', (e) => { if (!dragId) return; const c = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (c) { e.preventDefault(); const to = indexOf(tabsUi.m, c.dataset.tabId); const from = indexOf(tabsUi.m, dragId); setTabs(moveTab(tabsUi.m, dragId, from < to ? to - 1 : to)); paintTabs() } })
3811
+ document.addEventListener('dragend', () => { dragId = null; document.querySelectorAll('#tabStrip .dragging, #tabStrip .drop-before').forEach((x) => x.classList.remove('dragging', 'drop-before')) })
3812
+ }
3813
+ // Phones: swipe left / right on a session page for the next / previous open session
3814
+ {
3815
+ let sw = null
3816
+ const narrowQ = window.matchMedia('(max-width: 699.98px)')
3817
+ const scrollsSideways = (el) => { for (let n = el; n && n !== document.body; n = n.parentElement) { if (n.scrollWidth > n.clientWidth + 2 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true } return false }
3818
+ document.addEventListener('touchstart', (e) => {
3819
+ sw = null
3820
+ if (!narrowQ.matches || tabsUi.route !== 's' || e.touches.length !== 1) return
3821
+ const t = e.target
3822
+ if (t.closest('input, textarea, select, [contenteditable], .lo-menu, .cmdk, .doc-viewer, .tabs, .moremenu') || scrollsSideways(t)) return
3823
+ sw = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }
3824
+ }, { passive: true })
3825
+ document.addEventListener('touchend', (e) => {
3826
+ if (!sw || !e.changedTouches.length) return
3827
+ const p = e.changedTouches[0]
3828
+ const dir = swipeDir(p.clientX - sw.x, p.clientY - sw.y, Date.now() - sw.at)
3829
+ sw = null
3830
+ if (!dir || (window.getSelection && String(window.getSelection()))) return
3831
+ const n = neighbours(tabsUi.m, tabsUi.active)
3832
+ const to = dir === 'next' ? n.next : n.prev
3833
+ if (!to) return
3834
+ const t = tabsUi.m.tabs.find((x) => x.id === to)
3835
+ document.body.classList.remove('swipe-next', 'swipe-prev'); void document.body.offsetWidth
3836
+ document.body.classList.add('swipe-' + dir); setTimeout(() => document.body.classList.remove('swipe-' + dir), 260)
3837
+ goTab(tabView(t).href)
3838
+ }, { passive: true })
3839
+ }
3840
+ // A width change keeps your place: the element at the top of the page stays at
3841
+ // the top, and a Live chat that was at its newest stays there.
3842
+ function anchorY() {
3843
+ let y = 0
3844
+ for (const el of [document.querySelector('.top'), document.getElementById('tabStrip'), document.getElementById('tabPager')]) {
3845
+ if (!el) continue
3846
+ const cs = getComputedStyle(el); if (cs.display === 'none' || !/(fixed|sticky)/.test(cs.position)) continue
3847
+ const r = el.getBoundingClientRect(); if (r.top <= 1 && r.bottom < innerHeight / 2) y = Math.max(y, r.bottom)
3848
+ }
3849
+ return y
3850
+ }
3851
+ function noteAnchor() {
3852
+ if (Date.now() < tabsUi.quietUntil) return
3853
+ const y = anchorY() + 6, m = $app.getBoundingClientRect()
3854
+ if (m.bottom < y) { tabsUi.anchor = null; return }
3855
+ let el = document.elementFromPoint(Math.max(m.left + 24, Math.min(m.left + m.width / 2, innerWidth - 24)), y)
3856
+ if (!el || !$app.contains(el) || el === $app) { const kids = [...$app.querySelectorAll('h1, h2, h3, p, li, .card, .fact, .lv-msg, section, div')]; el = kids.find((k) => { const r = k.getBoundingClientRect(); return r.bottom > y && r.height > 0 }) || null }
3857
+ tabsUi.anchor = el ? { el, top: el.getBoundingClientRect().top, scrollY: window.scrollY } : null
3858
+ }
3859
+ function keepPlace() {
3860
+ const a = tabsUi.anchor
3861
+ tabsUi.quietUntil = Date.now() + 250
3862
+ if (a && a.el.isConnected && a.scrollY > 0) window.scrollBy(0, a.el.getBoundingClientRect().top - a.top)
3863
+ document.querySelectorAll('.live-log').forEach((log) => { const st = tabsUi.logs.get(log); if (st && st.bottom) log.scrollTop = log.scrollHeight })
3864
+ }
3865
+ {
3866
+ let raf = 0
3867
+ window.addEventListener('scroll', () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; noteAnchor() }) }, { passive: true })
3868
+ document.addEventListener('scroll', (e) => { const log = e.target; if (log && log.classList && log.classList.contains('live-log') && Date.now() >= tabsUi.quietUntil) tabsUi.logs.set(log, { bottom: log.scrollHeight - log.scrollTop - log.clientHeight < 24 }) }, true)
3869
+ let lay = layoutFor(innerWidth)
3870
+ window.addEventListener('resize', () => {
3871
+ keepPlace()
3872
+ const now = layoutFor(innerWidth)
3873
+ if (now !== lay) { lay = now; document.body.dataset.layout = now; scrollActiveTab() }
3874
+ })
3875
+ document.body.dataset.layout = lay
3876
+ }
3877
+
3664
3878
  // Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
3665
3879
  // past the line; the page reads its data again, the same as a live update (no
3666
3880
  // full reload unless that fails). Never from a field, the Live box or a menu.
@@ -3669,7 +3883,7 @@ function closeQuickLook(focusTab = false) {
3669
3883
  ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
3670
3884
  ptr.innerHTML = '<span class="ptr-ring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></span>'
3671
3885
  document.body.appendChild(ptr)
3672
- const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 819.98px)')
3886
+ const coarse = window.matchMedia('(pointer: coarse)'), narrow = window.matchMedia('(max-width: 699.98px)')
3673
3887
  const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
3674
3888
  let pull = null, refreshing = false
3675
3889
  const paint = (step) => {
@@ -4973,3 +5187,6 @@ connect()
4973
5187
  // The home-screen app's service worker (v0.4.6): it only serves an offline page
4974
5188
  // when the Mac can't be reached; it caches no API data and no board pages.
4975
5189
  if ('serviceWorker' in navigator && window.isSecureContext) navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {})
5190
+ // 0.9.8: phone notifications. The one per-device button (More on a phone,
5191
+ // Settings → General everywhere); the service worker shows the pushes.
5192
+ mountPush({ api })
package/ui/index.html CHANGED
@@ -46,6 +46,7 @@
46
46
  <a href="#/digest" data-route="digest" role="menuitem"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 18h-5"/> <path d="M18 14h-8"/> <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2"/> <rect width="8" height="4" x="10" y="6" rx="1"/></svg><span>Digest</span></a>
47
47
  <a href="#/whats-new" data-route="whats-new" role="menuitem"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/> <path d="M20 2v4"/> <path d="M22 4h-4"/> <circle cx="4" cy="20" r="2"/></svg><span>What’s new</span><span class="newdot" hidden></span></a>
48
48
  <a href="#/settings" data-route="settings" role="menuitem"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/> <circle cx="12" cy="12" r="3"/></svg><span>Settings</span><span class="topdot" hidden></span></a>
49
+ <div class="push-slot" data-push-slot role="group" aria-label="Notifications" hidden></div>
49
50
  </div>
50
51
  <div class="quicklook" id="quickLook" role="dialog" aria-labelledby="qlTitle" hidden></div>
51
52
  <div class="statuspop" id="statusPop" role="dialog" aria-label="Status" hidden></div>
@@ -56,6 +57,9 @@
56
57
  <div class="cmdk-foot"><span><kbd>↑</kbd><kbd>↓</kbd> to move</span><span><kbd>↵</kbd> to open</span><span><kbd>esc</kbd> to close</span></div>
57
58
  </div>
58
59
  </div>
60
+ <nav class="tabstrip" id="tabStrip" aria-label="Open sessions" hidden></nav>
61
+ <div class="openrow" id="openRow" role="navigation" aria-label="Open sessions" hidden></div>
62
+ <div class="tabpager" id="tabPager" role="navigation" aria-label="Open sessions" hidden></div>
59
63
  <main id="app"></main>
60
64
  <div class="toast" id="toast" role="status" aria-live="polite"></div>
61
65
  <script src="relay-client.js" type="module"></script>
package/ui/live-status.js CHANGED
@@ -12,6 +12,10 @@
12
12
  // └─► failed └─► stopped (the turn ended with no answer)
13
13
  // received/working with nothing new for LIVE_QUIET_MS reads "quiet" until
14
14
  // something happens again; replied and failed are final.
15
+ // A message that arrived while Claude was already working on another one is
16
+ // folded into that turn (folded: the id of the message that owns the turn):
17
+ // it has no Working bubble of its own, and when the turn ends it is
18
+ // "Answered with the reply above".
15
19
 
16
20
  export const LIVE_QUIET_MS = 3 * 60 * 1000
17
21
  export const LIVE_PHASES = ['sent', 'received', 'working', 'replied', 'stopped', 'failed']
@@ -22,7 +26,7 @@ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, '
22
26
  export function initStatus(at = 0) { return { phase: 'sent', at, last: at, tool: null, waiting: null, ran: [], endedAt: null } }
23
27
 
24
28
  // One thing that happened to the message. ev: { type, at, tool?, detail?, asked?, text? }
25
- // delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended
29
+ // delivered | failed | started | tool | tool_done | ask | ask_done | text | reply | ended | folded | answered | lost
26
30
  export function stepStatus(st, ev = {}) {
27
31
  const s = { ...(st || initStatus(ev.at || 0)), ran: [...((st && st.ran) || [])] }
28
32
  const at = ev.at || s.last
@@ -43,6 +47,8 @@ export function stepStatus(st, ev = {}) {
43
47
  case 'ask_done': s.waiting = null; break
44
48
  case 'reply': s.phase = 'replied'; s.tool = null; s.waiting = null; s.endedAt = at; break
45
49
  case 'ended': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = ev.text ? 'replied' : 'stopped'; break
50
+ case 'folded': if (s.phase === 'sent' || s.phase === 'received') s.phase = 'working'; s.folded = ev.owner || true; s.tool = null; s.waiting = null; break
51
+ case 'answered': s.phase = 'replied'; s.folded = ev.owner || s.folded || true; s.tool = null; s.waiting = null; s.endedAt = at; break // folded: the reply above is its answer
46
52
  case 'lost': s.tool = null; s.waiting = null; s.endedAt = at; s.phase = 'stopped'; s.lost = true; break // a restart came in between and no answer is in the transcript
47
53
  default: return s
48
54
  }
@@ -53,22 +59,23 @@ export function stepStatus(st, ev = {}) {
53
59
  // The line under the message: { label, steps, spin, tone, elapsedMs }
54
60
  export function statusView(st, now = Date.now()) {
55
61
  if (!st) return null
56
- const quiet = (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
62
+ const quiet = !st.folded && (st.phase === 'received' || st.phase === 'working') && now - (st.last || st.at) >= LIVE_QUIET_MS
57
63
  const elapsedMs = Math.max(0, (st.endedAt || now) - (st.at || now))
58
64
  let label
59
65
  if (st.phase === 'sent') label = 'Sent, waiting for the session'
60
66
  else if (st.phase === 'received') label = quiet ? 'Received · no word from Claude yet' : 'Received · Claude is starting…'
67
+ else if (st.phase === 'working' && st.folded) label = 'Taken into the turn above'
61
68
  else if (st.phase === 'working') {
62
69
  if (st.waiting) label = 'Waiting for your OK' + (st.waiting.tool ? ' (' + st.waiting.tool + (st.waiting.detail ? ': ' + st.waiting.detail : '') + ')' : '')
63
70
  else if (st.tool) label = 'Claude is working… (running ' + st.tool.name + (st.tool.detail ? ': ' + st.tool.detail : '') + ')'
64
71
  else label = quiet ? 'Claude has been quiet for a while (check the terminal)' : 'Claude is working…'
65
- } else if (st.phase === 'replied') label = 'Replied'
72
+ } else if (st.phase === 'replied') label = st.folded ? 'Answered with the reply above' : 'Replied'
66
73
  else if (st.phase === 'stopped') label = st.lost ? 'No reply recorded' : 'Stopped without a reply'
67
74
  else label = 'Didn’t arrive'
68
75
  const at = { sent: 0, received: 1, working: 2, replied: 3, stopped: 3, failed: 0 }[st.phase] || 0
69
76
  const steps = ['Sent', 'Received', st.phase === 'stopped' ? (st.lost ? 'No reply' : 'Stopped') : 'Working', st.phase === 'stopped' ? null : 'Replied'].filter(Boolean)
70
77
  .map((name, i) => ({ name, state: st.phase === 'failed' ? (i === 0 ? 'bad' : 'todo') : i < at ? 'done' : i === at ? (FINAL.has(st.phase) || st.phase === 'stopped' ? 'done' : 'now') : 'todo' }))
71
- const spin = !quiet && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
78
+ const spin = !quiet && !st.folded && (st.phase === 'sent' || st.phase === 'received' || st.phase === 'working') && !st.waiting
72
79
  const tone = st.phase === 'failed' ? 'bad' : st.phase === 'stopped' || quiet || st.waiting ? 'warn' : st.phase === 'replied' ? 'good' : 'busy'
73
80
  return { label, steps, spin, tone, quiet, elapsedMs, final: FINAL.has(st.phase) || st.phase === 'stopped' }
74
81
  }