@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.
- package/CHANGELOG.md +7 -0
- package/bin/leftoff.js +299 -299
- 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 +92 -16
- package/ui/machines.js +17 -0
- package/ui/tabs.js +9 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.8-beta.
|
|
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"
|
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.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.
|
|
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
|
|
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
|
|
877
|
-
// search
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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') {
|
|
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) + '"
|
|
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,
|