@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/CHANGELOG.md +4 -0
- package/bin/leftoff.js +313 -313
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +7 -1
- package/ui/app.js +57 -6
- package/ui/machines.js +17 -0
- package/ui/tabs.js +9 -5
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:
|
|
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.
|
|
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"
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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') {
|
|
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) + '"
|
|
169
|
-
: '
|
|
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"
|
|
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
|
-
|
|
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,
|