@leftoff/cli 0.6.9 → 0.7.1
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 +14 -0
- package/bin/leftoff.js +304 -295
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +14 -3
- package/mod/hooks/register.js +22 -4
- package/package.json +1 -1
- package/ui/app.css +81 -0
- package/ui/app.js +119 -15
- package/ui/handoffs.js +184 -0
- package/ui/repos.js +3 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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
|
@@ -284,10 +284,20 @@ const CP_ORDER = ['id', 'session_id', 'session_title', 'created_at', 'trigger',
|
|
|
284
284
|
|
|
285
285
|
export function resumeCommand(session) {
|
|
286
286
|
const id = session.id || session.sessionId
|
|
287
|
-
const cwd = session
|
|
287
|
+
const cwd = resumeDir(session)
|
|
288
288
|
return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + claudeInvocation(session, ['--resume', id])
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// 0.7.1: where to run `claude --resume`: the session's folder, or, inside a git
|
|
292
|
+
// worktree, that worktree's own top folder (Claude Code declines to re-enter a
|
|
293
|
+
// `git worktree add` worktree from one of its subfolders; code.claude.com/docs/en/worktrees).
|
|
294
|
+
export function resumeDir(session) {
|
|
295
|
+
const cwd = session && session.cwd
|
|
296
|
+
const wt = session && session.repo && session.repo.worktree
|
|
297
|
+
if (cwd && wt && typeof wt === 'string' && (cwd === wt || String(cwd).startsWith(wt.replace(/\/+$/, '') + '/'))) return wt
|
|
298
|
+
return cwd || null
|
|
299
|
+
}
|
|
300
|
+
|
|
291
301
|
// POSIX single quotes when needed (also right in fish).
|
|
292
302
|
export function shArg(s) {
|
|
293
303
|
const v = String(s)
|
|
@@ -494,7 +504,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
494
504
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
495
505
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
496
506
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
497
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked']
|
|
507
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos']
|
|
498
508
|
|
|
499
509
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
500
510
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
|
@@ -745,7 +755,8 @@ export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
|
|
|
745
755
|
// "N empty sessions" row until their first prompt; the digest and restore skip them.
|
|
746
756
|
export function isEmptySession(s, checkpointCount = 0) {
|
|
747
757
|
if (!s) return true
|
|
748
|
-
|
|
758
|
+
// 0.7.1: a prompt counts. A long first turn (a worktree bug fix) has no completed turn yet.
|
|
759
|
+
return !(Number(s.turns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
|
|
749
760
|
!(Number(s.historyTurns) > 0) && s.startSource !== 'resume'
|
|
750
761
|
}
|
|
751
762
|
|
package/mod/hooks/register.js
CHANGED
|
@@ -27,7 +27,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
27
27
|
const MODEL_TIMEOUT_MS = 20000
|
|
28
28
|
|
|
29
29
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
30
|
-
const MOD_VERSION = '0.
|
|
30
|
+
const MOD_VERSION = '0.7.1' // keep in step with .claude-plugin/plugin.json
|
|
31
31
|
|
|
32
32
|
const st = {
|
|
33
33
|
internal: false,
|
|
@@ -828,6 +828,18 @@ function startReplyLoop($) {
|
|
|
828
828
|
loop().catch(() => { st.replyLoop = false })
|
|
829
829
|
}
|
|
830
830
|
|
|
831
|
+
// 0.7.1: Claude moved (cd, or into a git worktree). Settings-hook payloads carry
|
|
832
|
+
// the session's cwd and transcript path, which both follow it into a worktree
|
|
833
|
+
// (code.claude.com/docs/en/worktrees), so pass them on when they change.
|
|
834
|
+
function moved(e) {
|
|
835
|
+
const out = {}
|
|
836
|
+
const r = st.record || {}
|
|
837
|
+
if (e && typeof e.cwd === 'string' && e.cwd && e.cwd !== r.cwd) out.cwd = e.cwd
|
|
838
|
+
if (e && typeof e.transcript_path === 'string' && e.transcript_path && e.transcript_path !== r.transcriptPath) out.transcriptPath = e.transcript_path
|
|
839
|
+
if (out.cwd && st.hello) st.hello = { ...st.hello, cwd: out.cwd, repo: null } // a hello said again says where it is now
|
|
840
|
+
return out
|
|
841
|
+
}
|
|
842
|
+
|
|
831
843
|
export function register(on, options) {
|
|
832
844
|
on('session.start', async ($, e, next) => {
|
|
833
845
|
try {
|
|
@@ -890,7 +902,7 @@ export function register(on, options) {
|
|
|
890
902
|
})
|
|
891
903
|
|
|
892
904
|
on('classic.UserPromptSubmit', async ($, e, next) => {
|
|
893
|
-
await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title }) // our own /rename isn't a name you chose
|
|
905
|
+
await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title, ...moved(e) }) // our own /rename isn't a name you chose
|
|
894
906
|
return next(e)
|
|
895
907
|
})
|
|
896
908
|
|
|
@@ -920,6 +932,7 @@ export function register(on, options) {
|
|
|
920
932
|
// board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
|
|
921
933
|
const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
|
|
922
934
|
await sendOrSpool($, 'turn_completed', {
|
|
935
|
+
...moved(e),
|
|
923
936
|
lastAssistantPreview: clip(e.last_assistant_message || '', 280),
|
|
924
937
|
question: trailingQuestion(e.last_assistant_message || ''),
|
|
925
938
|
...(listHint ? { listHint } : {}),
|
|
@@ -937,13 +950,18 @@ export function register(on, options) {
|
|
|
937
950
|
})
|
|
938
951
|
|
|
939
952
|
on('classic.Notification', async ($, e, next) => {
|
|
940
|
-
await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200) })
|
|
953
|
+
await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200), ...moved(e) })
|
|
941
954
|
return next(e)
|
|
942
955
|
})
|
|
943
956
|
|
|
944
957
|
on('classic.PermissionRequest', permissionHook)
|
|
945
958
|
|
|
946
|
-
on('classic.PostToolUse', async ($, e, next) => {
|
|
959
|
+
on('classic.PostToolUse', async ($, e, next) => {
|
|
960
|
+
await toolRan($, e)
|
|
961
|
+
const m = moved(e) // EnterWorktree, or a cd: say so now, not at the end of the turn
|
|
962
|
+
if (m.cwd || m.transcriptPath) await sendOrSpool($, 'activity', { kind: 'cwd', ...m })
|
|
963
|
+
return next(e)
|
|
964
|
+
})
|
|
947
965
|
|
|
948
966
|
on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
|
|
949
967
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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
|
@@ -342,6 +342,9 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
342
342
|
.repomore .opt[hidden] { display: none; }
|
|
343
343
|
.chip.repo-link { cursor: pointer; font: inherit; font-family: var(--mono); font-size: 11.5px; }
|
|
344
344
|
.chip.repo-link:hover { color: var(--text); border-color: var(--line-strong); }
|
|
345
|
+
/* 0.7.1: git worktree marker and "started in" */
|
|
346
|
+
.chip.wt { color: var(--text2); max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
347
|
+
.chip.started-in { color: var(--muted); border-style: dashed; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
345
348
|
.linkish { background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer; font: inherit; }
|
|
346
349
|
.linkish:hover { text-decoration: underline; }
|
|
347
350
|
.digest-repos { margin: 0 0 14px; }
|
|
@@ -1441,3 +1444,81 @@ a.cpr:hover { border-color: var(--line-strong); }
|
|
|
1441
1444
|
.stat.on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent), var(--card-shadow); background: color-mix(in srgb, var(--accent) 12%, var(--card)); }
|
|
1442
1445
|
.stat.on .n { color: var(--accent); }
|
|
1443
1446
|
.stat.on .k { color: var(--text); }
|
|
1447
|
+
|
|
1448
|
+
/* ---- To do and Files (0.7.0) ---- */
|
|
1449
|
+
.todo-subcount { font-size: 13px; font-weight: 500; color: var(--yellow); background: var(--tint-yellow); border-radius: 999px; padding: 2px 9px; margin-left: 6px; vertical-align: middle; }
|
|
1450
|
+
.stat .todo-subn { display: inline-block; margin-left: 6px; font-size: 11px; font-weight: 500; color: var(--yellow); vertical-align: middle; white-space: nowrap; }
|
|
1451
|
+
.todos-sec { margin-top: 18px; }
|
|
1452
|
+
.todo-head h2 { color: var(--yellow); }
|
|
1453
|
+
.todo-head .count { background: var(--yellow); color: #1a1405; }
|
|
1454
|
+
.todo-group { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; margin-top: 10px; }
|
|
1455
|
+
.todo-sess { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
|
1456
|
+
.todo-sess a { color: var(--text2); text-decoration: none; font-weight: 600; }
|
|
1457
|
+
ul.todos { list-style: none; margin: 0; padding: 0; }
|
|
1458
|
+
li.todo { display: flex; gap: 10px; align-items: flex-start; padding: 8px 0; border-top: 1px solid var(--line); }
|
|
1459
|
+
.todo-group li.todo:first-child, .stodos li.todo:first-child { border-top: 0; }
|
|
1460
|
+
li.todo.ticked { opacity: .45; }
|
|
1461
|
+
li.todo.ticked .todo-text { text-decoration: line-through; }
|
|
1462
|
+
.todo-check { position: relative; flex: none; width: 24px; height: 24px; margin-top: 1px; cursor: pointer; }
|
|
1463
|
+
.todo-check input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
|
|
1464
|
+
.todo-check .box { display: block; width: 22px; height: 22px; border: 2px solid var(--line-strong); border-radius: 6px; background: var(--field); }
|
|
1465
|
+
.todo-check input:checked + .box { background: var(--green); border-color: var(--green); }
|
|
1466
|
+
.todo-check input:focus-visible + .box { box-shadow: 0 0 0 3px var(--focus); }
|
|
1467
|
+
.todo-main { min-width: 0; flex: 1; }
|
|
1468
|
+
.todo-text { color: var(--text); font-size: 14.5px; line-height: 1.4; overflow-wrap: anywhere; }
|
|
1469
|
+
.todo-text code, .todo-code code { font-family: var(--mono); font-size: 12.5px; }
|
|
1470
|
+
.todo-codes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
|
1471
|
+
.todo-code { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; padding: 4px 8px; color: var(--text2); cursor: pointer; font: inherit; }
|
|
1472
|
+
.todo-code code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
1473
|
+
.todo-code .todo-cp { flex: none; font-size: 11.5px; color: var(--accent); }
|
|
1474
|
+
.todo-meta { margin-top: 3px; font-size: 12px; color: var(--faint); }
|
|
1475
|
+
.todo-add { display: flex; gap: 8px; margin-top: 10px; }
|
|
1476
|
+
.todo-add input { flex: 1; min-width: 0; font-size: 16px; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--field); color: var(--text); }
|
|
1477
|
+
.todo-add input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
|
|
1478
|
+
.handoff-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; }
|
|
1479
|
+
.handoff-chips .chip { text-decoration: none; }
|
|
1480
|
+
.chip.todochip { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 35%, transparent); background: var(--tint-yellow); }
|
|
1481
|
+
.chip.fileschip { color: var(--cyan); border-color: color-mix(in srgb, var(--cyan) 35%, transparent); background: var(--tint-cyan); }
|
|
1482
|
+
.closed-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
1483
|
+
.chip.docs-filter { cursor: pointer; font: inherit; font-size: 13px; padding: 5px 12px; }
|
|
1484
|
+
.chip.docs-filter[aria-pressed="true"] { color: var(--cyan); border-color: var(--cyan); background: var(--tint-cyan); }
|
|
1485
|
+
ul.files-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); overflow: hidden; }
|
|
1486
|
+
ul.files-list li + li { border-top: 1px solid var(--line); }
|
|
1487
|
+
.f-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 11px 12px; background: none; border: 0; color: var(--text); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
|
|
1488
|
+
.f-row:hover { background: var(--subtle); }
|
|
1489
|
+
.f-ic { flex: none; min-width: 38px; text-align: center; font-size: 10px; font-weight: 700; letter-spacing: .03em; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: 3px 4px; }
|
|
1490
|
+
.f-ic.art { color: var(--accent); font-size: 13px; padding: 1px 4px; }
|
|
1491
|
+
.f-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
|
1492
|
+
.f-name.mono { font-family: var(--mono); font-size: 13px; }
|
|
1493
|
+
.f-kind { flex: none; font-size: 12px; color: var(--muted); }
|
|
1494
|
+
.f-kind.new { color: var(--green); }
|
|
1495
|
+
.f-at { flex: none; font-size: 12px; color: var(--faint); }
|
|
1496
|
+
.f-code { margin: 8px 2px 0; font-size: 13px; }
|
|
1497
|
+
.doc-viewer { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.55); display: flex; align-items: stretch; justify-content: center; padding: max(16px, env(safe-area-inset-top)) 12px 12px; }
|
|
1498
|
+
.doc-viewer[hidden] { display: none; }
|
|
1499
|
+
html.dv-open, html.dv-open body { overflow: hidden; }
|
|
1500
|
+
.dv-sheet { width: 100%; max-width: 820px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); display: flex; flex-direction: column; overflow: hidden; }
|
|
1501
|
+
.dv-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
|
1502
|
+
.dv-title { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1503
|
+
.dv-x { flex: none; width: 36px; height: 36px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); color: var(--text); font-size: 20px; line-height: 1; cursor: pointer; }
|
|
1504
|
+
.dv-sub { padding: 6px 14px 0; font-size: 12px; }
|
|
1505
|
+
.dv-body { flex: 1; overflow: auto; padding: 8px 16px 24px; -webkit-overflow-scrolling: touch; }
|
|
1506
|
+
.doc-md { font-size: 15px; line-height: 1.55; color: var(--text2); overflow-wrap: anywhere; }
|
|
1507
|
+
.doc-md h2, .doc-md h3, .doc-md h4 { color: var(--text); margin: 18px 0 8px; }
|
|
1508
|
+
.doc-md h2, .doc-md h3, .doc-md h4 { text-transform: none; letter-spacing: normal; }
|
|
1509
|
+
.doc-md h2 { font-size: 20px; } .doc-md h3 { font-size: 17px; } .doc-md h4 { font-size: 15px; }
|
|
1510
|
+
.doc-md code { font-family: var(--mono); font-size: 13px; background: var(--bg2); border-radius: 5px; padding: 1px 5px; }
|
|
1511
|
+
.doc-md pre.doc-code, .doc-pre { background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.45; white-space: pre; }
|
|
1512
|
+
.doc-md pre.doc-code code { background: none; padding: 0; }
|
|
1513
|
+
.doc-md blockquote { margin: 10px 0; padding: 2px 12px; border-left: 3px solid var(--line-strong); color: var(--muted); }
|
|
1514
|
+
.doc-md li.sub { margin-left: 18px; }
|
|
1515
|
+
.doc-md li.task { list-style: none; margin-left: -18px; }
|
|
1516
|
+
.doc-md .tbox { display: inline-block; width: 13px; height: 13px; border: 1.5px solid var(--line-strong); border-radius: 3px; margin-right: 7px; vertical-align: -1px; }
|
|
1517
|
+
.doc-md .tbox.on { background: var(--green); border-color: var(--green); }
|
|
1518
|
+
.doc-table { overflow-x: auto; }
|
|
1519
|
+
.doc-md table { border-collapse: collapse; font-size: 13.5px; }
|
|
1520
|
+
.doc-md table { overflow-wrap: normal; word-break: normal; }
|
|
1521
|
+
.doc-md th { white-space: nowrap; }
|
|
1522
|
+
.doc-md th, .doc-md td { border: 1px solid var(--line); padding: 5px 8px; text-align: left; }
|
|
1523
|
+
.doc-img { text-align: center; padding-top: 10px; }
|
|
1524
|
+
.doc-img img { max-width: 100%; height: auto; border-radius: 8px; background: #fff; }
|
package/ui/app.js
CHANGED
|
@@ -9,6 +9,7 @@ import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentRepl
|
|
|
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 } from './prs.js'
|
|
12
|
+
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
|
|
12
13
|
const $app = document.getElementById('app')
|
|
13
14
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
14
15
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
@@ -138,11 +139,20 @@ function agentChip(a, { title } = {}) {
|
|
|
138
139
|
return '<span class="chip machine agent' + (a.ok === false ? ' stale' : '') + '" title="' + esc(title || ('external agent · ' + (a.pullsHere ? 'pulled by this Mac' : 'pulled by ' + (a.puller || 'another Mac')) + (a.lastSuccessAt ? ' · last pull ' + ago(a.lastSuccessAt) : ''))) + '">' + esc(a.icon || '◆') + ' ' + esc(a.display || a.name) + '</span>'
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
// 0.7.1: a session in a git worktree says which ("⑂ fix-login"); one filed
|
|
143
|
+
// where its edits went says where it started.
|
|
144
|
+
function wtChips(s) {
|
|
145
|
+
let h = ''
|
|
146
|
+
if (s.worktree && s.worktree.name) h += '<span class="chip mono wt" title="' + esc('git worktree · ' + (s.worktree.path || s.worktree.name)) + '">⑂ ' + esc(s.worktree.name) + '</span>'
|
|
147
|
+
if (s.startedIn) h += '<span class="chip started-in" title="' + esc('Started in ' + s.startedIn + '; its edits went to ' + (s.repoLabel || s.repoName || 'this repo')) + '">started in ' + esc(s.startedIn) + '</span>'
|
|
148
|
+
return h
|
|
149
|
+
}
|
|
150
|
+
|
|
141
151
|
function where(s) {
|
|
142
152
|
if (s.agent) return (s.project ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(s.repoLabel) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc(s.repoLabel + ' · show only this project') + '">' + esc(s.repoLabel) + '</button>' : '') + agentChip(s.agent)
|
|
143
153
|
const branch = s.repo && s.repo.branch
|
|
144
154
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
145
|
-
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>' +
|
|
155
|
+
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) +
|
|
146
156
|
(multi() || !s.isLocal ? '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + '</span>' : '') + modelChip(s)
|
|
147
157
|
}
|
|
148
158
|
|
|
@@ -298,7 +308,7 @@ function card(s) {
|
|
|
298
308
|
'</div>' + pill(s.status) + '</div>' +
|
|
299
309
|
'<div class="meta">' + spaceChip(s) + where(s) + (ln ? '<span class="livenote ' + esc(s.live) + '">' + esc(ln) + '</span>' : '') + (endedNote ? '<span>' + esc(endedNote) + '</span>' : '') + '<span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + (s.historyTurns ? ' · resumed (' + esc(s.historyTurns) + ' before)' : s.startSource === 'resume' && !s.turns ? ' · resumed' : s.empty && s.live !== 'closed' ? ' · no prompt yet' : '') + '</span>' +
|
|
300
310
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
301
|
-
cardPrsHtml(s.prs) + listChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
311
|
+
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
302
312
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
303
313
|
'<div class="actions-row">' + goButton(s) +
|
|
304
314
|
'<button type="button" class="btn" data-action="resume">Copy resume</button>' +
|
|
@@ -371,7 +381,7 @@ function chipBar(chips, selected, allCount, emptyNote) {
|
|
|
371
381
|
async function viewBoard() {
|
|
372
382
|
if (!state.health) await refreshHealth()
|
|
373
383
|
if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
|
|
374
|
-
const { sessions, viewer, closedCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
384
|
+
const { sessions, viewer, closedCount, todoCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
375
385
|
state.sessions = sessions
|
|
376
386
|
state.viewerLocal = viewer ? viewer.local !== false : true
|
|
377
387
|
state.ghostty = viewer ? viewer.ghostty === true : false
|
|
@@ -414,8 +424,10 @@ async function viewBoard() {
|
|
|
414
424
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
415
425
|
const needs = needsItems(sessions)
|
|
416
426
|
html += needsStrip(needs, needOpts())
|
|
427
|
+
// 0.7.0: open to-dos are their own sub-count, not part of the Needs you number
|
|
428
|
+
const todoN = f.repos.length || sp ? inRepos.reduce((n, s) => n + ((s.todos && s.todos.length) || 0), 0) : (todoCount || 0)
|
|
417
429
|
html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
|
|
418
|
-
'<button type="button" class="stat' + (filter === k ? ' on' : '') + '" data-action="filter" data-filter="' + k + '" aria-pressed="' + (filter === k) + '" title="' + esc(filter === k ? 'Showing ' + fl.label.toLowerCase() + ' · tap to show all' : 'Show ' + fl.label.toLowerCase()) + '"><div class="n">' + count(k) + '</div><div class="k"><span class="pill ' + fl.cls + '" aria-hidden="true"></span>' + esc(fl.label) + '</div></button>').join('') + '</div>'
|
|
430
|
+
'<button type="button" class="stat' + (filter === k ? ' on' : '') + '" data-action="filter" data-filter="' + k + '" aria-pressed="' + (filter === k) + '" title="' + esc(filter === k ? 'Showing ' + fl.label.toLowerCase() + ' · tap to show all' : 'Show ' + fl.label.toLowerCase()) + '"><div class="n">' + count(k) + (k === 'needs' && todoN ? '<span class="todo-subn" title="To-dos handed back to you (not counted as waiting)">' + todoN + (todoN === 1 ? ' to-do' : ' to-dos') + '</span>' : '') + '</div><div class="k"><span class="pill ' + fl.cls + '" aria-hidden="true"></span>' + esc(fl.label) + '</div></button>').join('') + '</div>'
|
|
419
431
|
html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
|
|
420
432
|
if (filter === 'closed') {
|
|
421
433
|
html += await closedSection(f, sp)
|
|
@@ -443,10 +455,11 @@ async function viewBoard() {
|
|
|
443
455
|
// a search box and Show more. Each card has its last checkpoint and Resume,
|
|
444
456
|
// which reopens it in a new Ghostty tab (claude --resume) on the Mac that owns it.
|
|
445
457
|
const CLOSED_PAGE = 30
|
|
446
|
-
const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set() }
|
|
458
|
+
const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set(), docs: false }
|
|
447
459
|
function closedQuery(f, sp) {
|
|
448
460
|
const qs = new URLSearchParams({ limit: String(closedUi.limit) })
|
|
449
461
|
if (closedUi.q.trim()) qs.set('q', closedUi.q.trim())
|
|
462
|
+
if (closedUi.docs) qs.set('docs', '1')
|
|
450
463
|
if (state.machine) qs.set('machine', state.machine)
|
|
451
464
|
if (f.repos.length) qs.set('repo', f.repos.join(','))
|
|
452
465
|
if (sp) qs.set('space', sp)
|
|
@@ -461,12 +474,14 @@ async function closedFetch(f, sp) {
|
|
|
461
474
|
async function closedSection(f, sp) {
|
|
462
475
|
const r = await closedFetch(f, sp)
|
|
463
476
|
return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.total, 'nothing closed yet') +
|
|
464
|
-
'<div class="closed-head"><div class="set-search closed-search">' + icon('search') + '<input id="closedQ" type="search" placeholder="Search closed sessions" aria-label="Search closed sessions" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(closedUi.q) + '"></div
|
|
477
|
+
'<div class="closed-head"><div class="set-search closed-search">' + icon('search') + '<input id="closedQ" type="search" placeholder="Search closed sessions" aria-label="Search closed sessions" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(closedUi.q) + '"></div>' +
|
|
478
|
+
'<button type="button" class="chip docs-filter" data-action="closed-docs" aria-pressed="' + closedUi.docs + '" title="Only sessions that made docs, plans or artifacts">' + (closedUi.docs ? '✓ ' : '') + 'Docs & artifacts</button></div>' +
|
|
465
479
|
'<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
|
|
466
480
|
}
|
|
467
481
|
function closedListHtml(r, f, sp) {
|
|
468
482
|
if (!r.sessions.length) {
|
|
469
483
|
const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
|
|
484
|
+
if (closedUi.docs && !closedUi.q.trim()) return '<div class="empty mt">No closed sessions with docs or artifacts' + esc((f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')) + ' yet. <button type="button" class="linkish" data-action="closed-clear">Clear</button></div>'
|
|
470
485
|
return '<div class="empty mt">' + (closedUi.q.trim() ? 'No closed sessions match “' + esc(closedUi.q.trim()) + '”' + esc(inWhat) + '.' : 'No closed sessions' + esc(inWhat) + ' yet.') +
|
|
471
486
|
(f.repos.length || sp || closedUi.q ? ' <button type="button" class="linkish" data-action="closed-clear">Clear</button>' : '') + '</div>'
|
|
472
487
|
}
|
|
@@ -494,10 +509,10 @@ function closedCard(s, viewer) {
|
|
|
494
509
|
return '<article class="card ended is-closed closed-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
|
|
495
510
|
'<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-closed">closed</span></div>' +
|
|
496
511
|
'<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
|
|
497
|
-
'<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>' +
|
|
512
|
+
'<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) +
|
|
498
513
|
'<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
|
|
499
514
|
'<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>' +
|
|
500
|
-
cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
515
|
+
cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
501
516
|
'<div class="actions-row">' + resumeButton(s, viewer) +
|
|
502
517
|
'<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' +
|
|
503
518
|
'<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
|
|
@@ -536,7 +551,7 @@ async function resumeOpen(btn, id) {
|
|
|
536
551
|
btn.disabled = true; btn.textContent = 'Opening…'
|
|
537
552
|
try {
|
|
538
553
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/resume', {}, { timeoutMs: 60000 })
|
|
539
|
-
toast(r.peer ? 'Resumed on ' + r.peer + ' in a new Ghostty tab' : 'Resumed in a new Ghostty tab')
|
|
554
|
+
toast(r.note ? r.note : r.peer ? 'Resumed on ' + r.peer + ' in a new Ghostty tab' : 'Resumed in a new Ghostty tab', r.note ? 6000 : undefined)
|
|
540
555
|
return true
|
|
541
556
|
} catch (e) {
|
|
542
557
|
toast(e.message, 6000)
|
|
@@ -614,11 +629,14 @@ async function viewWaiting() {
|
|
|
614
629
|
const f = boardFilter()
|
|
615
630
|
const shownSessions = applyFilter(all.map((n) => n.session), { repos: f.repos })
|
|
616
631
|
const items = all.filter((n) => shownSessions.includes(n.session))
|
|
617
|
-
|
|
632
|
+
const todoOv = needs.todos || { open: [], done: [] }
|
|
633
|
+
const nTodos = (todoOv.open || []).length
|
|
634
|
+
let html = '<div class="pagehead"><div><h1>Needs you' + (nTodos ? ' <span class="todo-subcount">' + nTodos + (nTodos === 1 ? ' to-do' : ' to-dos') + '</span>' : '') + '</h1><div class="sub">Permission prompts and questions waiting on you, longest wait first. Answer here, or Go to the terminal.' +
|
|
618
635
|
(state.mayAnswer ? '' : ' Answering from this device is off.') + '</div></div></div>'
|
|
619
636
|
html += chipBar(repoChips(all.map((n) => n.session)), f.repos, all.length, 'nothing waiting')
|
|
620
637
|
if (!items.length) html += '<div class="empty mt"><p><strong>Nothing waiting on you.</strong></p><p>When a session asks for permission or asks you a question, it shows up here and at the top of the board.' + (f.repos.length && all.length ? ' (' + all.length + ' in other repos. <button type="button" class="linkish" data-action="clear-filters">Show all</button>)' : '') + '</p></div>'
|
|
621
638
|
else html += '<section class="needs-strip quiet mt" aria-label="Waiting"><div class="needs-list">' + items.map((n) => needPanel(n, needOpts())).join('') + '</div></section>'
|
|
639
|
+
html += todosSection(todoOv, { agoFn: ago, mayEdit: state.mayAnswer !== false, doneOpen: !!state.todoDoneOpen, repos: f.repos })
|
|
622
640
|
const recent = needs.recent || []
|
|
623
641
|
html += '<details class="recent"' + (state.recentOpen !== false ? ' open' : '') + '><summary>Recently answered <span class="count">' + recent.length + '</span></summary>' +
|
|
624
642
|
(recent.length ? '<ul>' + recent.map((r) => '<li><span class="t" title="' + esc(r.at) + '">' + esc(ago(r.at)) + '</span><a href="#/s/' + encodeURIComponent(r.session.id) + '">' + esc(r.session.title || 'Untitled session') + '</a>' +
|
|
@@ -630,8 +648,10 @@ async function viewWaiting() {
|
|
|
630
648
|
html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
|
|
631
649
|
$app.innerHTML = banners() + html
|
|
632
650
|
// Open/closed survives live repaints (0.6.5).
|
|
633
|
-
const dz = $app.querySelector('details.dismissed')
|
|
651
|
+
const dz = $app.querySelector('details.dismissed:not(.todo-done-list)')
|
|
634
652
|
if (dz) dz.addEventListener('toggle', () => { state.dismissedOpen = dz.open })
|
|
653
|
+
const td = $app.querySelector('details.todo-done-list')
|
|
654
|
+
if (td) td.addEventListener('toggle', () => { state.todoDoneOpen = td.open })
|
|
635
655
|
const rc = $app.querySelector('details.recent')
|
|
636
656
|
if (rc) rc.addEventListener('toggle', () => { state.recentOpen = rc.open })
|
|
637
657
|
}
|
|
@@ -739,15 +759,18 @@ async function viewSession(id) {
|
|
|
739
759
|
const branch = s.repo && s.repo.branch
|
|
740
760
|
const spv = await api('GET', '/v1/spaces').catch(() => null)
|
|
741
761
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
742
|
-
html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled session') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + ' ' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
|
|
762
|
+
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>' +
|
|
743
763
|
'<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>'
|
|
744
764
|
html += '<div class="facts">' +
|
|
745
|
-
fact('Machine', s.machine + (s.isLocal ? ' (this one)' : ' (synced)')) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
765
|
+
fact('Machine', s.machine + (s.isLocal ? ' (this one)' : ' (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)) +
|
|
746
766
|
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) +
|
|
747
767
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
748
768
|
fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
749
769
|
html += spacePicker(s, spv, 'session', s.id)
|
|
750
770
|
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
771
|
+
// 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
|
|
772
|
+
html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
|
|
773
|
+
html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
|
|
751
774
|
if (s.needsYou) html += '<section class="needs-strip one" aria-label="Needs you"><div class="needs-head"><h2>Needs you</h2></div><div class="needs-list">' + needPanel({ ...s.needsYou, session: s }, needOpts({ compact: true })) + '</div></section>'
|
|
752
775
|
if (s.statusChanges && s.statusChanges.length) {
|
|
753
776
|
html += '<div class="striplabel">Status history</div><div class="strip">' + s.statusChanges.slice(-24).map((c) => '<span class="s-' + esc(c.status) + '" title="' + esc(clock(c.at)) + '">' + esc(LABEL[c.status] || c.status) + '</span>').join('') + '</div>'
|
|
@@ -2957,6 +2980,15 @@ document.addEventListener('click', async (ev) => {
|
|
|
2957
2980
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
2958
2981
|
if (action === 'need-more') return needMoreClick(btn)
|
|
2959
2982
|
if (action === 'need-dismiss') return needDismissClick(btn)
|
|
2983
|
+
// ---- To do and Files (0.7.0)
|
|
2984
|
+
if (action === 'todo-restore' || action === 'todo-restore-all') {
|
|
2985
|
+
btn.disabled = true
|
|
2986
|
+
state.todoDoneOpen = true
|
|
2987
|
+
try { await api('POST', '/v1/todos/restore', action === 'todo-restore-all' ? { all: true } : { key: btn.dataset.todo }) } catch (e) { toast(e.message, 5000) }
|
|
2988
|
+
return render()
|
|
2989
|
+
}
|
|
2990
|
+
if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
|
|
2991
|
+
if (action === 'doc-close') return closeDoc()
|
|
2960
2992
|
if (action === 'need-restore' || action === 'need-restore-all') {
|
|
2961
2993
|
btn.disabled = true
|
|
2962
2994
|
state.dismissedOpen = true
|
|
@@ -2966,7 +2998,9 @@ document.addEventListener('click', async (ev) => {
|
|
|
2966
2998
|
if (action === 'go') return goClick(btn, id, s)
|
|
2967
2999
|
if (action === 'resume-open') return resumeOpen(btn, btn.dataset.id || id)
|
|
2968
3000
|
if (action === 'closed-more') { closedUi.limit += CLOSED_PAGE; btn.disabled = true; try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) } return }
|
|
2969
|
-
|
|
3001
|
+
// 0.7.0: only sessions with docs, plans or artifacts
|
|
3002
|
+
if (action === 'closed-docs') { closedUi.docs = !closedUi.docs; closedUi.limit = CLOSED_PAGE; btn.setAttribute('aria-pressed', String(closedUi.docs)); btn.textContent = (closedUi.docs ? '✓ ' : '') + 'Docs & artifacts'; try { await closedFetch(state.boardFilter || { repos: [] }, state.boardSpace); closedRepaint() } catch (e) { toast(e.message, 5000) } return }
|
|
3003
|
+
if (action === 'closed-clear') { closedUi.docs = false; closedUi.q = ''; closedUi.limit = CLOSED_PAGE; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: 'closed' }) }
|
|
2970
3004
|
if (action && action.startsWith('st-')) return stAction(action, btn)
|
|
2971
3005
|
if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
|
|
2972
3006
|
if (action === 'resume') {
|
|
@@ -3109,6 +3143,65 @@ async function needMoreClick(btn) {
|
|
|
3109
3143
|
paint()
|
|
3110
3144
|
}
|
|
3111
3145
|
|
|
3146
|
+
// Tick a to-do (0.7.0): done on every board at once, kept in the folder, Undo for 5 s.
|
|
3147
|
+
async function todoDoneChange(cb) {
|
|
3148
|
+
const key = cb.dataset.todoDone
|
|
3149
|
+
if (!key || !cb.checked) return
|
|
3150
|
+
const li = cb.closest('li.todo')
|
|
3151
|
+
if (li) li.classList.add('ticked')
|
|
3152
|
+
try { await api('POST', '/v1/todos/done', { key }) } catch (e) { cb.checked = false; if (li) li.classList.remove('ticked'); toast('Couldn’t tick it: ' + e.message, 5000); return }
|
|
3153
|
+
for (const s of state.sessions || []) if (s.todos) s.todos = s.todos.filter((t) => t.key !== key)
|
|
3154
|
+
toastUndo('Done', async () => {
|
|
3155
|
+
try { await api('POST', '/v1/todos/restore', { key }) } catch (e) { toast(e.message, 5000) }
|
|
3156
|
+
render()
|
|
3157
|
+
})
|
|
3158
|
+
setTimeout(() => { if (!busy()) render() }, 400)
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
// The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
|
|
3162
|
+
// that owns it (through another Mac or the relay from a phone). Markdown is
|
|
3163
|
+
// rendered from escaped text; HTML files show as source; anything else downloads.
|
|
3164
|
+
function docViewerEl() {
|
|
3165
|
+
let el = document.getElementById('docViewer')
|
|
3166
|
+
if (el) return el
|
|
3167
|
+
el = document.createElement('div')
|
|
3168
|
+
el.id = 'docViewer'
|
|
3169
|
+
el.className = 'doc-viewer'
|
|
3170
|
+
el.hidden = true
|
|
3171
|
+
el.setAttribute('role', 'dialog')
|
|
3172
|
+
el.setAttribute('aria-modal', 'true')
|
|
3173
|
+
el.setAttribute('aria-labelledby', 'docViewerTitle')
|
|
3174
|
+
el.innerHTML = '<div class="dv-sheet"><div class="dv-head"><div class="dv-title" id="docViewerTitle"></div><a class="btn sm dv-dl" hidden>Download</a><button type="button" class="dv-x" data-action="doc-close" aria-label="Close">×</button></div><div class="dv-sub faint"></div><div class="dv-body"></div></div>'
|
|
3175
|
+
el.addEventListener('click', (ev) => { if (ev.target === el) closeDoc() })
|
|
3176
|
+
document.body.appendChild(el)
|
|
3177
|
+
return el
|
|
3178
|
+
}
|
|
3179
|
+
function closeDoc() { const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = '' } document.documentElement.classList.remove('dv-open') }
|
|
3180
|
+
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
3181
|
+
async function openDoc(sessionId, { path, plan } = {}) {
|
|
3182
|
+
if (!sessionId || (!path && !plan)) return
|
|
3183
|
+
const el = docViewerEl()
|
|
3184
|
+
const name = plan ? 'Plan' : String(path).split('/').pop()
|
|
3185
|
+
el.querySelector('.dv-title').textContent = name
|
|
3186
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
3187
|
+
el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
|
|
3188
|
+
const dl = el.querySelector('.dv-dl'); dl.hidden = true; dl.removeAttribute('href')
|
|
3189
|
+
el.hidden = false
|
|
3190
|
+
document.documentElement.classList.add('dv-open')
|
|
3191
|
+
const qs = new URLSearchParams(plan ? { plan } : { path })
|
|
3192
|
+
try {
|
|
3193
|
+
const r = await api('GET', '/v1/sessions/' + encodeURIComponent(sessionId) + '/file?' + qs)
|
|
3194
|
+
if (el.hidden) return
|
|
3195
|
+
el.querySelector('.dv-title').textContent = r.name || name
|
|
3196
|
+
el.querySelector('.dv-sub').textContent = [r.plan ? 'Plan' : r.kind === 'new' ? 'New' : r.kind === 'edited' ? 'Edited' : '', r.size ? (r.size < 1024 ? r.size + ' B' : Math.round(r.size / 1024) + ' KB') : '', r.peer ? 'from ' + r.peer : ''].filter(Boolean).join(' · ')
|
|
3197
|
+
el.querySelector('.dv-body').innerHTML = docViewerBody(r)
|
|
3198
|
+
const href = docDownloadHref(r)
|
|
3199
|
+
if (href) { dl.href = href; dl.download = String(r.name || name).split('/').pop() + (r.plan ? '.md' : ''); dl.hidden = false }
|
|
3200
|
+
} catch (e) {
|
|
3201
|
+
el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(e.message) + '</div>'
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3112
3205
|
// Dismiss a need (0.6.5): gone from every card and count at once, kept on
|
|
3113
3206
|
// the daemon (so your other devices and the relay see it too), Undo for 5 s.
|
|
3114
3207
|
async function needDismissClick(btn) {
|
|
@@ -3138,6 +3231,16 @@ function goClick(btn, id, s) {
|
|
|
3138
3231
|
}
|
|
3139
3232
|
|
|
3140
3233
|
document.addEventListener('submit', async (ev) => {
|
|
3234
|
+
if (ev.target.dataset && ev.target.dataset.todoAdd) {
|
|
3235
|
+
ev.preventDefault()
|
|
3236
|
+
const input = ev.target.querySelector('input[name=todo]')
|
|
3237
|
+
const text = input.value.trim()
|
|
3238
|
+
if (!text) { input.focus(); return }
|
|
3239
|
+
const sid = ev.target.dataset.todoAdd === '-' ? null : ev.target.dataset.todoAdd
|
|
3240
|
+
const b = ev.target.querySelector('button'); if (b) b.disabled = true
|
|
3241
|
+
try { await api('POST', '/v1/todos', { text, sessionId: sid }); input.value = ''; input.blur(); toast('Added to your to-dos'); await render() } catch (e) { toast(e.message, 5000) } finally { if (b) b.disabled = false }
|
|
3242
|
+
return
|
|
3243
|
+
}
|
|
3141
3244
|
if (ev.target.dataset && ev.target.dataset.prLink) {
|
|
3142
3245
|
ev.preventDefault()
|
|
3143
3246
|
const input = ev.target.querySelector('input[name=ref]')
|
|
@@ -3204,7 +3307,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
3204
3307
|
|
|
3205
3308
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
3206
3309
|
let pending = null
|
|
3207
|
-
const busy = () => !!document.querySelector('.lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))
|
|
3310
|
+
const busy = () => !!document.querySelector('.doc-viewer:not([hidden]), .lo-menu, .title input, .repomore[open], .need .deny-form:not([hidden])') || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.addform') && document.activeElement.value) || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('.need') && /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName))
|
|
3208
3311
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
3209
3312
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
3210
3313
|
function soon() { if (pending) return; pending = setTimeout(() => { pending = null; if (!busy() && !(state.route && state.route.name === 'settings') && !(state.route && state.route.name === 'catchup' && cuUi.open)) render(); refreshHealth() }, 800) }
|
|
@@ -3223,6 +3326,7 @@ function connect() {
|
|
|
3223
3326
|
}
|
|
3224
3327
|
|
|
3225
3328
|
document.addEventListener('change', async (ev) => {
|
|
3329
|
+
if (ev.target.dataset && ev.target.dataset.todoDone) return todoDoneChange(ev.target)
|
|
3226
3330
|
// Spaces (v0.5.8): a session's or agent's own space, a Mac's default.
|
|
3227
3331
|
if (ev.target.dataset && ev.target.dataset.spaceKind) {
|
|
3228
3332
|
const sel = ev.target
|