@leftoff/cli 0.9.8-beta.21 → 0.9.8-beta.23

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/docs/SECURITY.md CHANGED
@@ -79,7 +79,7 @@ You deploy the relay yourself, from `relay/` in this repo, to your own Cloudflar
79
79
 
80
80
  - **On the registry record of each signed-up name: the email address, `plan: "beta"`, `via: "signup"`, the Mac's name and its public signing key, and when it was created.** The email is there for the confirmation link, for "one name per email", and for billing later; it is not shown on the board or sent anywhere else. Names made with the admin token keep `plan: "free"` and no email.
81
81
  - **Pending signups**, for up to 30 minutes plus a day after they expire or are confirmed (so a polling CLI hears what happened): the name, email, Mac name and public key, and **SHA-256 hashes** of the link token and of the CLI's poll secret. The tokens themselves are never stored.
82
- - **Rate-limit stamps** for an hour: a SHA-256 hash of the requester's IP address, and the email address, with the times of their signups (5 an hour per IP, 3 per email).
82
+ - **Rate-limit stamps** for an hour: SHA-256 hashes of the requester's IP address and of its /24 (IPv4) or /64 (IPv6) network, and the email address (with any `+tag` dropped, and dots too for Gmail), with the times of their signups (5 an hour per IP, 15 per network, 3 per email, 200 across the whole relay). A refused signup says which name an email already has only to a Mac that is one of that name's own.
83
83
  - **The confirmation email goes through Resend** (the relay's `RESEND_API_KEY`), so Resend sees the address, the subject and the link, the same as for the site's "Claim your name" emails.
84
84
  - **leftoff.dev sees the name**, when the relay asks who claimed it, and **the name and email** when the relay tells it a signup was confirmed. Both calls carry the shared `RELAY_LOOKUP_KEY` secret as a bearer token; the site's answer to the lookup is the claim's status and email, which the relay compares and doesn't store.
85
85
  - Confirming takes a **POST from the page's Confirm button**; opening the link (a GET) never confirms, so mail scanners and link previews can't. The name is then bound to the Mac's public key from the signup request: only a Mac holding the matching private key can connect as that Mac, exactly like `relay register`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8-beta.21",
3
+ "version": "0.9.8-beta.23",
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.21' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.8-beta.23' // 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.21",
3
+ "version": "0.9.8-beta.23",
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
@@ -1722,6 +1722,8 @@ html.dv-open .lo-menu { z-index: 62; }
1722
1722
  .syncdetails { margin: 0 0 14px; padding: 10px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
1723
1723
  .syncitem.red b { color: var(--red); }
1724
1724
  .stale-line { font-size: 12.5px; margin: -6px 0 12px; }
1725
+ .machine-only { font-size: 13px; margin: 12px 0 -4px; color: var(--text); display: flex; flex-wrap: wrap; align-items: center; gap: 0 6px; }
1726
+ .machine-only .linkish { min-height: 44px; font-weight: 600; }
1725
1727
  .chip.machine.stale { border-style: dashed; }
1726
1728
  .rchip .rx { margin-left: 4px; font-size: 11px; opacity: .7; }
1727
1729
  .syncdetails .joinline { margin-top: 4px; }
@@ -2413,6 +2415,9 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2413
2415
  .tabstrip, .openrow, .tabpager { display: none; }
2414
2416
  .tabstrip[hidden], .openrow[hidden], .tabpager[hidden] { display: none !important; }
2415
2417
  .tab-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2418
+ .strip-tip { position: fixed; z-index: 70; left: 0; top: 0; padding: 4px 8px; border-radius: 7px; background: var(--card2); border: 1px solid var(--line-strong); box-shadow: var(--toast-shadow); color: var(--text2); font-size: 12px; line-height: 1.3; font-weight: 500; white-space: nowrap; pointer-events: none; opacity: 0; visibility: hidden; transform: translateY(-3px); transition: opacity .12s, transform .12s, visibility 0s .12s; }
2419
+ .strip-tip.on { opacity: 1; visibility: visible; transform: none; transition: opacity .12s, transform .12s; }
2420
+ @media (prefers-reduced-motion: reduce) { .strip-tip, .strip-tip.on { transition: none; transform: none; } }
2416
2421
  .preview .tab-t, .tab-s.preview .tab-t, .ochip.preview .tab-t { font-style: italic; }
2417
2422
  .tab-live, .tab-need { flex: none; width: 8px; height: 8px; border-radius: 50%; position: relative; }
2418
2423
  .tab-live { background: var(--green); animation: live-pulse 1.6s ease-in-out infinite; }
@@ -2442,6 +2447,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2442
2447
  .tab-keep .ic { width: 12px; height: 12px; }
2443
2448
  .tab-keep { opacity: 0; }
2444
2449
  .tab-cell:hover .tab-keep, .tab-keep:focus-visible, .tab-cell.on .tab-keep { opacity: 1; }
2450
+ .tab-cell.preview { min-width: 164px; } /* the pin and the ×, with room for the title */
2445
2451
  .tab-cell.dragging { opacity: .5; }
2446
2452
  .tab-cell.drop-before { box-shadow: inset 2px 0 0 var(--accent); }
2447
2453
  .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; }
@@ -2468,7 +2474,7 @@ mark.tx, .co-snip mark, .tx-snip mark, .tx-msg-text mark { background: var(--tx-
2468
2474
  .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; }
2469
2475
  .ochip-cell.preview { border-style: dashed; }
2470
2476
  .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; }
2471
- .ochip-cell:not(.preview) .ochip { padding-right: 2px; }
2477
+ .ochip-cell .ochip { padding-right: 2px; }
2472
2478
  .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; }
2473
2479
  .ochip-x .ic { width: 15px; height: 15px; }
2474
2480
  .tabpager { padding: 4px max(8px, env(safe-area-inset-left)) 0; }
package/ui/app.js CHANGED
@@ -25,7 +25,7 @@ import { mdInline, renderSection } from './changelog-md.js'
25
25
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
26
26
  import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix, isPhoneUa } from './viewer.js'
27
27
  import { restoreAccess, hintHtml, restoreWhen, askLine } from './restore-gate.js'
28
- import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
28
+ import { defaultEmoji, PICKER_SET, isEmoji, boardMachineFilter, machineOnlyLabel } from './machines.js'
29
29
  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
30
30
  import { statusView, elapsedText } from './live-status.js'
31
31
  import { anyPending, shouldPoll, streamDead, isStale, liveSignature, wakeReason, createWake, createWatchdog, HEARTBEAT_MS, POLL_MS, AWAY_MS } from './live-wake.js' // 0.9.8 live after sleep
@@ -330,6 +330,13 @@ function staleLine(sessions) {
330
330
  const ms = [...new Set(sessions.filter((x) => !x.isLocal && x.machine in st).map((x) => x.machine))]
331
331
  return ms.length ? '<div class="stale-line faint">' + ms.map((m) => esc(m) + ': showing data as of ' + esc(st[m] ? ago(st[m]) : 'its last sync')).join(' · ') + '</div>' : ''
332
332
  }
333
+ // 0.9.8: a board filtered to one Mac says so, with the way back to every Mac.
334
+ function machineOnlyLine() {
335
+ if (!state.machine) return ''
336
+ const known = { machines: (state.health && state.health.machines) || [], agents: agentNames() }
337
+ const agent = known.agents.includes(state.machine) && !known.machines.includes(state.machine)
338
+ return '<div class="machine-only" role="status">' + (agent ? '' : mEmoji(state.machine) + ' ') + esc(machineOnlyLabel(state.machine, known)) + ' · <button type="button" class="linkish" data-action="machine-all">Show all Macs</button></div>'
339
+ }
333
340
  function staleNote(machine) {
334
341
  const st = state.health && state.health.sync && state.health.sync.stale
335
342
  if (!st || !machine || !(machine in st)) return ''
@@ -768,9 +775,16 @@ async function projectAction(action, btn) {
768
775
  }
769
776
  }
770
777
  let lastMenuAnchor = null
778
+ // The board's Machine filter, kept in this browser ('' = every Mac).
779
+ function setMachineFilter(m) {
780
+ state.machine = m || ''
781
+ try { if (state.machine) localStorage.setItem('cp.machine', state.machine); else localStorage.removeItem('cp.machine') } catch {}
782
+ }
771
783
 
772
784
  async function viewBoard() {
773
785
  if (!state.health) await refreshHealth()
786
+ // 0.9.8: a saved Machine filter for a Mac (or agent) the board no longer knows is dropped
787
+ if (state.machine && state.health && boardMachineFilter(state.machine, { machines: state.health.machines || [], agents: agentNames() }) !== state.machine) setMachineFilter('')
774
788
  if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
775
789
  const [{ sessions: all, viewer, closedCount, todoCount, parked = [] }] = await Promise.all([api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : '')), loadProjects()])
776
790
  // 0.8.2: parked sessions have their own section (any age), not a status group
@@ -830,6 +844,7 @@ async function viewBoard() {
830
844
  const todoN = f.repos.length || sp || pj ? inRepos.reduce((n, s) => n + ((s.todos && s.todos.length) || 0), 0) : (todoCount || 0)
831
845
  html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
832
846
  '<button type="button" class="stat' + (filter === k ? ' on' : '') + '" data-action="filter" data-filter="' + k + '" aria-pressed="' + (filter === k) + '" title="' + esc(filter === k ? 'Showing ' + fl.label.toLowerCase() + ' · tap to show all' : 'Show ' + fl.label.toLowerCase()) + '"><div class="n">' + count(k) + (k === 'needs' && todoN ? '<span class="todo-subn" title="To-dos handed back to you (not counted as waiting)">' + todoN + (todoN === 1 ? ' to-do' : ' to-dos') + '</span>' : '') + '</div><div class="k"><span class="pill ' + fl.cls + '" aria-hidden="true"></span>' + esc(fl.label) + '</div></button>').join('') + '</div>'
847
+ html += machineOnlyLine() // 0.9.8: in the filter rows, with the spaces and repos it narrows
833
848
  html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
834
849
  html += projectRow(spaced, pj, 'board-project')
835
850
  if (filter === 'closed') {
@@ -851,7 +866,7 @@ async function viewBoard() {
851
866
  html += '<div class="empty mt"><p><strong>No sessions yet.</strong></p><p>Start Claude Code with the mod loaded (<code>claude --plugin-dir ./mod</code>) or install the settings hooks (<code>leftoff init --hooks</code>). Sessions show up here as soon as you send a prompt.</p></div>'
852
867
  } else if (!list.length && shown.length) {
853
868
  const pjName = pj ? (projectsUi.list.find((p) => p.id === pj) || {}).name : null
854
- const inWhat = (pjName ? ' in ' + pjName : '') + (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
869
+ const inWhat = (pjName ? ' in ' + pjName : '') + (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '') + (state.machine ? ' on ' + state.machine : '')
855
870
  html += '<div class="empty mt">' + (filter ? 'Nothing ' + esc(FILTERS[filter].label.toLowerCase()) + esc(inWhat) + ' right now.' : 'No sessions' + esc(inWhat) + ' today.') +
856
871
  ' <button type="button" class="linkish" data-action="clear-filters">Show everything</button></div>'
857
872
  } else if (list.length) {
@@ -4422,6 +4437,15 @@ async function refreshTabSessions() {
4422
4437
  paintTabs()
4423
4438
  } catch {}
4424
4439
  }
4440
+ // What had focus in the strip, to find it again after a repaint: the same control,
4441
+ // else (Keep open is gone once kept) the same tab's ×, else the tab itself.
4442
+ function tabFocusKey(el) {
4443
+ const d = el.dataset, q = (a, v) => '[' + a + '="' + CSS.escape(v) + '"]'
4444
+ if (d.tabClose) return [q('data-tab-close', d.tabClose), q('data-tab', d.tabClose)]
4445
+ if (d.tabKeep) return [q('data-tab-keep', d.tabKeep), q('data-tab-close', d.tabKeep), q('data-tab', d.tabKeep)]
4446
+ if (d.tab) return [q('data-tab', d.tab)]
4447
+ return el.id ? ['#' + CSS.escape(el.id)] : null
4448
+ }
4425
4449
  function paintTabs() {
4426
4450
  const views = tabViews()
4427
4451
  const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
@@ -4429,10 +4453,10 @@ function paintTabs() {
4429
4453
  const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
4430
4454
  if (sh !== tabsUi.html.strip) {
4431
4455
  const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
4432
- const had = strip.contains(document.activeElement) ? (document.activeElement.dataset.tab || document.activeElement.dataset.tabClose || document.activeElement.dataset.tabKeep || document.activeElement.id) : null
4456
+ const had = strip.contains(document.activeElement) ? tabFocusKey(document.activeElement) : null
4433
4457
  strip.innerHTML = sh; tabsUi.html.strip = sh
4434
4458
  const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
4435
- 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 }) }
4459
+ if (had) { const el = had.map((q) => strip.querySelector(q)).find(Boolean); if (el) el.focus({ preventScroll: true }) }
4436
4460
  }
4437
4461
  strip.hidden = false
4438
4462
  const rh = openRowHtml(views, esc)
@@ -4500,6 +4524,32 @@ document.addEventListener('click', (e) => {
4500
4524
  if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
4501
4525
  })
4502
4526
  document.addEventListener('dblclick', (e) => { const t = e.target.closest && e.target.closest('#tabStrip .tab-cell'); if (t) { e.preventDefault(); keepTab(t.dataset.tabId) } })
4527
+ // The strip's buttons name themselves in a styled tip under them (hover after a
4528
+ // beat, keyboard focus at once), not a native title. The strip scrolls sideways,
4529
+ // which clips anything hanging below it, so the tip is one fixed element on the page.
4530
+ {
4531
+ const tip = document.createElement('span'); tip.className = 'strip-tip'; tip.setAttribute('aria-hidden', 'true'); document.body.appendChild(tip)
4532
+ let cur = null, timer = 0
4533
+ const hide = () => { clearTimeout(timer); cur = null; tip.classList.remove('on') }
4534
+ const show = (el, delay) => {
4535
+ clearTimeout(timer); cur = el
4536
+ timer = setTimeout(() => {
4537
+ if (cur !== el || !el.isConnected) return hide()
4538
+ tip.textContent = el.dataset.tip
4539
+ const r = el.getBoundingClientRect(), w = tip.offsetWidth
4540
+ tip.style.left = Math.round(Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2))) + 'px'
4541
+ tip.style.top = Math.round(r.bottom + 4) + 'px'
4542
+ tip.classList.add('on')
4543
+ }, delay)
4544
+ }
4545
+ const tipped = (e) => e.target.closest && e.target.closest('#tabStrip [data-tip]')
4546
+ document.addEventListener('pointerover', (e) => { const el = tipped(e); if (!el) { if (cur) hide() } else if (e.pointerType === 'mouse' && el !== cur) show(el, 350) })
4547
+ document.addEventListener('pointerout', (e) => { const el = tipped(e); if (el && el === cur && !el.contains(e.relatedTarget)) hide() })
4548
+ document.addEventListener('focusin', (e) => { const el = tipped(e); if (el && el.matches(':focus-visible')) show(el, 0); else if (cur) hide() })
4549
+ document.addEventListener('focusout', (e) => { if (tipped(e) === cur) hide() })
4550
+ for (const ev of ['pointerdown', 'scroll', 'hashchange', 'resize']) window.addEventListener(ev, hide, true)
4551
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && cur) hide() })
4552
+ }
4503
4553
  // Working in a session keeps its tab: a Live message, typing there, answering Claude there
4504
4554
  const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
4505
4555
  for (const ev of ['input', 'change', 'submit']) document.addEventListener(ev, (e) => { if (e.target && $app.contains(e.target) && e.target.type !== 'search') promoteActive() }, true)
@@ -5298,7 +5348,8 @@ document.addEventListener('click', async (ev) => {
5298
5348
  if (action === 'tl-session') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: btn.dataset.session || null }); return }
5299
5349
  if (action === 'tl-trigger') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, trigger: btn.dataset.trigger || null }); return }
5300
5350
  if (action === 'tl-clear') { saveRepos(window.localStorage, []); location.hash = '#/timeline?repo='; return }
5301
- if (action === 'clear-filters') { state.moreOpen = false; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: null, project: null }) }
5351
+ if (action === 'clear-filters') { state.moreOpen = false; state.boardSpace = null; saveSpace(null); setMachineFilter(''); return setBoardFilter({ repos: [], status: null, project: null }) }
5352
+ if (action === 'machine-all') { setMachineFilter(''); return render() }
5302
5353
  // Projects (0.9.0): the filter chips, the chip on a card, and the menus
5303
5354
  if (action === 'board-project' || action === 'project-filter') {
5304
5355
  ev.preventDefault(); ev.stopPropagation()
@@ -5926,7 +5977,7 @@ document.addEventListener('change', async (ev) => {
5926
5977
  return cuLoadOptions().then(render)
5927
5978
  }
5928
5979
  if (ev.target.id === 'tlSession') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: ev.target.value || null }); return }
5929
- if (ev.target.id === 'machineFilter') { state.machine = ev.target.value; localStorage.setItem('cp.machine', state.machine); render() }
5980
+ if (ev.target.id === 'machineFilter') { setMachineFilter(ev.target.value); render() }
5930
5981
  })
5931
5982
 
5932
5983
  // Moving between Settings sections only swaps the section shown (v0.5.9): a
package/ui/machines.js CHANGED
@@ -41,3 +41,20 @@ export function machineEmojis(names, chosen = {}) {
41
41
  for (const n of all) if (!out[n]) { out[n] = defaultEmoji(n, taken); taken.add(out[n]) }
42
42
  return out
43
43
  }
44
+
45
+ // 0.9.8: the board's Machine filter is saved in the browser (cp.machine). A
46
+ // filter left on (picked once to find another Mac's session) quietly hid every
47
+ // other Mac's sessions, and with them that Mac's space tab, on that browser
48
+ // only. A saved filter is used only while the board still knows that Mac (or
49
+ // agent); '' = every Mac.
50
+ export function boardMachineFilter(saved, { machines = [], agents = [] } = {}) {
51
+ const m = String(saved || '')
52
+ if (!m) return ''
53
+ if (!machines.length && !agents.length) return m // the board hasn't heard yet
54
+ return machines.includes(m) || agents.includes(m) ? m : ''
55
+ }
56
+ // The line a filtered board shows: whose sessions these are.
57
+ export function machineOnlyLabel(m, { machines = [], agents = [] } = {}) {
58
+ if (!m) return ''
59
+ return agents.includes(m) && !machines.includes(m) ? 'Only ' + m + '’s items' : 'Only ' + m + '’s sessions'
60
+ }
package/ui/tabs.js CHANGED
@@ -143,6 +143,7 @@ export function tabView(t, session, { title, working } = {}) {
143
143
  }
144
144
 
145
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 PIN = '<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>'
146
147
  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
148
  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
149
  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>'
@@ -156,6 +157,8 @@ function marks(v, esc) {
156
157
  function label(v) { return (v.preview ? 'Preview: ' : '') + v.title + (v.working ? ' · Claude is working' : '') + (v.needs ? ' · needs you' : '') }
157
158
 
158
159
  // The strip (700px and up): Board, then the open sessions, then ⋯ (all of them).
160
+ // Every session tab has a × (a preview has its Keep-open pin before it); the
161
+ // buttons' names show as styled tips (data-tip, app.js), not native titles.
159
162
  // The current tab is aria-current="true" (the current item of this set); the rail keeps "page".
160
163
  export function stripHtml(views, activeId, onBoard, esc) {
161
164
  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>'
@@ -165,12 +168,13 @@ export function stripHtml(views, activeId, onBoard, esc) {
165
168
  '<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
169
  '<span class="tab-t">' + esc(v.title) + '</span>' + marks(v, esc) + '</a>' +
167
170
  (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>') +
171
+ ? '<button type="button" class="tab-x tab-keep" data-tab-keep="' + esc(v.id) + '" data-tip="Keep open" aria-label="' + esc('Keep ' + v.title + ' open') + '">' + PIN + '</button>'
172
+ : '') +
173
+ '<button type="button" class="tab-x" data-tab-close="' + esc(v.id) + '" data-tip="Close tab" aria-label="' + esc('Close ' + v.title) + '">' + X + '</button>' +
170
174
  '</div>'
171
175
  }).join('')
172
176
  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>'
177
+ '<button type="button" class="tab-all" id="tabAll" data-tip="All open sessions" aria-label="All open sessions (' + views.length + ')" aria-haspopup="menu"' + (views.length ? '' : ' hidden') + '>' + DOTS + '</button>'
174
178
  }
175
179
 
176
180
  // The menu behind ⋯ (picker.js's action menu): every open session, in order.
@@ -181,12 +185,12 @@ export function allTabsItems(views, activeId) {
181
185
  })))
182
186
  }
183
187
 
184
- // The phone's Open row on the board: the same order, the same marks, the preview in italics.
188
+ // The phone's Open row on the board: the same order, the same marks, the preview in italics, a × on each.
185
189
  export function openRowHtml(views, esc) {
186
190
  if (!views.length) return ''
187
191
  return '<div class="openrow-h"><span>Open</span><span class="faint">' + views.length + '</span></div><div class="openrow-chips">' + views.map((v) =>
188
192
  '<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>'
193
+ '<button type="button" class="ochip-x" data-tab-close="' + esc(v.id) + '" aria-label="' + esc('Close ' + v.title) + '">' + X + '</button></span>').join('') + '</div>'
190
194
  }
191
195
 
192
196
  // A session page on a phone: "2 of 4" with ‹ › (44px) for people who don't swipe,