@leftoff/cli 0.9.8-beta.7 → 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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,7 @@ What changed in Leftoff (called Checkpoints until 0.4.5), newest first. The boar
9
9
  - **Outside events**: CI or a webhook can post into a live session with this Mac's key (`leftoff live key`), signed through your leftoff.dev board.
10
10
  - **Restore from your phone or another Mac**: it asks that Mac first, and nothing opens until someone there confirms.
11
11
  - **Sidebar**: the sections move to a narrow rail on the left, with a count on Needs you. On phones they sit in a bar at the bottom, the rest under More.
12
+ - **Tabs**: sessions open in tabs next to the board. A click opens one italic preview tab that the next click replaces; it stays once you send a Live message, type there, or double-click it. On a phone, an Open row on the board and a swipe between open sessions. The layout follows the window's width, so iPad, Split View and folding keep your tabs.
12
13
  - **Quick look**: hover Needs you (or long-press it on a phone) to see the five longest waits and Allow or Deny a permission prompt right there.
13
14
  - **Pull to refresh** on phones: pull down from the top of any page to read it again.
14
15
  - **Always allow here** on permission prompts, like Claude Code's own "don't ask again". It adds the exact rule (say `Bash(rm /tmp/x)`) to the project's `.claude/settings.local.json`; the ⓘ beside it shows which rule and where. Quick look calls it **Always**.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8-beta.7",
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.7' // 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.7",
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
 
@@ -2328,3 +2328,76 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2328
2328
  .moremenu .push-slot { border-top: 1px solid var(--line); margin-top: 6px; padding: 12px 8px 6px; }
2329
2329
  .moremenu:has(.push-slot:not([hidden])) { width: 270px; }
2330
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,9 +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
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
25
26
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref, docMenuItems, docFileName, renderDoc } from './handoffs.js'
26
27
  const $app = document.getElementById('app')
27
28
  // 0.9.8: the pulsing dots (Live while Claude works, active, a blocking ask) keep their beat through the board's redraws
@@ -3380,6 +3381,7 @@ function parseRoute() {
3380
3381
  async function render() {
3381
3382
  const r = (state.route = parseRoute())
3382
3383
  paintNav(r)
3384
+ tabsOnRoute(r)
3383
3385
  try {
3384
3386
  if (r.name === 's' && r.arg) await viewSession(r.arg)
3385
3387
  else if (r.name === 'waiting') await viewWaiting()
@@ -3394,6 +3396,7 @@ async function render() {
3394
3396
  else if (r.name === 'search') await viewSearch(r.q || '')
3395
3397
  else if (r.name === 'setup') await viewSetup()
3396
3398
  else await viewBoard()
3399
+ tabsAfterRender()
3397
3400
  } catch (e) {
3398
3401
  if (e.code === 'login_required') return showLogin()
3399
3402
  $app.innerHTML = '<div class="empty">Couldn’t load: ' + esc(e.message) + '</div>'
@@ -3501,12 +3504,12 @@ function paintTopDot() {
3501
3504
  }
3502
3505
 
3503
3506
  // ------------------------------------------------------------ the rail, the bottom bar and Quick look (0.9.8)
3504
- // 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
3505
3508
  // bar with the rest under More. Hover the Needs you item (or long-press it on a
3506
3509
  // phone, or press ↓/→ on it) for Quick look: the count and the five longest
3507
3510
  // waits, with Allow / Deny on permission prompts (the Needs you page's own
3508
3511
  // buttons, so the same checks apply and nothing is answered without a click).
3509
- const railMode = () => window.matchMedia('(min-width: 820px)').matches
3512
+ const railMode = () => window.matchMedia('(min-width: 700px)').matches
3510
3513
  function paintNav(r = state.route || parseRoute()) {
3511
3514
  const key = navKey(r.name)
3512
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') })
@@ -3664,6 +3667,214 @@ function closeQuickLook(focusTab = false) {
3664
3667
  window.addEventListener('resize', () => { placeQuickLook(); if (statusOpen()) placeStatus() })
3665
3668
  }
3666
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
+
3667
3878
  // Pull to refresh (phones, 0.9.8): pull down from the top of a page and let go
3668
3879
  // past the line; the page reads its data again, the same as a live update (no
3669
3880
  // full reload unless that fails). Never from a field, the Live box or a menu.
@@ -3672,7 +3883,7 @@ function closeQuickLook(focusTab = false) {
3672
3883
  ptr.className = 'ptr'; ptr.id = 'ptr'; ptr.setAttribute('aria-hidden', 'true'); ptr.hidden = true
3673
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>'
3674
3885
  document.body.appendChild(ptr)
3675
- 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)')
3676
3887
  const overlayOpen = () => qlUi.open || moreOpen() || statusOpen() || !!document.querySelector('.cmdk:not([hidden]), .lo-menu, .doc-viewer:not([hidden]), .emoji-sheet-wrap')
3677
3888
  let pull = null, refreshing = false
3678
3889
  const paint = (step) => {
package/ui/index.html CHANGED
@@ -57,6 +57,9 @@
57
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>
58
58
  </div>
59
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>
60
63
  <main id="app"></main>
61
64
  <div class="toast" id="toast" role="status" aria-live="polite"></div>
62
65
  <script src="relay-client.js" type="module"></script>
package/ui/tabs.js ADDED
@@ -0,0 +1,216 @@
1
+ // Tabs for sessions (0.9.8). The board is always the first tab and can't be
2
+ // closed. Clicking a session opens it in ONE preview tab (its title in italics)
3
+ // that the next click replaces, VS Code style; it stays open (permanent, with a
4
+ // close ×) once you send a Live message in it, type there, choose Keep open or
5
+ // double-click the tab, and a middle-click or ⌘/Ctrl-click opens a session
6
+ // straight as permanent. The open set and its order are kept per device
7
+ // (localStorage) and survive a reload; a deep link opens as the preview.
8
+ //
9
+ // The layout follows the window's WIDTH, never the device: under 700px no strip
10
+ // (an Open row on the board, swipe or ‹ › between open sessions on a session
11
+ // page); 700–1099px the strip with the rail as icons; from 1100px the full rail
12
+ // and the strip. Pure functions over a plain model; app.js wires them.
13
+
14
+ export const TABS_KEY = 'cp.tabs'
15
+ export const MAX_TABS = 40
16
+ export const NARROW_MAX = 699.98 // < 700: phones, a foldable closed
17
+ export const WIDE_MIN = 1100 // ≥ 1100: iPad landscape, a Mac
18
+
19
+ // 'narrow' | 'medium' | 'wide' for a CSS width
20
+ export function layoutFor(width) {
21
+ const w = Number(width) || 0
22
+ return w < 700 ? 'narrow' : w < WIDE_MIN ? 'medium' : 'wide'
23
+ }
24
+
25
+ const clean = (e) => ({ id: String(e.id), title: typeof e.title === 'string' ? e.title.slice(0, 200) : '', preview: !!e.preview, ...(typeof e.hash === 'string' && e.hash.startsWith('#/s/') ? { hash: e.hash } : {}) })
26
+
27
+ // Any stored or hand-made value → a valid model: string ids, no repeats, at most
28
+ // one preview (the last one wins), at most MAX_TABS.
29
+ export function normalize(raw) {
30
+ const list = raw && Array.isArray(raw.tabs) ? raw.tabs : []
31
+ const seen = new Set(), tabs = []
32
+ for (const e of list) {
33
+ if (!e || e.id == null || e.id === '' || seen.has(String(e.id))) continue
34
+ seen.add(String(e.id)); tabs.push(clean(e))
35
+ }
36
+ let pi = -1
37
+ tabs.forEach((t, i) => { if (t.preview) { if (pi >= 0) tabs[pi].preview = false; pi = i } })
38
+ return { tabs: tabs.slice(-MAX_TABS) }
39
+ }
40
+ export function emptyTabs() { return { tabs: [] } }
41
+ export function parseStored(text) {
42
+ try { return normalize(JSON.parse(text || 'null')) } catch { return emptyTabs() }
43
+ }
44
+ export function serialize(m) { return JSON.stringify({ v: 1, tabs: normalize(m).tabs }) }
45
+
46
+ export const indexOf = (m, id) => m.tabs.findIndex((t) => t.id === String(id))
47
+ export const isOpen = (m, id) => indexOf(m, id) >= 0
48
+ export const previewOf = (m) => m.tabs.find((t) => t.preview) || null
49
+
50
+ // Open a session. Already open: it just gets focus (permanent promotes a preview).
51
+ // Not open: as the preview, replacing the old preview in place; or, permanent,
52
+ // appended at the end.
53
+ export function openTab(m, id, { permanent = false, title, hash } = {}) {
54
+ const tabs = m.tabs.map((t) => ({ ...t }))
55
+ id = String(id)
56
+ const i = tabs.findIndex((t) => t.id === id)
57
+ const extra = { ...(title ? { title: String(title).slice(0, 200) } : {}), ...(hash && hash.startsWith('#/s/') ? { hash } : {}) }
58
+ if (i >= 0) {
59
+ Object.assign(tabs[i], extra)
60
+ if (permanent) tabs[i].preview = false
61
+ return { tabs }
62
+ }
63
+ const e = { id, title: '', preview: !permanent, ...extra }
64
+ const pi = tabs.findIndex((t) => t.preview)
65
+ if (!permanent && pi >= 0) tabs[pi] = e
66
+ else tabs.push(e)
67
+ // too many: the oldest permanent tab that isn't this one goes
68
+ while (tabs.length > MAX_TABS) { const k = tabs.findIndex((t) => t.id !== id); tabs.splice(k, 1) }
69
+ return { tabs }
70
+ }
71
+
72
+ // The preview becomes permanent (a Live message, typing there, Keep open, a double-click).
73
+ export function promote(m, id) {
74
+ const i = indexOf(m, id)
75
+ if (i < 0 || !m.tabs[i].preview) return m
76
+ return { tabs: m.tabs.map((t, k) => (k === i ? { ...t, preview: false } : t)) }
77
+ }
78
+
79
+ // Keep a tab's title (shown while its session isn't loaded) and where in it you were.
80
+ export function remember(m, id, { title, hash } = {}) {
81
+ const i = indexOf(m, id)
82
+ if (i < 0) return m
83
+ const t = m.tabs[i]
84
+ const nt = { ...t, ...(title && title !== t.title ? { title: String(title).slice(0, 200) } : {}), ...(hash && hash.startsWith('#/s/') && hash !== t.hash ? { hash } : {}) }
85
+ if (nt.title === t.title && nt.hash === t.hash) return m
86
+ return { tabs: m.tabs.map((x, k) => (k === i ? nt : x)) }
87
+ }
88
+
89
+ // Close a tab. When it was the one you're on, `next` is where to go: the
90
+ // neighbour on the right, else the one on the left, else the board (null).
91
+ export function closeTab(m, id, activeId = null) {
92
+ const i = indexOf(m, id)
93
+ if (i < 0) return { model: m, next: activeId == null ? null : String(activeId) }
94
+ const tabs = m.tabs.filter((_, k) => k !== i)
95
+ let next = activeId == null ? null : String(activeId)
96
+ if (String(id) === next) next = tabs.length ? (tabs[i] || tabs[i - 1]).id : null
97
+ return { model: { tabs }, next }
98
+ }
99
+
100
+ // Move a tab to another position (drag on the strip).
101
+ export function moveTab(m, id, to) {
102
+ const i = indexOf(m, id)
103
+ if (i < 0) return m
104
+ const tabs = m.tabs.slice()
105
+ const [t] = tabs.splice(i, 1)
106
+ tabs.splice(Math.max(0, Math.min(tabs.length, to)), 0, t)
107
+ return { tabs }
108
+ }
109
+
110
+ // Where a session sits among the open ones (the phone's "2 of 4", swipe and ‹ ›).
111
+ export function neighbours(m, id) {
112
+ const i = indexOf(m, id)
113
+ if (i < 0) return { index: 0, count: m.tabs.length, prev: null, next: null }
114
+ return { index: i + 1, count: m.tabs.length, prev: i > 0 ? m.tabs[i - 1].id : null, next: i < m.tabs.length - 1 ? m.tabs[i + 1].id : null }
115
+ }
116
+
117
+ // The session id a hash opens (#/s/<id>, #/s/<id>/live, #/s/<id>?hit=…), or null.
118
+ export function sessionOfHash(hash) {
119
+ const h = String(hash || '').replace(/^#\/?/, '').split('?')[0]
120
+ const p = h.split('/').filter(Boolean)
121
+ if (p[0] !== 's' || !p[1]) return null
122
+ try { return decodeURIComponent(p[1]) } catch { return p[1] }
123
+ }
124
+ // Where a tab goes back to: its own page, keeping /live (not a search hit).
125
+ export function tabHref(t) {
126
+ const base = '#/s/' + encodeURIComponent(t.id)
127
+ if (t.hash && sessionOfHash(t.hash) === t.id && t.hash.split('?')[0].endsWith('/live')) return base + '/live'
128
+ return base
129
+ }
130
+ export function keepHash(hash) { return String(hash || '').split('?')[0] }
131
+
132
+ // What a tab shows for its session: the title, the Live pulse while Claude is
133
+ // working (the Live chip's own signal), a dot when it needs you (the same as
134
+ // Needs you). `session` may be missing (not loaded yet): the kept title, no marks.
135
+ export function tabView(t, session, { title, working } = {}) {
136
+ const s = session || null
137
+ return {
138
+ id: t.id, preview: !!t.preview, href: tabHref(t),
139
+ title: (s && title ? title(s) : '') || t.title || 'Session',
140
+ working: !!(s && working && working(s)),
141
+ needs: !!(s && s.needsYou && !s.archived),
142
+ }
143
+ }
144
+
145
+ const X = '<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="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'
146
+ const BOARD_IC = '<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"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>'
147
+ const DOTS = '<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"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>'
148
+ const PREV = '<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 18-6-6 6-6"/></svg>'
149
+ const NEXT = '<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 18 6-6-6-6"/></svg>'
150
+
151
+ // The marks after a title: a pulsing Live dot, or the red needs-you dot (both when both).
152
+ function marks(v, esc) {
153
+ return (v.working ? '<span class="tab-live" title="Claude is working"><span class="lo-vh">, Claude is working</span></span>' : '') +
154
+ (v.needs ? '<span class="tab-need" title="Needs you"><span class="lo-vh">, needs you</span></span>' : '')
155
+ }
156
+ function label(v) { return (v.preview ? 'Preview: ' : '') + v.title + (v.working ? ' · Claude is working' : '') + (v.needs ? ' · needs you' : '') }
157
+
158
+ // The strip (700px and up): Board, then the open sessions, then ⋯ (all of them).
159
+ // The current tab is aria-current="true" (the current item of this set); the rail keeps "page".
160
+ export function stripHtml(views, activeId, onBoard, esc) {
161
+ const board = '<a class="tab tab-board' + (onBoard ? ' on' : '') + '" href="#/" data-tab="board"' + (onBoard ? ' aria-current="true"' : '') + ' title="Board">' + BOARD_IC + '<span class="tab-t">Board</span></a>'
162
+ const tabs = views.map((v) => {
163
+ const on = v.id === activeId
164
+ return '<div class="tab-cell' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" data-tab-id="' + esc(v.id) + '">' +
165
+ '<a class="tab tab-s' + (on ? ' on' : '') + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '"' + (on ? ' aria-current="true"' : '') + ' title="' + esc(v.title + (v.preview ? ' · preview: double-click to keep it open' : '')) + '" aria-label="' + esc(label(v)) + '" draggable="true">' +
166
+ '<span class="tab-t">' + esc(v.title) + '</span>' + marks(v, esc) + '</a>' +
167
+ (v.preview
168
+ ? '<button type="button" class="tab-x tab-keep" data-tab-keep="' + esc(v.id) + '" title="Keep open" aria-label="' + esc('Keep ' + v.title + ' open') + '"><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="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg></button>'
169
+ : '<button type="button" class="tab-x" data-tab-close="' + esc(v.id) + '" title="Close tab" aria-label="' + esc('Close ' + v.title) + '">' + X + '</button>') +
170
+ '</div>'
171
+ }).join('')
172
+ return '<div class="tabstrip-in">' + board + '<div class="tab-scroll" role="list">' + tabs + '</div></div>' +
173
+ '<button type="button" class="tab-all" id="tabAll" title="All open sessions" aria-label="All open sessions (' + views.length + ')" aria-haspopup="menu"' + (views.length ? '' : ' hidden') + '>' + DOTS + '</button>'
174
+ }
175
+
176
+ // The menu behind ⋯ (picker.js's action menu): every open session, in order.
177
+ export function allTabsItems(views, activeId) {
178
+ return [{ label: 'Board', attrs: { 'data-tab-go': '#/' }, on: activeId == null }].concat(views.map((v) => ({
179
+ label: v.title + (v.preview ? ' (preview)' : ''), sub: [v.working ? 'Claude is working' : '', v.needs ? 'Needs you' : ''].filter(Boolean).join(' · ') || undefined,
180
+ on: v.id === activeId, attrs: { 'data-tab-go': v.href },
181
+ })))
182
+ }
183
+
184
+ // The phone's Open row on the board: the same order, the same marks, the preview in italics.
185
+ export function openRowHtml(views, esc) {
186
+ if (!views.length) return ''
187
+ return '<div class="openrow-h"><span>Open</span><span class="faint">' + views.length + '</span></div><div class="openrow-chips">' + views.map((v) =>
188
+ '<span class="ochip-cell' + (v.preview ? ' preview' : '') + '"><a class="ochip' + (v.preview ? ' preview' : '') + '" href="' + esc(v.href) + '" data-tab="' + esc(v.id) + '" aria-label="' + esc(label(v)) + '"><span class="tab-t">' + esc(v.title) + '</span>' + marks(v, esc) + '</a>' +
189
+ (v.preview ? '' : '<button type="button" class="ochip-x" data-tab-close="' + esc(v.id) + '" aria-label="' + esc('Close ' + v.title) + '" title="Close">' + X + '</button>') + '</span>').join('') + '</div>'
190
+ }
191
+
192
+ // A session page on a phone: "2 of 4" with ‹ › (44px) for people who don't swipe,
193
+ // and Keep open on a preview.
194
+ export function pagerHtml(m, id, views, esc) {
195
+ const n = neighbours(m, id)
196
+ if (!n.index || n.count < 2) {
197
+ const v = views.find((x) => x.id === String(id))
198
+ return v && v.preview ? '<div class="pager-in"><span class="pager-n faint">Preview</span><button type="button" class="btn pager-keep" data-tab-keep="' + esc(v.id) + '">Keep open</button></div>' : ''
199
+ }
200
+ const byId = (x) => views.find((v) => v.id === x)
201
+ const v = byId(String(id))
202
+ const btn = (to, dir) => {
203
+ const t = to ? byId(to) : null
204
+ return t ? '<a class="pager-b pager-' + dir + '" href="' + esc(t.href) + '" data-tab="' + esc(t.id) + '" data-pager="' + dir + '" aria-label="' + esc((dir === 'prev' ? 'Previous: ' : 'Next: ') + t.title) + '" title="' + esc(t.title) + '">' + (dir === 'prev' ? PREV : NEXT) + '</a>'
205
+ : '<span class="pager-b pager-' + dir + ' off" aria-hidden="true">' + (dir === 'prev' ? PREV : NEXT) + '</span>'
206
+ }
207
+ return '<div class="pager-in">' + btn(n.prev, 'prev') + '<span class="pager-n" aria-live="polite">' + n.index + ' of ' + n.count + '<span class="lo-vh"> open sessions</span></span>' +
208
+ (v && v.preview ? '<button type="button" class="btn pager-keep" data-tab-keep="' + esc(v.id) + '">Keep open</button>' : '') + btn(n.next, 'next') + '</div>'
209
+ }
210
+
211
+ // A horizontal swipe on a phone's session page: which way, or null. Mostly
212
+ // sideways (twice as far across as down), at least 60px, quick (under 700ms).
213
+ export function swipeDir(dx, dy, ms) {
214
+ if (Math.abs(dx) < 60 || Math.abs(dx) < 2 * Math.abs(dy) || ms > 700) return null
215
+ return dx < 0 ? 'next' : 'prev'
216
+ }