@leftoff/cli 0.9.8-beta.20 → 0.9.8-beta.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8-beta.20",
3
+ "version": "0.9.8-beta.22",
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.20' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.8-beta.22' // 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.20",
3
+ "version": "0.9.8-beta.22",
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') {
@@ -840,6 +855,7 @@ async function viewBoard() {
840
855
  }
841
856
  html += repoRow(inSpace, f)
842
857
  html += boardSearch(bq, closedHits, list.length)
858
+ if ((state.boardQ || '').trim() !== bq) return // you typed on while this one loaded: the newer search paints
843
859
  if (bq) {
844
860
  if (list.length) html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
845
861
  paintBoardKeepingSearch(banners() + html)
@@ -850,7 +866,7 @@ async function viewBoard() {
850
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>'
851
867
  } else if (!list.length && shown.length) {
852
868
  const pjName = pj ? (projectsUi.list.find((p) => p.id === pj) || {}).name : null
853
- 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 : '')
854
870
  html += '<div class="empty mt">' + (filter ? 'Nothing ' + esc(FILTERS[filter].label.toLowerCase()) + esc(inWhat) + ' right now.' : 'No sessions' + esc(inWhat) + ' today.') +
855
871
  ' <button type="button" class="linkish" data-action="clear-filters">Show everything</button></div>'
856
872
  } else if (list.length) {
@@ -873,39 +889,63 @@ function boardSearch(q, closedHits, n) {
873
889
  (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') +
874
890
  ' <a class="linkish tx-board-link" href="#/search?q=' + encodeURIComponent(q) + '">' + icon('message-square') + 'Search what was said →</a></div>' : '') + '</div>'
875
891
  }
876
- // A repaint from a live update (or the 30 s refresh) while you type in the
877
- // search box keeps the box focused, with what you typed and the caret.
892
+ // A repaint from a live update (or the 30 s refresh, or your own typing's
893
+ // search) never rebuilds the search box: everything around it is replaced and
894
+ // the box itself stays the same element, so the caret, a selection, a word
895
+ // being composed and the focus are exactly as you left them. (Rebuilding it
896
+ // and putting the caret back moved the caret while you typed: the typing's
897
+ // own search put back where the caret was when it started, after you'd typed on.)
878
898
  function paintBoardKeepingSearch(h) {
879
899
  if (parseRoute().name === 's') return // 0.9.8: a slow board answer never paints over the session you opened
880
900
  const old = document.getElementById('boardQ')
881
- const keep = old && document.activeElement === old ? { v: old.value, pos: old.selectionStart } : null
901
+ const row = old && old.closest('.board-search-row')
902
+ if (row && row.parentNode === $app) {
903
+ const tpl = document.createElement('template')
904
+ tpl.innerHTML = h
905
+ const nrow = tpl.content.querySelector('.board-search-row')
906
+ const nbox = nrow && nrow.querySelector('#boardQ')
907
+ if (nbox && nrow.parentNode === tpl.content) {
908
+ const kids = [...tpl.content.childNodes], at = kids.indexOf(nrow)
909
+ while ($app.firstChild && $app.firstChild !== row) $app.removeChild($app.firstChild)
910
+ while (row.nextSibling) $app.removeChild(row.nextSibling)
911
+ row.before(...kids.slice(0, at))
912
+ row.after(...kids.slice(at + 1))
913
+ // the note under the box (n matches, closed matches) follows the new answer
914
+ for (const n of [...row.childNodes]) if (!n.contains(old)) row.removeChild(n)
915
+ for (const n of [...nrow.childNodes]) if (!n.contains(nbox)) row.appendChild(n)
916
+ // the words change only when you aren't typing (the address changed, Esc cleared it)
917
+ if (document.activeElement !== old && !boardQTimer && old.value !== nbox.value) old.value = nbox.value
918
+ return
919
+ }
920
+ }
921
+ const keep = old && document.activeElement === old ? { v: old.value, a: old.selectionStart, b: old.selectionEnd, d: old.selectionDirection } : null
882
922
  $app.innerHTML = h
883
923
  if (!keep) return
884
924
  const q = document.getElementById('boardQ')
885
925
  if (!q) return
886
926
  if (q.value !== keep.v) q.value = keep.v
887
- q.focus(); try { q.setSelectionRange(keep.pos, keep.pos) } catch {}
927
+ q.focus({ preventScroll: true }); try { q.setSelectionRange(keep.a, keep.b, keep.d || 'none') } catch {}
888
928
  }
889
929
  let boardQTimer = null
890
930
  function boardSearchWire() {
891
931
  const q = document.getElementById('boardQ')
892
- if (!q) return
932
+ if (!q || q.dataset.wired) return // the same box across repaints: wired once
933
+ q.dataset.wired = '1'
893
934
  q.addEventListener('input', () => {
894
935
  clearTimeout(boardQTimer)
895
936
  boardQTimer = setTimeout(async () => {
896
- const pos = q.selectionStart
937
+ boardQTimer = null
938
+ if (!q.isConnected) return
897
939
  state.boardQ = q.value
898
940
  try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
899
941
  state.route = parseRoute()
900
- await viewBoard()
901
- const again = document.getElementById('boardQ')
902
- if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
942
+ await viewBoard() // the box isn't rebuilt, so the caret stays wherever you've typed to since
903
943
  }, 160)
904
944
  })
905
945
  q.addEventListener('keydown', (ev) => {
906
946
  if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) }
907
947
  // 0.9.6: Enter (Search on the phone's keyboard) opens the full results, transcripts included
908
- else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
948
+ else if (ev.key === 'Enter' && q.value.trim() && !ev.isComposing) { ev.preventDefault(); clearTimeout(boardQTimer); boardQTimer = null; location.hash = '#/search?q=' + encodeURIComponent(q.value.trim()) }
909
949
  })
910
950
  }
911
951
 
@@ -4397,6 +4437,15 @@ async function refreshTabSessions() {
4397
4437
  paintTabs()
4398
4438
  } catch {}
4399
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
+ }
4400
4449
  function paintTabs() {
4401
4450
  const views = tabViews()
4402
4451
  const strip = document.getElementById('tabStrip'), row = document.getElementById('openRow'), pager = document.getElementById('tabPager')
@@ -4404,10 +4453,10 @@ function paintTabs() {
4404
4453
  const sh = stripHtml(views, tabsUi.active, tabsUi.route === 'board', esc)
4405
4454
  if (sh !== tabsUi.html.strip) {
4406
4455
  const sc = strip.querySelector('.tab-scroll'), left = sc ? sc.scrollLeft : 0
4407
- 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
4408
4457
  strip.innerHTML = sh; tabsUi.html.strip = sh
4409
4458
  const nsc = strip.querySelector('.tab-scroll'); if (nsc) nsc.scrollLeft = left
4410
- 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 }) }
4411
4460
  }
4412
4461
  strip.hidden = false
4413
4462
  const rh = openRowHtml(views, esc)
@@ -4475,6 +4524,32 @@ document.addEventListener('click', (e) => {
4475
4524
  if (t && !e.metaKey && !e.ctrlKey && t.dataset.tab !== 'board') { e.preventDefault(); goTab(t.getAttribute('href')) }
4476
4525
  })
4477
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
+ }
4478
4553
  // Working in a session keeps its tab: a Live message, typing there, answering Claude there
4479
4554
  const promoteActive = () => { if (tabsUi.active && tabsUi.route === 's') { const m = promote(tabsUi.m, tabsUi.active); if (m !== tabsUi.m) { setTabs(m); paintTabs() } } }
4480
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)
@@ -5273,7 +5348,8 @@ document.addEventListener('click', async (ev) => {
5273
5348
  if (action === 'tl-session') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: btn.dataset.session || null }); return }
5274
5349
  if (action === 'tl-trigger') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, trigger: btn.dataset.trigger || null }); return }
5275
5350
  if (action === 'tl-clear') { saveRepos(window.localStorage, []); location.hash = '#/timeline?repo='; return }
5276
- 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() }
5277
5353
  // Projects (0.9.0): the filter chips, the chip on a card, and the menus
5278
5354
  if (action === 'board-project' || action === 'project-filter') {
5279
5355
  ev.preventDefault(); ev.stopPropagation()
@@ -5901,7 +5977,7 @@ document.addEventListener('change', async (ev) => {
5901
5977
  return cuLoadOptions().then(render)
5902
5978
  }
5903
5979
  if (ev.target.id === 'tlSession') { const tf = state.tlFilter || {}; location.hash = timelineHash({ ...tf, session: ev.target.value || null }); return }
5904
- 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() }
5905
5981
  })
5906
5982
 
5907
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,