@leftoff/cli 0.8.5 → 0.8.7
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 +19 -0
- package/bin/leftoff.js +276 -266
- 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 +48 -0
- package/ui/app.js +154 -21
- package/ui/icons.js +8 -1
- package/ui/machines.js +43 -0
- package/ui/picker.js +106 -2
- package/ui/repos.js +19 -0
- package/ui/viewer.js +15 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
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.7' // 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.7",
|
|
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
|
@@ -1605,3 +1605,51 @@ html.dv-open, html.dv-open body { overflow: hidden; }
|
|
|
1605
1605
|
.rchip .rx { margin-left: 4px; font-size: 11px; opacity: .7; }
|
|
1606
1606
|
.syncdetails .joinline { margin-top: 4px; }
|
|
1607
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
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/* 0.8.7: a card's actions are one row: main button, copy icon, ⋯ (the rest in a menu) */
|
|
1635
|
+
.card > .actions-row { flex-wrap: nowrap; align-items: center; min-width: 0; }
|
|
1636
|
+
.card > .actions-row > .btn:not(.icon-btn), .card > .actions-row > .go-off { min-width: 0; flex: 0 1 auto; }
|
|
1637
|
+
.card > .actions-row > .btn:not(.icon-btn) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; line-height: 1.45; }
|
|
1638
|
+
.card > .actions-row > .btn:not(.icon-btn) .ic { vertical-align: -2px; margin-right: 5px; }
|
|
1639
|
+
.card > .actions-row > .go-off { display: inline-flex; align-items: center; gap: 6px; overflow: hidden; }
|
|
1640
|
+
.card > .actions-row > .go-off .why { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
1641
|
+
.card > .actions-row > .ro { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1642
|
+
.btn.icon-btn { flex: none; width: 30px; height: 28px; padding: 0; justify-content: center; }
|
|
1643
|
+
.btn.icon-btn .ic { width: 15px; height: 15px; margin: 0; }
|
|
1644
|
+
.card > .actions-row > .btn.more { margin-left: auto; }
|
|
1645
|
+
.btn.copy-resume.copied { color: var(--green, #2fbf71); border-color: currentColor; }
|
|
1646
|
+
.lo-actmenu { min-width: 190px; max-width: min(320px, calc(100vw - 16px)); overflow-y: auto; }
|
|
1647
|
+
.lo-act { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: none; color: var(--text2); font: inherit; font-size: 14px; text-align: left; cursor: pointer; white-space: nowrap; }
|
|
1648
|
+
.lo-act .ic { width: 16px; height: 16px; flex: none; color: var(--muted); }
|
|
1649
|
+
.lo-act:hover, .lo-act:focus { outline: none; background: var(--bg2); color: var(--text); }
|
|
1650
|
+
[data-theme="dark"] .lo-act:hover, [data-theme="dark"] .lo-act:focus, :root:not([data-theme="light"]) .lo-act:hover, :root:not([data-theme="light"]) .lo-act:focus { background: var(--line); }
|
|
1651
|
+
.lo-act:focus-visible { box-shadow: inset 0 0 0 2px var(--focus); }
|
|
1652
|
+
.lo-act:disabled { opacity: .55; cursor: progress; }
|
|
1653
|
+
.lo-menu.sheet.lo-actmenu { max-width: none; }
|
|
1654
|
+
.lo-menu.sheet .lo-act { padding: 14px 10px; font-size: 16px; }
|
|
1655
|
+
@media (pointer: coarse) { .btn.icon-btn { width: 36px; height: 34px; } }
|
package/ui/app.js
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
// /v1 API on the same origin (auth rides the cookie set at GET /).
|
|
3
3
|
import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
|
-
import { selectHtml, comboHtml, closeMenu } from './picker.js'
|
|
5
|
+
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey } 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
11
|
import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
|
|
12
|
-
import { wantsSetupBanner, setupCandidate, localLeftoffAnswers } from './viewer.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: {} }
|
|
@@ -97,7 +98,13 @@ function toastUndo(msg, onUndo, ms = 5000) {
|
|
|
97
98
|
toast.t = setTimeout(() => t.classList.remove('show', 'has-action'), ms)
|
|
98
99
|
}
|
|
99
100
|
async function copy(text) {
|
|
100
|
-
try { await navigator.clipboard.writeText(text); toast('Copied: ' + text) } catch { prompt('Copy this:', text) }
|
|
101
|
+
try { await navigator.clipboard.writeText(text); toast('Copied: ' + text); return true } catch { prompt('Copy this:', text); return false }
|
|
102
|
+
}
|
|
103
|
+
// 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
|
|
104
|
+
function copiedMark(btn) {
|
|
105
|
+
clearTimeout(btn._copied)
|
|
106
|
+
btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.title = 'Copied'
|
|
107
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = 'Copy resume command' }, 1500)
|
|
101
108
|
}
|
|
102
109
|
const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
|
|
103
110
|
|
|
@@ -159,7 +166,7 @@ function where(s) {
|
|
|
159
166
|
const branch = s.repo && s.repo.branch
|
|
160
167
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
161
168
|
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 '<
|
|
169
|
+
(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
170
|
}
|
|
164
171
|
|
|
165
172
|
// 0.6.8: a session that ran through Ollama (or another server) says so on its card.
|
|
@@ -232,6 +239,38 @@ function setupDismissed() { try { return window.localStorage.getItem(SETUP_KEY)
|
|
|
232
239
|
|
|
233
240
|
// The board's sync chip: quiet when all's well, amber or red when Syncthing
|
|
234
241
|
// reports an error, a disk runs low, or another Mac has been away 15+ min.
|
|
242
|
+
// 0.8.6: each Mac's emoji (the daemon sends the map; a new name gets its default here).
|
|
243
|
+
function mEmoji(m) {
|
|
244
|
+
const map = (state.health && state.health.machineEmojis) || {}
|
|
245
|
+
return map[m] || defaultEmoji(m, new Set(Object.values(map)))
|
|
246
|
+
}
|
|
247
|
+
// Tap a Mac's chip: our own emoji picker (a sheet on a phone), never a native control.
|
|
248
|
+
function openEmojiSheet(machine) {
|
|
249
|
+
closeEmojiSheet()
|
|
250
|
+
const cur = mEmoji(machine)
|
|
251
|
+
const el = document.createElement('div')
|
|
252
|
+
el.className = 'emoji-sheet-wrap'
|
|
253
|
+
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) + '">' +
|
|
254
|
+
'<div class="emoji-head"><b>' + esc(cur) + ' ' + esc(machine) + '</b><button type="button" class="linkish" data-action="emoji-close" aria-label="Close">✕</button></div>' +
|
|
255
|
+
'<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>' +
|
|
256
|
+
'<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">' +
|
|
257
|
+
'<button type="button" class="btn primary" data-action="emoji-own" data-machine="' + esc(machine) + '">Use</button></div>' +
|
|
258
|
+
'<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>'
|
|
259
|
+
document.body.appendChild(el)
|
|
260
|
+
const first = el.querySelector('.emoji-opt.on') || el.querySelector('.emoji-opt')
|
|
261
|
+
if (first) first.focus()
|
|
262
|
+
el.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') closeEmojiSheet() })
|
|
263
|
+
}
|
|
264
|
+
function closeEmojiSheet() { document.querySelectorAll('.emoji-sheet-wrap').forEach((x) => x.remove()) }
|
|
265
|
+
async function setMachineEmoji(machine, emoji) {
|
|
266
|
+
try {
|
|
267
|
+
const r = await api('POST', '/v1/machines/emoji', { machine, emoji: emoji || null })
|
|
268
|
+
if (state.health) state.health.machineEmojis = r.machineEmojis
|
|
269
|
+
closeEmojiSheet()
|
|
270
|
+
render()
|
|
271
|
+
} catch (e) { toast(e.message, 5000) }
|
|
272
|
+
}
|
|
273
|
+
|
|
235
274
|
function syncChip() {
|
|
236
275
|
const sy = (state.health && state.health.sync) || null
|
|
237
276
|
if (!sy || sy.level === 'ok' || !(sy.items || []).length) return ''
|
|
@@ -369,8 +408,44 @@ function agentCard(s) {
|
|
|
369
408
|
agentListChips(s) + (n ? cardBadge(n) : '') + agentCpBlock(s.latestCheckpoint) +
|
|
370
409
|
'<a class="cplink" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session') + ' →</a>' +
|
|
371
410
|
'<div class="actions-row">' + (n ? agentReplyButton(a, 'btn') : '') +
|
|
372
|
-
'
|
|
411
|
+
roNote('read-only · from ' + (a.display || a.name) + '’s feed') + '</div></article>'
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 0.8.7: a card's actions are one row: its main button (Go, or Resume once it's
|
|
415
|
+
// closed), a small copy icon for the resume command, and "⋯" for the rest. The
|
|
416
|
+
// ⋯ menu's items ride in the card as an inert <template> (real buttons with the
|
|
417
|
+
// same data-action as before), so the one click handler below runs them unchanged.
|
|
418
|
+
function copyResumeIcon(s) {
|
|
419
|
+
return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
|
|
420
|
+
}
|
|
421
|
+
// items: [{ action, label, icon, title, attrs }]
|
|
422
|
+
function moreMenu(s, items) {
|
|
423
|
+
items = items.filter(Boolean)
|
|
424
|
+
if (!items.length) return ''
|
|
425
|
+
return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '" aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
|
|
426
|
+
'<template class="more-items">' + items.map((it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
|
|
427
|
+
Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + '>' +
|
|
428
|
+
(it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>').join('') + '</template>'
|
|
429
|
+
}
|
|
430
|
+
function openCardMenu(btn) {
|
|
431
|
+
const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
|
|
432
|
+
if (!tpl) return
|
|
433
|
+
const card = btn.closest('.card')
|
|
434
|
+
const title = card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : ''
|
|
435
|
+
const items = [...tpl.content.querySelectorAll('button')].map((b) => {
|
|
436
|
+
const svg = b.querySelector('svg')
|
|
437
|
+
const attrs = {}
|
|
438
|
+
for (const a of b.attributes) if (a.name.startsWith('data-') || a.name === 'title') attrs[a.name] = a.value
|
|
439
|
+
return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs }
|
|
440
|
+
})
|
|
441
|
+
openActionMenu(btn, items, { title })
|
|
373
442
|
}
|
|
443
|
+
const sessionMenuItems = (s) => s.isLocal ? [
|
|
444
|
+
{ action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus' },
|
|
445
|
+
{ action: 'pin', label: s.pinned ? 'Unpin' : 'Pin', icon: s.pinned ? 'pin-off' : 'pin' },
|
|
446
|
+
{ action: 'archive', label: 'Archive', icon: 'archive' },
|
|
447
|
+
] : []
|
|
448
|
+
const roNote = (t) => '<span class="ro" title="' + esc(t) + '">' + esc(t) + '</span>'
|
|
374
449
|
|
|
375
450
|
function card(s) {
|
|
376
451
|
if (s.agent) return agentCard(s)
|
|
@@ -386,11 +461,9 @@ function card(s) {
|
|
|
386
461
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
387
462
|
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
388
463
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
389
|
-
'<div class="actions-row">' + goButton(s) +
|
|
390
|
-
|
|
391
|
-
(s.isLocal ? '
|
|
392
|
-
'<button type="button" class="btn" data-action="pin">' + (s.pinned ? 'Unpin' : 'Pin') + '</button>' +
|
|
393
|
-
'<button type="button" class="btn" data-action="archive">Archive</button>' : '<span class="ro">' + (s.peer && s.peer.ok ? 'lives on ' + esc(s.machine) + ' · answering and Go work through it' : 'read-only here · lives on ' + esc(s.machine)) + '</span>') +
|
|
464
|
+
'<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
|
|
465
|
+
copyResumeIcon(s) +
|
|
466
|
+
(s.isLocal ? moreMenu(s, sessionMenuItems(s)) : roNote(s.peer && s.peer.ok ? 'lives on ' + s.machine + ' · answering and Go work through it' : 'read-only here · lives on ' + s.machine)) +
|
|
394
467
|
'</div></article>'
|
|
395
468
|
}
|
|
396
469
|
|
|
@@ -409,6 +482,7 @@ const FILTERS = {
|
|
|
409
482
|
function boardFilter() {
|
|
410
483
|
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
411
484
|
const f = readFilter(params, window.localStorage)
|
|
485
|
+
state.boardQ = (params.get('q') || '').slice(0, 200) // 0.8.6: board search
|
|
412
486
|
// A bare #/ restored from localStorage: put it in the address bar so the
|
|
413
487
|
// view is linkable as it stands.
|
|
414
488
|
if (f.from === 'storage') history.replaceState(null, '', hashFor(f))
|
|
@@ -426,6 +500,7 @@ function setBoardFilter(next) {
|
|
|
426
500
|
function hashFor(f) {
|
|
427
501
|
let h = filterHash(f)
|
|
428
502
|
if (state.boardSpace) h += (h.includes('?') ? '&' : '?') + 'space=' + encodeURIComponent(state.boardSpace)
|
|
503
|
+
if (state.boardQ && f.status !== 'closed') h += (h.includes('?') ? '&' : '?') + 'q=' + encodeURIComponent(state.boardQ)
|
|
429
504
|
return state.route && state.route.name === 'waiting' ? h.replace(/^#\//, '#/waiting') : h
|
|
430
505
|
}
|
|
431
506
|
|
|
@@ -495,10 +570,14 @@ async function viewBoard() {
|
|
|
495
570
|
const inSpace = sp ? shown.filter((s) => s.space === sp) : shown
|
|
496
571
|
const inRepos = applyFilter(inSpace, { repos: f.repos })
|
|
497
572
|
const count = (k) => k === 'closed' ? (closedCount == null ? '—' : closedCount) : inRepos.filter((s) => inStatus(s, k)).length
|
|
498
|
-
const
|
|
573
|
+
const bq = (state.boardQ || '').trim()
|
|
574
|
+
// 0.8.6: a search looks through every session the board has (not only today's), folded ones too
|
|
575
|
+
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)
|
|
576
|
+
let closedHits = 0
|
|
577
|
+
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 {} }
|
|
499
578
|
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>' +
|
|
500
579
|
'<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
|
|
501
|
-
options: [['', 'All machines'], ...state.health.machines.map((m) => [m, m +
|
|
580
|
+
options: [['', 'All machines'], ...state.health.machines.map((m) => [m, mEmoji(m) + ' ' + m + thisOneSuffix(m, state.health)]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
|
|
502
581
|
'<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
|
|
503
582
|
'<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
|
|
504
583
|
html += syncDetails() + staleLine(sessions)
|
|
@@ -517,6 +596,13 @@ async function viewBoard() {
|
|
|
517
596
|
return closedWire()
|
|
518
597
|
}
|
|
519
598
|
html += repoRow(inSpace, f)
|
|
599
|
+
html += boardSearch(bq, closedHits, list.length)
|
|
600
|
+
if (bq) {
|
|
601
|
+
if (list.length) html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
|
|
602
|
+
$app.innerHTML = banners() + html
|
|
603
|
+
revealStat()
|
|
604
|
+
return boardSearchWire()
|
|
605
|
+
}
|
|
520
606
|
if (!shown.length && !empties.length && !parked.length) {
|
|
521
607
|
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>'
|
|
522
608
|
} else if (!list.length && shown.length) {
|
|
@@ -532,6 +618,33 @@ async function viewBoard() {
|
|
|
532
618
|
if (closedCount > 0) html += '<div class="closed-link"><button type="button" class="linkish" data-action="filter" data-filter="closed">Closed (' + esc(closedCount) + ') →</button></div>'
|
|
533
619
|
$app.innerHTML = banners() + html
|
|
534
620
|
revealStat()
|
|
621
|
+
boardSearchWire()
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// 0.8.6: search the board. The words live in the URL (?q=), typing keeps the
|
|
625
|
+
// focus, and matches among closed sessions are one tap away.
|
|
626
|
+
function boardSearch(q, closedHits, n) {
|
|
627
|
+
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>' +
|
|
628
|
+
(q ? '<div class="board-search-note">' + (n ? esc(n + (n === 1 ? ' match' : ' matches')) : 'No open or recent session matches.') +
|
|
629
|
+
(closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') + '</div>' : '') + '</div>'
|
|
630
|
+
}
|
|
631
|
+
let boardQTimer = null
|
|
632
|
+
function boardSearchWire() {
|
|
633
|
+
const q = document.getElementById('boardQ')
|
|
634
|
+
if (!q) return
|
|
635
|
+
q.addEventListener('input', () => {
|
|
636
|
+
clearTimeout(boardQTimer)
|
|
637
|
+
boardQTimer = setTimeout(async () => {
|
|
638
|
+
const pos = q.selectionStart
|
|
639
|
+
state.boardQ = q.value
|
|
640
|
+
try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
|
|
641
|
+
state.route = parseRoute()
|
|
642
|
+
await viewBoard()
|
|
643
|
+
const again = document.getElementById('boardQ')
|
|
644
|
+
if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
|
|
645
|
+
}, 160)
|
|
646
|
+
})
|
|
647
|
+
q.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) } })
|
|
535
648
|
}
|
|
536
649
|
|
|
537
650
|
// Closed (0.6.7): every ended session on every machine, newest first, with
|
|
@@ -596,9 +709,8 @@ function closedCard(s, viewer) {
|
|
|
596
709
|
'<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
|
|
597
710
|
'<span title="' + esc(clock(s.endedAt)) + '">ended ' + esc(ago(s.endedAt)) + '</span><span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + '</span></div>' +
|
|
598
711
|
cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
599
|
-
'<div class="actions-row">' + resumeButton(s, viewer) +
|
|
600
|
-
|
|
601
|
-
'<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
|
|
712
|
+
'<div class="actions-row">' + resumeButton(s, viewer) + copyResumeIcon(s) +
|
|
713
|
+
moreMenu(s, [{ action: 'open-href', label: s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details', icon: 'history', attrs: { 'data-href': href } }]) +
|
|
602
714
|
'</div></article>'
|
|
603
715
|
}
|
|
604
716
|
function closedRepaint() {
|
|
@@ -670,9 +782,8 @@ function parkedCard(s, viewer) {
|
|
|
670
782
|
(s.isLocal ? '' : '<span class="chip machine remote" title="another machine">⌂ ' + esc(s.machine) + '</span>') + modelChip(s) +
|
|
671
783
|
'<span title="' + esc(clock(p.at)) + '">parked ' + esc(ago(p.at)) + '</span>' + (open ? '<span>·</span><span>still open</span>' : '') + '</div>' +
|
|
672
784
|
(note ? '<div class="park-note"><span class="k">' + (p.note ? 'Note' : 'Next') + '</span> ' + esc(note) + '</div>' : '') +
|
|
673
|
-
'<div class="actions-row">' + main +
|
|
674
|
-
(
|
|
675
|
-
'<button type="button" class="linkish park-done" data-action="unpark" data-id="' + esc(s.id) + '" title="No longer parked' + (open ? '' : '; it goes to Closed') + '">Done with it</button>' +
|
|
785
|
+
'<div class="actions-row">' + main + (open ? '' : copyResumeIcon(s)) +
|
|
786
|
+
moreMenu(s, [{ action: 'unpark', label: 'Done with it', icon: 'circle-check', title: 'No longer parked' + (open ? '' : '; it goes to Closed') }]) +
|
|
676
787
|
'</div></article>'
|
|
677
788
|
}
|
|
678
789
|
// The session page's Park row (0.8.2): any open session, Codex too, with an
|
|
@@ -888,7 +999,7 @@ async function viewSession(id) {
|
|
|
888
999
|
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>' +
|
|
889
1000
|
'<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>'
|
|
890
1001
|
html += '<div class="facts">' +
|
|
891
|
-
fact('Machine', s.machine + (s.
|
|
1002
|
+
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)) +
|
|
892
1003
|
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) +
|
|
893
1004
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
894
1005
|
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>'
|
|
@@ -2910,6 +3021,12 @@ document.addEventListener('input', (ev) => {
|
|
|
2910
3021
|
})
|
|
2911
3022
|
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 })) } })
|
|
2912
3023
|
|
|
3024
|
+
window.addEventListener('hashchange', () => closeActionMenu())
|
|
3025
|
+
// 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
|
|
3026
|
+
document.addEventListener('keydown', (ev) => {
|
|
3027
|
+
const b = ev.target && ev.target.closest && ev.target.closest('[data-action="card-more"]')
|
|
3028
|
+
if (b) actionMenuKey(b, ev, () => openCardMenu(b))
|
|
3029
|
+
})
|
|
2913
3030
|
document.addEventListener('click', async (ev) => {
|
|
2914
3031
|
const btn = ev.target.closest('[data-action]')
|
|
2915
3032
|
if (!btn) return
|
|
@@ -3092,6 +3209,19 @@ document.addEventListener('click', async (ev) => {
|
|
|
3092
3209
|
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')
|
|
3093
3210
|
await refreshHealth(); return render()
|
|
3094
3211
|
}
|
|
3212
|
+
if (action === 'machine-emoji') { ev.preventDefault(); ev.stopPropagation(); return openEmojiSheet(btn.dataset.machine) }
|
|
3213
|
+
if (action === 'emoji-close') return closeEmojiSheet()
|
|
3214
|
+
if (action === 'emoji-pick') return setMachineEmoji(btn.dataset.machine, btn.dataset.emoji)
|
|
3215
|
+
if (action === 'emoji-own') {
|
|
3216
|
+
const v = (document.getElementById('emojiOwn') || {}).value || ''
|
|
3217
|
+
if (!isEmoji(v)) return toast('Type one emoji', 3000)
|
|
3218
|
+
return setMachineEmoji(btn.dataset.machine, v.trim())
|
|
3219
|
+
}
|
|
3220
|
+
if (action === 'closed-search') {
|
|
3221
|
+
const f = state.boardFilter || readFilter(state.route && state.route.params, window.localStorage)
|
|
3222
|
+
closedUi.q = btn.dataset.q || ''; closedUi.limit = CLOSED_PAGE; state.boardQ = ''
|
|
3223
|
+
return setBoardFilter({ repos: f.repos, status: 'closed' })
|
|
3224
|
+
}
|
|
3095
3225
|
if (action === 'filter') {
|
|
3096
3226
|
const f = state.boardFilter || readFilter(state.route && state.route.params, window.localStorage)
|
|
3097
3227
|
return setBoardFilter({ repos: f.repos, status: f.status === btn.dataset.filter ? null : btn.dataset.filter })
|
|
@@ -3203,6 +3333,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
3203
3333
|
try { await api('POST', '/v1/needs/restore', action === 'need-restore-all' ? { all: true } : { key: btn.dataset.need }); toast(action === 'need-restore-all' ? 'Restored' : 'Restored to Needs you') } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
|
|
3204
3334
|
return render()
|
|
3205
3335
|
}
|
|
3336
|
+
if (action === 'card-more') { ev.preventDefault(); return openCardMenu(btn) }
|
|
3337
|
+
if (action === 'open-href') { location.hash = btn.dataset.href; return }
|
|
3206
3338
|
if (action === 'go') return goClick(btn, id, s)
|
|
3207
3339
|
if (action === 'resume-open') { const ok = await resumeOpen(btn, btn.dataset.id || id); if (ok && btn.closest('.parked-card')) await render(); return ok }
|
|
3208
3340
|
if (action === 'unpark') {
|
|
@@ -3218,9 +3350,10 @@ document.addEventListener('click', async (ev) => {
|
|
|
3218
3350
|
if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
|
|
3219
3351
|
if (action === 'resume') {
|
|
3220
3352
|
const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
|
|
3221
|
-
await copy(cmd)
|
|
3353
|
+
if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
|
|
3222
3354
|
} else if (action === 'checkpoint') {
|
|
3223
3355
|
btn.disabled = true; btn.textContent = 'Writing…'
|
|
3356
|
+
if (!btn.isConnected) toast('Writing a checkpoint…', 20000) // from the ⋯ menu, which has closed
|
|
3224
3357
|
const cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true })
|
|
3225
3358
|
toast('Checkpoint saved · Next: ' + cp.next)
|
|
3226
3359
|
render()
|
package/ui/icons.js
CHANGED
|
@@ -31,7 +31,14 @@ export const ICONS = {
|
|
|
31
31
|
"chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
|
|
32
32
|
"chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
|
|
33
33
|
"mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
|
|
34
|
-
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>"
|
|
34
|
+
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>",
|
|
35
|
+
"copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/>",
|
|
36
|
+
"ellipsis": "<circle cx=\"12\" cy=\"12\" r=\"1\"/> <circle cx=\"19\" cy=\"12\" r=\"1\"/> <circle cx=\"5\" cy=\"12\" r=\"1\"/>",
|
|
37
|
+
"pin": "<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\"/>",
|
|
38
|
+
"pin-off": "<path d=\"M12 17v5\"/> <path d=\"M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89\"/> <path d=\"m2 2 20 20\"/> <path d=\"M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11\"/>",
|
|
39
|
+
"archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
|
|
40
|
+
"bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
|
|
41
|
+
"circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>"
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
export function icon(name, cls = 'ic') {
|
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
|
+
}
|