@leftoff/cli 0.8.3 → 0.8.6
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 +29 -0
- package/assets/sample-transcript.jsonl +11 -11
- package/bin/leftoff.js +317 -306
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +43 -2
- package/mod/hooks/register.js +17 -4
- package/package.json +1 -1
- package/ui/app.css +63 -0
- package/ui/app.js +183 -16
- package/ui/machines.js +43 -0
- package/ui/prs.js +188 -24
- package/ui/repos.js +19 -0
- package/ui/viewer.js +49 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
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/core.js
CHANGED
|
@@ -55,7 +55,19 @@ export function mergeConfig(base, over) {
|
|
|
55
55
|
return out
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// A time-ordered UUIDv7 (Codex rollouts, some Claude Code ids) starts with
|
|
59
|
+
// the clock: every session in the same few hours shared "01a133" (0.8.6).
|
|
60
|
+
// Those take their short forms from the random tail instead; UUIDv4 ids keep
|
|
61
|
+
// the head, so existing tab markers and folders still match.
|
|
62
|
+
const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
63
|
+
export function isTimeOrderedId(id) { return UUID_V7.test(String(id || '')) }
|
|
58
64
|
export function id8(id) {
|
|
65
|
+
const s = String(id || '')
|
|
66
|
+
if (isTimeOrderedId(s)) return s.replace(/-/g, '').slice(-8).toLowerCase()
|
|
67
|
+
return s.replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toLowerCase() || 'unknown0'
|
|
68
|
+
}
|
|
69
|
+
// What id8 was before 0.8.6 (folders made then are still found by it).
|
|
70
|
+
export function legacyId8(id) {
|
|
59
71
|
return String(id || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toLowerCase() || 'unknown0'
|
|
60
72
|
}
|
|
61
73
|
|
|
@@ -509,7 +521,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
509
521
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
510
522
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
511
523
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
512
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked']
|
|
524
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive']
|
|
513
525
|
|
|
514
526
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
515
527
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
|
@@ -572,7 +584,7 @@ export function parseSessionMd(text) {
|
|
|
572
584
|
// sets the *session's* name with Claude Code's own /rename and lets Claude Code
|
|
573
585
|
// draw it; a raw OSC 2 write would be painted over within a second.
|
|
574
586
|
|
|
575
|
-
export function shortId(id) {
|
|
587
|
+
export function shortId(id) { const k = id8(id); return isTimeOrderedId(id) ? k.slice(-6) : k.slice(0, 6) }
|
|
576
588
|
export function tabMarker(id) { return 'cp:' + shortId(id) }
|
|
577
589
|
|
|
578
590
|
const MARKER_RE = /\s*(?:·|-|\|)?\s*cp:([a-z0-9]{6})\s*$/i
|
|
@@ -792,6 +804,9 @@ export function applyEvent(s, ev) {
|
|
|
792
804
|
if (d.repo && typeof d.repo === 'object') s.repo = { ...(s.repo || {}), ...stripNulls(d.repo) }
|
|
793
805
|
// A title carrying our own tab marker came from the mod's /rename: it is the
|
|
794
806
|
// tab label, not a name the user chose, so it never overwrites the title.
|
|
807
|
+
// 0.8.6: Claude Code's own generated title (the status line hears it as
|
|
808
|
+
// session_name). It names the session unless you named it yourself.
|
|
809
|
+
if (typeof d.generatedTitle === 'string' && d.generatedTitle.trim() && !parseTabTitle(d.generatedTitle).id && !isOwnTabTitle(d.generatedTitle, s) && !['custom', 'manual'].includes(s.titleSource) && d.generatedTitle.trim() !== s.title) { s.title = clip(d.generatedTitle.trim(), 120); s.titleSource = 'claude' }
|
|
795
810
|
if (d.sessionTitle && !parseTabTitle(d.sessionTitle).id && d.sessionTitle !== s.title && !isOwnTabTitle(d.sessionTitle, s)) { s.title = d.sessionTitle; s.titleSource = 'custom' }
|
|
796
811
|
if (d.proc && typeof d.proc === 'object' && Number(d.proc.pid) > 1) {
|
|
797
812
|
s.pid = Number(d.proc.pid)
|
|
@@ -823,6 +838,7 @@ export function applyEvent(s, ev) {
|
|
|
823
838
|
if (s.capture === 'mod') s.lastHeartbeatAt = at
|
|
824
839
|
break
|
|
825
840
|
case 'activity':
|
|
841
|
+
if (d.kind === 'name') break // 0.8.6: a title heard by the status line is not activity
|
|
826
842
|
activity()
|
|
827
843
|
if (d.kind === 'tool' && d.phase === 'post' && s.needsInput && (s.lastNotification === 'permission_prompt' || s.capture === 'codex')) { s.needsInput = false; if (s.capture === 'codex') s.lastNotification = null }
|
|
828
844
|
if (d.kind === 'prompt') {
|
|
@@ -1559,3 +1575,28 @@ export function parseDoneArgs(args) {
|
|
|
1559
1575
|
if (nums.length && nums.every((x) => /^#?\d+$/.test(x))) return { numbers: nums.map((x) => Number(x.replace('#', ''))), text: null, list: inList ? inList[1] : null, undo }
|
|
1560
1576
|
return { numbers: [], text: main.trim() || null, list: inList ? inList[1] : null, undo }
|
|
1561
1577
|
}
|
|
1578
|
+
|
|
1579
|
+
// 0.8.6: /cp prints where this session's card opens (GET /v1/sessions/<id>/links).
|
|
1580
|
+
export function boardLinkLines(l) {
|
|
1581
|
+
if (!l || !l.local) return []
|
|
1582
|
+
const out = ['Board: ' + l.local]
|
|
1583
|
+
if (l.relay) out.push('Phone: ' + l.relay)
|
|
1584
|
+
for (const u of l.tailnet || []) out.push('Tailnet: ' + u)
|
|
1585
|
+
return out
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// 0.8.6: runs nobody sits in front of: `claude -p` / the Agent SDK
|
|
1589
|
+
// (CLAUDE_CODE_ENTRYPOINT sdk-*, or a mod session with no surface) and
|
|
1590
|
+
// `codex exec`. They aren't your work sessions, so the board leaves them out.
|
|
1591
|
+
export function isNonInteractive({ surface = null, entrypoint = null } = {}) {
|
|
1592
|
+
const e = String(entrypoint || '').toLowerCase()
|
|
1593
|
+
if (/^sdk/.test(e) || e === 'print' || e === 'headless') return true
|
|
1594
|
+
return surface === 'headless' || surface === 'codex-exec'
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Our own summarizer's and titler's prompts (buildCheckpointPrompt, TITLE
|
|
1598
|
+
// prompt): a session that starts with one is Leftoff talking to a model.
|
|
1599
|
+
export function isOwnSummarizerPrompt(p) {
|
|
1600
|
+
const t = String(p || '')
|
|
1601
|
+
return /^First prompt:\n/.test(t) || /^Repo: .* · cwd .*\nTitle: .*\nPrevious checkpoint: /.test(t)
|
|
1602
|
+
}
|
package/mod/hooks/register.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
|
|
20
20
|
parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
|
|
21
21
|
listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
|
|
22
|
+
boardLinkLines, isNonInteractive,
|
|
22
23
|
} from './core.js'
|
|
23
24
|
|
|
24
25
|
const PANE_ID = 'checkpoints'
|
|
@@ -27,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
27
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
28
29
|
|
|
29
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
30
|
-
const MOD_VERSION = '0.8.
|
|
31
|
+
const MOD_VERSION = '0.8.6' // keep in step with .claude-plugin/plugin.json
|
|
31
32
|
|
|
32
33
|
const st = {
|
|
33
34
|
internal: false,
|
|
@@ -137,7 +138,9 @@ async function sayHelloAgain($) {
|
|
|
137
138
|
|
|
138
139
|
async function isInternal($) {
|
|
139
140
|
// Leftoff's own claude runs (the summarizer's claude -p, claude plugin …) carry this.
|
|
140
|
-
|
|
141
|
+
if ((await $.env.get('LEFTOFF_INTERNAL')) || (await $.env.get('CHECKPOINTS_INTERNAL'))) return true
|
|
142
|
+
// 0.8.6: `claude -p` / the Agent SDK (CLAUDE_CODE_ENTRYPOINT sdk-*): not a work session.
|
|
143
|
+
try { return isNonInteractive({ entrypoint: await $.env.get('CLAUDE_CODE_ENTRYPOINT') }) } catch { return false }
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
async function loadSetup($) {
|
|
@@ -252,8 +255,18 @@ async function reportModHealth($) {
|
|
|
252
255
|
async function checkpointCommand($, args) {
|
|
253
256
|
const a = parseCheckpointArgs(args)
|
|
254
257
|
const cp = await makeCheckpoint($, 'manual', a.note, a.length)
|
|
255
|
-
|
|
256
|
-
return {}
|
|
258
|
+
const links = await boardLinks($)
|
|
259
|
+
if (!cp) return { text: ['Leftoff: a checkpoint is already being made.', ...links].join('\n') }
|
|
260
|
+
return links.length ? { text: links.join('\n') } : {}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 0.8.6: this session's card on the board, here and (when they're on) from
|
|
264
|
+
// your phone (relay, tailnet); the daemon knows those. boardLinkLines: core.js.
|
|
265
|
+
async function boardLinks($) {
|
|
266
|
+
if (st.internal || !st.sessionId) return []
|
|
267
|
+
const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/links', undefined, 800)
|
|
268
|
+
if (r && r.ok && r.json) return boardLinkLines(r.json)
|
|
269
|
+
return boardLinkLines({ local: 'http://127.0.0.1:' + (st.port || 7429) + '/#/s/' + encodeURIComponent(st.sessionId) })
|
|
257
270
|
}
|
|
258
271
|
|
|
259
272
|
// ------------------------------------------------------------ park (v0.8.2)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
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
|
@@ -1427,6 +1427,44 @@ a.cpr:hover { border-color: var(--line-strong); }
|
|
|
1427
1427
|
.prlink input { flex: 1 1 auto; min-width: 0; font-size: 16px; }
|
|
1428
1428
|
.prs-gh { margin-top: 6px; }
|
|
1429
1429
|
.prs-foot { font-size: 12px; margin-top: 6px; }
|
|
1430
|
+
/* 0.8.5: PR filters (search, repo chips, state/check chips), the why, Quiet, details on tap */
|
|
1431
|
+
.prs-search { margin: 0 0 4px; }
|
|
1432
|
+
.prs-q { width: 100%; max-width: 480px; font-size: 16px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--text); }
|
|
1433
|
+
.prs-chips { margin-top: 8px; }
|
|
1434
|
+
.prs-chips .rchip.ck .rl::before { content: '◇ '; color: var(--faint); }
|
|
1435
|
+
.pr-why { color: var(--muted); }
|
|
1436
|
+
.prrow { cursor: pointer; }
|
|
1437
|
+
.prrow.open { border-color: var(--line-strong); }
|
|
1438
|
+
.prrow a, .prrow button, .prrow .pr-detail { cursor: auto; }
|
|
1439
|
+
.prrow a, .prrow button { cursor: pointer; }
|
|
1440
|
+
.prgroup.quiet > summary { list-style: none; cursor: pointer; }
|
|
1441
|
+
.prgroup.quiet > summary::-webkit-details-marker { display: none; }
|
|
1442
|
+
.prgroup.quiet > summary h2 { color: var(--muted); }
|
|
1443
|
+
.prgroup.quiet > summary h2::before { content: '▸ '; }
|
|
1444
|
+
.prgroup.quiet[open] > summary h2::before { content: '▾ '; }
|
|
1445
|
+
.prgroup.quiet .qhint { font-weight: 400; font-size: 12px; }
|
|
1446
|
+
.pr-detail { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 10px; font-size: 13px; cursor: auto; }
|
|
1447
|
+
.pr-detail.loading, .pr-detail.none { color: var(--muted); }
|
|
1448
|
+
.pd-h { font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
|
1449
|
+
.pd-stats { text-transform: none; letter-spacing: 0; font-family: var(--mono); font-weight: 500; color: var(--muted); }
|
|
1450
|
+
.pd-add { color: var(--green); }
|
|
1451
|
+
.pd-del { color: var(--red); }
|
|
1452
|
+
.pd-checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
1453
|
+
.pd-checks li { display: flex; gap: 7px; align-items: baseline; min-width: 0; }
|
|
1454
|
+
.pd-checks li a { color: var(--text); overflow-wrap: anywhere; }
|
|
1455
|
+
.pd-checks li.failed .pd-ic { color: var(--red); }
|
|
1456
|
+
.pd-checks li.pending .pd-ic { color: var(--yellow); }
|
|
1457
|
+
.pd-st { margin-left: auto; font-size: 12px; color: var(--muted); flex: none; }
|
|
1458
|
+
.pd-pass { color: var(--green); font-size: 12.5px; margin-top: 2px; }
|
|
1459
|
+
.pd-review { display: flex; flex-wrap: wrap; gap: 4px 10px; }
|
|
1460
|
+
.pd-text, .pd-clip { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text2); }
|
|
1461
|
+
.pd-body > summary { list-style: none; cursor: pointer; }
|
|
1462
|
+
.pd-body > summary::-webkit-details-marker { display: none; }
|
|
1463
|
+
.pd-body[open] > summary { display: none; }
|
|
1464
|
+
.pd-more { margin-left: 6px; }
|
|
1465
|
+
.pd-comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
1466
|
+
.pd-who { font-size: 12px; font-weight: 600; }
|
|
1467
|
+
.pd-cbody { color: var(--text2); overflow-wrap: anywhere; }
|
|
1430
1468
|
@media (max-width: 639.98px) {
|
|
1431
1469
|
.tabs { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
1432
1470
|
.prrow { flex-direction: column; gap: 8px; }
|
|
@@ -1567,3 +1605,28 @@ html.dv-open, html.dv-open body { overflow: hidden; }
|
|
|
1567
1605
|
.rchip .rx { margin-left: 4px; font-size: 11px; opacity: .7; }
|
|
1568
1606
|
.syncdetails .joinline { margin-top: 4px; }
|
|
1569
1607
|
.syncdetails code.cmd { font-family: var(--mono); font-size: 12px; background: var(--bg2); border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; overflow-wrap: anywhere; min-width: 0; }
|
|
1608
|
+
|
|
1609
|
+
/* 0.8.6: board search */
|
|
1610
|
+
.board-search-row { margin: 12px 0 0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
1611
|
+
.board-search { flex: 1 1 280px; max-width: 420px; }
|
|
1612
|
+
.board-search-note { color: var(--muted); font-size: 13px; }
|
|
1613
|
+
@media (max-width: 640px) { .board-search { max-width: none; flex-basis: 100%; } }
|
|
1614
|
+
|
|
1615
|
+
/* 0.8.6: machine emojis */
|
|
1616
|
+
.chip.machine { cursor: pointer; font: inherit; }
|
|
1617
|
+
.chip.machine .memo { font-size: 1.05em; line-height: 1; }
|
|
1618
|
+
.emoji-sheet-wrap { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; }
|
|
1619
|
+
.emoji-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, .35); }
|
|
1620
|
+
.emoji-sheet { position: relative; width: min(380px, calc(100vw - 24px)); background: var(--card, var(--bg)); color: var(--text); border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: 0 12px 40px rgba(0, 0, 0, .25); }
|
|
1621
|
+
.emoji-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-size: 15px; }
|
|
1622
|
+
.emoji-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 6px; }
|
|
1623
|
+
.emoji-opt { height: 44px; font-size: 24px; line-height: 1; border: 1px solid var(--line); border-radius: 10px; background: var(--field); cursor: pointer; }
|
|
1624
|
+
.emoji-opt:hover { border-color: var(--line-strong); }
|
|
1625
|
+
.emoji-opt.on { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
|
|
1626
|
+
.emoji-own { display: flex; gap: 8px; margin-top: 12px; }
|
|
1627
|
+
.emoji-own input { flex: 1; min-width: 0; height: 40px; padding: 0 10px; font: inherit; font-size: 16px; background: var(--field); color: var(--text); border: 1px solid var(--line); border-radius: 10px; }
|
|
1628
|
+
.emoji-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 10px; font-size: 12.5px; flex-wrap: wrap; }
|
|
1629
|
+
@media (max-width: 599.98px) {
|
|
1630
|
+
.emoji-sheet-wrap { align-items: flex-end; }
|
|
1631
|
+
.emoji-sheet { width: 100vw; border-radius: 16px 16px 0 0; padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
|
|
1632
|
+
}
|
package/ui/app.js
CHANGED
|
@@ -4,12 +4,13 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
5
|
import { selectHtml, comboHtml, closeMenu } from './picker.js'
|
|
6
6
|
import { SETTINGS, settingsKeyFor } from './settings-nav.js'
|
|
7
|
-
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst } from './repos.js'
|
|
7
|
+
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches } from './repos.js'
|
|
8
8
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
9
9
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
10
10
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
11
|
-
import { cardPrsHtml, sessionPrsSection, prsPageHtml } from './prs.js'
|
|
12
|
-
import { wantsSetupBanner } from './viewer.js'
|
|
11
|
+
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
12
|
+
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
|
|
13
|
+
import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
|
|
13
14
|
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
|
|
14
15
|
const $app = document.getElementById('app')
|
|
15
16
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
@@ -159,7 +160,7 @@ function where(s) {
|
|
|
159
160
|
const branch = s.repo && s.repo.branch
|
|
160
161
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
161
162
|
return '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
|
|
162
|
-
(multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<
|
|
163
|
+
(multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<button type="button" class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '" data-action="machine-emoji" data-machine="' + esc(s.machine) + '"><span class="memo">' + esc(mEmoji(s.machine)) + '</span> ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</button>' })() : '') + modelChip(s)
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
// 0.6.8: a session that ran through Ollama (or another server) says so on its card.
|
|
@@ -214,7 +215,13 @@ function syncBanners(h) {
|
|
|
214
215
|
out += '<div class="banner warn lowdisk" role="status"><div class="grow"><b>' + esc(d.text) + '</b><div class="fixline">fix: ' + esc(d.fix.text) + '</div></div>' + (d.fix.cmd ? '<button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(d.fix.cmd) + '" title="' + esc(d.fix.cmd) + '">Copy</button>' : '') + '</div>'
|
|
215
216
|
}
|
|
216
217
|
const v = h.viewer || {}
|
|
217
|
-
|
|
218
|
+
const who = { viewer: v, ua: navigator.userAgent, maxTouchPoints: navigator.maxTouchPoints || 0, dismissed: setupDismissed() }
|
|
219
|
+
// Ask this computer's own Leftoff once; the banner waits for the answer.
|
|
220
|
+
if (setupCandidate(who) && state.localHere === undefined) {
|
|
221
|
+
state.localHere = null
|
|
222
|
+
localLeftoffAnswers().then((here) => { state.localHere = here; if (!here) render() })
|
|
223
|
+
}
|
|
224
|
+
if (wantsSetupBanner({ ...who, here: state.localHere })) {
|
|
218
225
|
const su = v.setup
|
|
219
226
|
out += '<div class="banner info setup-mac" role="status"><div class="grow">This board is running on <b>' + esc(su.machine || 'another Mac') + '</b>. To track sessions on this computer too, run:<div class="joinline"><code class="cmd">' + esc(su.command) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(su.command) + '">Copy</button></div></div>' +
|
|
220
227
|
'<button type="button" class="btn x" data-action="dismiss-setup" aria-label="Dismiss" title="Dismiss">✕</button></div>'
|
|
@@ -226,6 +233,38 @@ function setupDismissed() { try { return window.localStorage.getItem(SETUP_KEY)
|
|
|
226
233
|
|
|
227
234
|
// The board's sync chip: quiet when all's well, amber or red when Syncthing
|
|
228
235
|
// reports an error, a disk runs low, or another Mac has been away 15+ min.
|
|
236
|
+
// 0.8.6: each Mac's emoji (the daemon sends the map; a new name gets its default here).
|
|
237
|
+
function mEmoji(m) {
|
|
238
|
+
const map = (state.health && state.health.machineEmojis) || {}
|
|
239
|
+
return map[m] || defaultEmoji(m, new Set(Object.values(map)))
|
|
240
|
+
}
|
|
241
|
+
// Tap a Mac's chip: our own emoji picker (a sheet on a phone), never a native control.
|
|
242
|
+
function openEmojiSheet(machine) {
|
|
243
|
+
closeEmojiSheet()
|
|
244
|
+
const cur = mEmoji(machine)
|
|
245
|
+
const el = document.createElement('div')
|
|
246
|
+
el.className = 'emoji-sheet-wrap'
|
|
247
|
+
el.innerHTML = '<div class="emoji-backdrop" data-action="emoji-close"></div><div class="emoji-sheet" role="dialog" aria-modal="true" aria-label="Emoji for ' + esc(machine) + '">' +
|
|
248
|
+
'<div class="emoji-head"><b>' + esc(cur) + ' ' + esc(machine) + '</b><button type="button" class="linkish" data-action="emoji-close" aria-label="Close">✕</button></div>' +
|
|
249
|
+
'<div class="emoji-grid">' + PICKER_SET.map((e) => '<button type="button" class="emoji-opt' + (e === cur ? ' on' : '') + '" data-action="emoji-pick" data-machine="' + esc(machine) + '" data-emoji="' + esc(e) + '" aria-label="' + esc(e) + '">' + esc(e) + '</button>').join('') + '</div>' +
|
|
250
|
+
'<div class="emoji-own"><input id="emojiOwn" type="text" inputmode="text" maxlength="16" placeholder="or type any emoji" aria-label="Any emoji" autocomplete="off" autocapitalize="off" spellcheck="false">' +
|
|
251
|
+
'<button type="button" class="btn primary" data-action="emoji-own" data-machine="' + esc(machine) + '">Use</button></div>' +
|
|
252
|
+
'<div class="emoji-foot"><button type="button" class="linkish" data-action="emoji-pick" data-machine="' + esc(machine) + '" data-emoji="">Back to the default</button><span class="faint">Every Mac and your phone show the same one.</span></div></div>'
|
|
253
|
+
document.body.appendChild(el)
|
|
254
|
+
const first = el.querySelector('.emoji-opt.on') || el.querySelector('.emoji-opt')
|
|
255
|
+
if (first) first.focus()
|
|
256
|
+
el.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') closeEmojiSheet() })
|
|
257
|
+
}
|
|
258
|
+
function closeEmojiSheet() { document.querySelectorAll('.emoji-sheet-wrap').forEach((x) => x.remove()) }
|
|
259
|
+
async function setMachineEmoji(machine, emoji) {
|
|
260
|
+
try {
|
|
261
|
+
const r = await api('POST', '/v1/machines/emoji', { machine, emoji: emoji || null })
|
|
262
|
+
if (state.health) state.health.machineEmojis = r.machineEmojis
|
|
263
|
+
closeEmojiSheet()
|
|
264
|
+
render()
|
|
265
|
+
} catch (e) { toast(e.message, 5000) }
|
|
266
|
+
}
|
|
267
|
+
|
|
229
268
|
function syncChip() {
|
|
230
269
|
const sy = (state.health && state.health.sync) || null
|
|
231
270
|
if (!sy || sy.level === 'ok' || !(sy.items || []).length) return ''
|
|
@@ -403,6 +442,7 @@ const FILTERS = {
|
|
|
403
442
|
function boardFilter() {
|
|
404
443
|
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
405
444
|
const f = readFilter(params, window.localStorage)
|
|
445
|
+
state.boardQ = (params.get('q') || '').slice(0, 200) // 0.8.6: board search
|
|
406
446
|
// A bare #/ restored from localStorage: put it in the address bar so the
|
|
407
447
|
// view is linkable as it stands.
|
|
408
448
|
if (f.from === 'storage') history.replaceState(null, '', hashFor(f))
|
|
@@ -420,6 +460,7 @@ function setBoardFilter(next) {
|
|
|
420
460
|
function hashFor(f) {
|
|
421
461
|
let h = filterHash(f)
|
|
422
462
|
if (state.boardSpace) h += (h.includes('?') ? '&' : '?') + 'space=' + encodeURIComponent(state.boardSpace)
|
|
463
|
+
if (state.boardQ && f.status !== 'closed') h += (h.includes('?') ? '&' : '?') + 'q=' + encodeURIComponent(state.boardQ)
|
|
423
464
|
return state.route && state.route.name === 'waiting' ? h.replace(/^#\//, '#/waiting') : h
|
|
424
465
|
}
|
|
425
466
|
|
|
@@ -489,10 +530,14 @@ async function viewBoard() {
|
|
|
489
530
|
const inSpace = sp ? shown.filter((s) => s.space === sp) : shown
|
|
490
531
|
const inRepos = applyFilter(inSpace, { repos: f.repos })
|
|
491
532
|
const count = (k) => k === 'closed' ? (closedCount == null ? '—' : closedCount) : inRepos.filter((s) => inStatus(s, k)).length
|
|
492
|
-
const
|
|
533
|
+
const bq = (state.boardQ || '').trim()
|
|
534
|
+
// 0.8.6: a search looks through every session the board has (not only today's), folded ones too
|
|
535
|
+
const list = filter === 'closed' ? [] : bq ? applyFilter(sp ? sessions.filter((s) => s.space === sp) : sessions, f).filter((s) => searchMatches(s, bq)).sort((a, b) => (rank[a.status] - rank[b.status]) || (Date.parse(b.lastActivityAt || 0) - Date.parse(a.lastActivityAt || 0))) : applyFilter(inSpace, f)
|
|
536
|
+
let closedHits = 0
|
|
537
|
+
if (bq && filter !== 'closed') { try { const c = await api('GET', '/v1/sessions/closed?limit=1&q=' + encodeURIComponent(bq) + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : '')); closedHits = c.total || 0 } catch {} }
|
|
493
538
|
let html = '<div class="pagehead"><div><h1>Sessions</h1><div class="sub">Every Claude Code session on ' + (multi() ? 'your machines' : 'this machine') + (agentNames().length ? ', plus ' + esc(agentNames().join(' and ')) : '') + ', grouped by what needs you.</div>' + syncChip() + '</div>' +
|
|
494
539
|
'<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
|
|
495
|
-
options: [['', 'All machines'], ...state.health.machines.map((m) => [m, m +
|
|
540
|
+
options: [['', 'All machines'], ...state.health.machines.map((m) => [m, mEmoji(m) + ' ' + m + thisOneSuffix(m, state.health)]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
|
|
496
541
|
'<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
|
|
497
542
|
'<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
|
|
498
543
|
html += syncDetails() + staleLine(sessions)
|
|
@@ -511,6 +556,13 @@ async function viewBoard() {
|
|
|
511
556
|
return closedWire()
|
|
512
557
|
}
|
|
513
558
|
html += repoRow(inSpace, f)
|
|
559
|
+
html += boardSearch(bq, closedHits, list.length)
|
|
560
|
+
if (bq) {
|
|
561
|
+
if (list.length) html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
|
|
562
|
+
$app.innerHTML = banners() + html
|
|
563
|
+
revealStat()
|
|
564
|
+
return boardSearchWire()
|
|
565
|
+
}
|
|
514
566
|
if (!shown.length && !empties.length && !parked.length) {
|
|
515
567
|
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>'
|
|
516
568
|
} else if (!list.length && shown.length) {
|
|
@@ -526,6 +578,33 @@ async function viewBoard() {
|
|
|
526
578
|
if (closedCount > 0) html += '<div class="closed-link"><button type="button" class="linkish" data-action="filter" data-filter="closed">Closed (' + esc(closedCount) + ') →</button></div>'
|
|
527
579
|
$app.innerHTML = banners() + html
|
|
528
580
|
revealStat()
|
|
581
|
+
boardSearchWire()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// 0.8.6: search the board. The words live in the URL (?q=), typing keeps the
|
|
585
|
+
// focus, and matches among closed sessions are one tap away.
|
|
586
|
+
function boardSearch(q, closedHits, n) {
|
|
587
|
+
return '<div class="board-search-row"><div class="set-search board-search">' + icon('search') + '<input id="boardQ" type="search" placeholder="Find a session: title, repo, branch, folder or id" aria-label="Find a session" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div>' +
|
|
588
|
+
(q ? '<div class="board-search-note">' + (n ? esc(n + (n === 1 ? ' match' : ' matches')) : 'No open or recent session matches.') +
|
|
589
|
+
(closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') + '</div>' : '') + '</div>'
|
|
590
|
+
}
|
|
591
|
+
let boardQTimer = null
|
|
592
|
+
function boardSearchWire() {
|
|
593
|
+
const q = document.getElementById('boardQ')
|
|
594
|
+
if (!q) return
|
|
595
|
+
q.addEventListener('input', () => {
|
|
596
|
+
clearTimeout(boardQTimer)
|
|
597
|
+
boardQTimer = setTimeout(async () => {
|
|
598
|
+
const pos = q.selectionStart
|
|
599
|
+
state.boardQ = q.value
|
|
600
|
+
try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
|
|
601
|
+
state.route = parseRoute()
|
|
602
|
+
await viewBoard()
|
|
603
|
+
const again = document.getElementById('boardQ')
|
|
604
|
+
if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
|
|
605
|
+
}, 160)
|
|
606
|
+
})
|
|
607
|
+
q.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) } })
|
|
529
608
|
}
|
|
530
609
|
|
|
531
610
|
// Closed (0.6.7): every ended session on every machine, newest first, with
|
|
@@ -882,7 +961,7 @@ async function viewSession(id) {
|
|
|
882
961
|
html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled session') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + wtChips(s) + ' ' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
|
|
883
962
|
'<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
|
|
884
963
|
html += '<div class="facts">' +
|
|
885
|
-
fact('Machine', s.machine + (s.
|
|
964
|
+
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
886
965
|
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
|
|
887
966
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
888
967
|
fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
@@ -933,30 +1012,75 @@ function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') +
|
|
|
933
1012
|
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
934
1013
|
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
935
1014
|
// the Mac that has it) and caches ~5 min; while it loads we look again shortly.
|
|
936
|
-
const prsUi = { fresh: false, polls: 0, timer: null, data: {} }
|
|
1015
|
+
const prsUi = { fresh: false, polls: 0, timer: null, data: {}, at: {}, open: new Set(), details: {}, quietOpen: false, view: 'mine', filter: {} }
|
|
1016
|
+
// 0.8.5: the filters live in the hash (linkable; back/forward work); a chip
|
|
1017
|
+
// tap repaints from what we have and reads again only when that is old.
|
|
1018
|
+
function prsOpts(view, filter) { return { view, mayEdit: true, sessionAction: prSessionAction, filter, open: prsUi.open, details: prsUi.details, quietOpen: prsUi.quietOpen } }
|
|
1019
|
+
function paintPrs(d) {
|
|
1020
|
+
const q = document.getElementById('prs-q')
|
|
1021
|
+
const focused = q && document.activeElement === q, caret = focused ? q.selectionStart : null
|
|
1022
|
+
$app.innerHTML = banners() + prsPageHtml(d, prsOpts(prsUi.view, prsUi.filter))
|
|
1023
|
+
if (focused) { const n = document.getElementById('prs-q'); if (n) { n.focus(); try { n.setSelectionRange(caret, caret) } catch {} } }
|
|
1024
|
+
}
|
|
1025
|
+
// Just the chips and the list (typing in the search box keeps its focus).
|
|
1026
|
+
function paintPrsBody() {
|
|
1027
|
+
const el = document.getElementById('prs-body'), d = prsUi.data[prsUi.view]
|
|
1028
|
+
if (el && d) el.innerHTML = prsBodyHtml(d, prsOpts(prsUi.view, prsUi.filter))
|
|
1029
|
+
}
|
|
937
1030
|
async function viewPrs() {
|
|
938
1031
|
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
939
1032
|
let view = params.get('view')
|
|
940
1033
|
if (!view) { try { view = localStorage.getItem('leftoff.prsView') } catch {} }
|
|
941
1034
|
view = view === 'team' ? 'team' : 'mine'
|
|
942
|
-
|
|
943
|
-
|
|
1035
|
+
prsUi.view = view
|
|
1036
|
+
prsUi.filter = readPrFilter(params)
|
|
1037
|
+
const cached = prsUi.data[view]
|
|
1038
|
+
paintPrs(cached || null)
|
|
944
1039
|
const fresh = prsUi.fresh; prsUi.fresh = false
|
|
1040
|
+
if (cached && !fresh && !cached.loading && !cached.error && Date.now() - (prsUi.at[view] || 0) < 15000) return
|
|
945
1041
|
let d
|
|
946
1042
|
try { d = await api('GET', '/v1/prs?view=' + view + (fresh ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
947
1043
|
catch (e) { if (e.code === 'login_required') throw e; d = { error: 'Couldn’t load PRs: ' + e.message } }
|
|
948
1044
|
if (!(state.route && state.route.name === 'prs')) return
|
|
949
|
-
if (d && d.groups) {
|
|
1045
|
+
if (d && (d.rows || d.groups)) {
|
|
950
1046
|
// Go / Resume on a row look the session up like a card does.
|
|
951
|
-
for (const
|
|
1047
|
+
for (const r of prRows(d)) for (const x of r.sessions || []) if (!state.sessions.some((y) => y.id === x.id)) state.sessions.push(x)
|
|
952
1048
|
d.multiMachine = new Set(state.sessions.map((x) => x.machine).filter(Boolean)).size > 1
|
|
953
1049
|
}
|
|
954
1050
|
prsUi.data[view] = d
|
|
955
|
-
|
|
1051
|
+
prsUi.at[view] = Date.now()
|
|
1052
|
+
if (prsUi.view === view) paintPrs(d)
|
|
956
1053
|
clearTimeout(prsUi.timer)
|
|
957
|
-
if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') render() }, 2500) }
|
|
1054
|
+
if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') { prsUi.at[view] = 0; render() } }, 2500) }
|
|
958
1055
|
else if (!(d && d.loading)) prsUi.polls = 0
|
|
959
1056
|
}
|
|
1057
|
+
// Tap a PR: it opens in place; its details load once (cached ~5 min by the
|
|
1058
|
+
// daemon that has gh, a little here too).
|
|
1059
|
+
async function togglePr(url, { retry = false } = {}) {
|
|
1060
|
+
if (!url) return
|
|
1061
|
+
const was = prsUi.open.has(url)
|
|
1062
|
+
if (was && !retry) prsUi.open.delete(url); else prsUi.open.add(url)
|
|
1063
|
+
const repaint = () => {
|
|
1064
|
+
const d = prsUi.data[prsUi.view]
|
|
1065
|
+
const r = prRows(d).find((x) => x.pr.url === url)
|
|
1066
|
+
const el = [...document.querySelectorAll('article.prrow[data-pr]')].find((a) => a.dataset.pr === url)
|
|
1067
|
+
if (el && r) el.outerHTML = prRowHtml(r, { ...prsOpts(prsUi.view, prsUi.filter), team: prsUi.view === 'team', now: Date.now(), showMachine: !!d.multiMachine, open: prsUi.open.has(url), detail: prsUi.details[url] })
|
|
1068
|
+
}
|
|
1069
|
+
const have = prsUi.details[url]
|
|
1070
|
+
if (!prsUi.open.has(url) || (have && !retry && (have.loading || (have.ok && Date.now() - have.at < 60000)))) return repaint()
|
|
1071
|
+
prsUi.details[url] = { loading: true }
|
|
1072
|
+
repaint()
|
|
1073
|
+
let res
|
|
1074
|
+
try { res = await api('GET', '/v1/prs/detail?url=' + encodeURIComponent(url) + (retry ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
1075
|
+
catch (e) { if (e.code === 'login_required') throw e; res = { ok: false, code: 'failed' } }
|
|
1076
|
+
prsUi.details[url] = { ...res, at: Date.now() }
|
|
1077
|
+
if (state.route && state.route.name === 'prs') repaint()
|
|
1078
|
+
}
|
|
1079
|
+
function setPrsFilter(f) {
|
|
1080
|
+
const h = prsHash(prsUi.view, f)
|
|
1081
|
+
if (location.hash !== h) location.hash = h
|
|
1082
|
+
}
|
|
1083
|
+
|
|
960
1084
|
function prSessionAction(s) {
|
|
961
1085
|
if (s.live === 'closed' || s.status === 'ended') {
|
|
962
1086
|
const b = resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty })
|
|
@@ -2840,6 +2964,25 @@ document.addEventListener('click', (ev) => {
|
|
|
2840
2964
|
location.hash = c.dataset.href
|
|
2841
2965
|
})
|
|
2842
2966
|
|
|
2967
|
+
// 0.8.5: a tap anywhere on a PR row (not a link or button) opens it in place.
|
|
2968
|
+
document.addEventListener('click', (ev) => {
|
|
2969
|
+
const row = ev.target.closest('article.prrow[data-pr]')
|
|
2970
|
+
if (!row || ev.defaultPrevented || ev.target.closest('a, button, input, textarea, label, summary, details.pd-body, [data-action]')) return
|
|
2971
|
+
if (String(window.getSelection && window.getSelection()).trim()) return
|
|
2972
|
+
togglePr(row.dataset.pr)
|
|
2973
|
+
})
|
|
2974
|
+
document.addEventListener('toggle', (ev) => { if (ev.target && ev.target.matches && ev.target.matches('details.prgroup.quiet')) prsUi.quietOpen = ev.target.open }, true)
|
|
2975
|
+
let prsQTimer = null
|
|
2976
|
+
document.addEventListener('input', (ev) => {
|
|
2977
|
+
if (ev.target.id !== 'prs-q') return
|
|
2978
|
+
prsUi.filter = { ...prsUi.filter, q: ev.target.value }
|
|
2979
|
+
paintPrsBody()
|
|
2980
|
+
// The link follows without a history entry per keystroke.
|
|
2981
|
+
clearTimeout(prsQTimer)
|
|
2982
|
+
prsQTimer = setTimeout(() => { const h = prsHash(prsUi.view, prsUi.filter); if (location.hash !== h) history.replaceState(null, '', h) }, 250)
|
|
2983
|
+
})
|
|
2984
|
+
document.addEventListener('keydown', (ev) => { if (ev.target.id === 'prs-q' && ev.key === 'Escape' && ev.target.value) { ev.preventDefault(); ev.target.value = ''; ev.target.dispatchEvent(new Event('input', { bubbles: true })) } })
|
|
2985
|
+
|
|
2843
2986
|
document.addEventListener('click', async (ev) => {
|
|
2844
2987
|
const btn = ev.target.closest('[data-action]')
|
|
2845
2988
|
if (!btn) return
|
|
@@ -2853,7 +2996,17 @@ document.addEventListener('click', async (ev) => {
|
|
|
2853
2996
|
try {
|
|
2854
2997
|
if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
|
|
2855
2998
|
// PRs (0.6.9)
|
|
2856
|
-
if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = v
|
|
2999
|
+
if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = prsHash(v, prsUi.filter); return }
|
|
3000
|
+
// 0.8.5: filters (in the hash), expand a PR in place, the Quiet group
|
|
3001
|
+
if (action === 'prs-filter') {
|
|
3002
|
+
const f = { ...prsUi.filter }, k = btn.dataset.k, v = btn.dataset.v || ''
|
|
3003
|
+
if (k === 'repo') f.repos = v ? toggleRepo(f.repos || [], { key: btn.dataset.key || v, label: v }, ev.shiftKey || ev.metaKey || ev.ctrlKey) : []
|
|
3004
|
+
else if (k === 'state' || k === 'checks') f[k] = f[k] === v ? null : v
|
|
3005
|
+
return setPrsFilter(f)
|
|
3006
|
+
}
|
|
3007
|
+
if (action === 'prs-clear') { const q = document.getElementById('prs-q'); if (q) q.value = ''; return setPrsFilter({}) }
|
|
3008
|
+
if (action === 'pr-expand') return togglePr(btn.dataset.url)
|
|
3009
|
+
if (action === 'pr-expand-retry') return togglePr(btn.dataset.url, { retry: true })
|
|
2857
3010
|
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
2858
3011
|
if (action === 'pr-unlink') {
|
|
2859
3012
|
btn.disabled = true
|
|
@@ -3012,6 +3165,19 @@ document.addEventListener('click', async (ev) => {
|
|
|
3012
3165
|
toast(u.error ? 'Couldn’t check: ' + u.error : u.available ? 'Update available: ' + (u.remote.version ? 'v' + u.remote.version : u.remote.sha.slice(0, 7)) : 'You’re up to date')
|
|
3013
3166
|
await refreshHealth(); return render()
|
|
3014
3167
|
}
|
|
3168
|
+
if (action === 'machine-emoji') { ev.preventDefault(); ev.stopPropagation(); return openEmojiSheet(btn.dataset.machine) }
|
|
3169
|
+
if (action === 'emoji-close') return closeEmojiSheet()
|
|
3170
|
+
if (action === 'emoji-pick') return setMachineEmoji(btn.dataset.machine, btn.dataset.emoji)
|
|
3171
|
+
if (action === 'emoji-own') {
|
|
3172
|
+
const v = (document.getElementById('emojiOwn') || {}).value || ''
|
|
3173
|
+
if (!isEmoji(v)) return toast('Type one emoji', 3000)
|
|
3174
|
+
return setMachineEmoji(btn.dataset.machine, v.trim())
|
|
3175
|
+
}
|
|
3176
|
+
if (action === 'closed-search') {
|
|
3177
|
+
const f = state.boardFilter || readFilter(state.route && state.route.params, window.localStorage)
|
|
3178
|
+
closedUi.q = btn.dataset.q || ''; closedUi.limit = CLOSED_PAGE; state.boardQ = ''
|
|
3179
|
+
return setBoardFilter({ repos: f.repos, status: 'closed' })
|
|
3180
|
+
}
|
|
3015
3181
|
if (action === 'filter') {
|
|
3016
3182
|
const f = state.boardFilter || readFilter(state.route && state.route.params, window.localStorage)
|
|
3017
3183
|
return setBoardFilter({ repos: f.repos, status: f.status === btn.dataset.filter ? null : btn.dataset.filter })
|
|
@@ -3456,6 +3622,7 @@ let stream = null
|
|
|
3456
3622
|
function connect() {
|
|
3457
3623
|
if (stream) { try { stream.close() } catch {} }
|
|
3458
3624
|
const es = stream = new EventSource('/v1/stream')
|
|
3625
|
+
es.addEventListener('prs.updated', () => { prsUi.at = {} }) // 0.8.5: the PR page reads again on its next paint
|
|
3459
3626
|
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, soon)
|
|
3460
3627
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
3461
3628
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
package/ui/machines.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Machine emojis (0.8.6): each Mac gets one, shown wherever its name is.
|
|
2
|
+
// A default comes from the name (a few words match: night/bat → 🦇,
|
|
3
|
+
// shade/moon → 🌚 …; else a hash into a small set of distinct, widely
|
|
4
|
+
// supported emoji), never the same one for two Macs the board knows when it
|
|
5
|
+
// can help it. You can change it (board: tap the Mac's chip; CLI: `leftoff
|
|
6
|
+
// machine emoji`); the choice lives in the synced folder (machines/<name>.json)
|
|
7
|
+
// so every Mac and the relay show the same one. Shared by the daemon and the board.
|
|
8
|
+
export const EMOJI_SET = ['🦊', '🐙', '🦉', '🐳', '🦄', '🐢', '🦁', '🐝', '🌵', '🍄', '🚀', '⚡', '🌊', '🔥', '🍀', '🎲', '🧊', '🌻', '🐧', '🦖', '🍉', '🎸', '🛸', '🐸']
|
|
9
|
+
const WORDS = [
|
|
10
|
+
[/night|bat|dark/, '🦇'], [/shade|shadow|moon|luna/, '🌚'], [/sun|day|sol/, '🌞'], [/laptop|book|air/, '💻'],
|
|
11
|
+
[/studio|desk|tower|pro|work/, '🖥️'], [/pi$|raspberry/, '🥧'], [/cloud|server|box/, '☁️'], [/home/, '🏠'],
|
|
12
|
+
]
|
|
13
|
+
export const PICKER_SET = [...new Set(['🦇', '🌚', '🌞', '💻', '🖥️', ...EMOJI_SET, '🏠', '☁️', '🥧', '🐱', '🐶', '🍕', '🌈', '⭐'])]
|
|
14
|
+
|
|
15
|
+
function hash(s) { let h = 2166136261; for (const c of String(s)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619) >>> 0 } return h }
|
|
16
|
+
const key = (n) => String(n || '').toLowerCase().replace(/\.local$/, '')
|
|
17
|
+
|
|
18
|
+
// A valid machine emoji: a short run with at least one pictograph (flags too), no letters, digits or markup.
|
|
19
|
+
export function isEmoji(v) {
|
|
20
|
+
const s = String(v == null ? '' : v).trim()
|
|
21
|
+
if (!s || s.length > 16 || /[\s<>&"'`A-Za-z0-9]/.test(s)) return false
|
|
22
|
+
return /\p{Extended_Pictographic}|\p{Regional_Indicator}/u.test(s)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The one a name gets when nobody chose, skipping `taken` when it can.
|
|
26
|
+
export function defaultEmoji(name, taken = new Set()) {
|
|
27
|
+
const k = key(name)
|
|
28
|
+
for (const [re, e] of WORDS) if (re.test(k) && !taken.has(e)) return e
|
|
29
|
+
const start = hash(k) % EMOJI_SET.length
|
|
30
|
+
for (let i = 0; i < EMOJI_SET.length; i++) { const e = EMOJI_SET[(start + i) % EMOJI_SET.length]; if (!taken.has(e)) return e }
|
|
31
|
+
return EMOJI_SET[start]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// names: every machine the board knows; chosen: { name: emoji } from the folder.
|
|
35
|
+
// -> { name: emoji }, stable for a given set of names (sorted, chosen ones first).
|
|
36
|
+
export function machineEmojis(names, chosen = {}) {
|
|
37
|
+
const out = {}
|
|
38
|
+
const taken = new Set()
|
|
39
|
+
const all = [...new Set((names || []).filter(Boolean))].sort((a, b) => key(a).localeCompare(key(b)))
|
|
40
|
+
for (const n of all) if (isEmoji(chosen[n])) { out[n] = chosen[n]; taken.add(chosen[n]) }
|
|
41
|
+
for (const n of all) if (!out[n]) { out[n] = defaultEmoji(n, taken); taken.add(out[n]) }
|
|
42
|
+
return out
|
|
43
|
+
}
|