@leftoff/cli 0.6.8 → 0.7.0
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 +17 -0
- package/bin/leftoff.js +314 -302
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +140 -0
- package/ui/app.js +165 -12
- package/ui/handoffs.js +184 -0
- package/ui/index.html +1 -0
- package/ui/prs.js +128 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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
|
@@ -494,7 +494,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
494
494
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
495
495
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
496
496
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
497
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend']
|
|
497
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files']
|
|
498
498
|
|
|
499
499
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
500
500
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
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.0' // keep in step with .claude-plugin/plugin.json
|
|
31
31
|
|
|
32
32
|
const st = {
|
|
33
33
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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
|
@@ -1379,3 +1379,143 @@ a.need-pr:hover { border-color: var(--line-strong); }
|
|
|
1379
1379
|
@media (max-width: 640px) { .form .set-advbody { gap: 12px 18px; } }
|
|
1380
1380
|
.form .set-advbody > .section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
|
|
1381
1381
|
.form .hint.nomt { margin-top: 0; }
|
|
1382
|
+
|
|
1383
|
+
/* PRs (0.6.9): a card's PRs, the session page's list and Link a PR, the PRs tab */
|
|
1384
|
+
.pr-num { font-weight: 700; font-family: var(--mono); }
|
|
1385
|
+
.pr-state { font-weight: 600; color: var(--green); }
|
|
1386
|
+
.pr-state.draft, .pr-state.closed { color: var(--muted); }
|
|
1387
|
+
.pr-state.merged { color: var(--accent); }
|
|
1388
|
+
.pr-checks { color: var(--muted); white-space: nowrap; }
|
|
1389
|
+
.pr-checks.pass { color: var(--green); }
|
|
1390
|
+
.pr-checks.fail { color: var(--red); }
|
|
1391
|
+
.pr-checks.pending { color: var(--yellow); }
|
|
1392
|
+
.card-prs { display: flex; flex-direction: column; gap: 4px; margin: 8px 0 2px; min-width: 0; }
|
|
1393
|
+
.cpr { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 12.5px; color: var(--text); text-decoration: none; background: var(--card2); border: 1px solid var(--line); border-radius: 8px; padding: 4px 9px; }
|
|
1394
|
+
a.cpr:hover { border-color: var(--line-strong); }
|
|
1395
|
+
.cpr .pr-title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow-wrap: normal; }
|
|
1396
|
+
.cpr .pr-num, .cpr .pr-state, .cpr .pr-checks { flex: none; }
|
|
1397
|
+
.cpr.more { color: var(--muted); justify-content: center; }
|
|
1398
|
+
.prs-filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 14px; }
|
|
1399
|
+
.prgroup { margin-bottom: 18px; }
|
|
1400
|
+
.prgroup h2 { font-size: 14px; margin: 14px 0 8px; }
|
|
1401
|
+
.prgroup.attention h2 { color: var(--red); }
|
|
1402
|
+
.prrow { display: flex; gap: 10px; align-items: flex-start; background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin-bottom: 8px; min-width: 0; }
|
|
1403
|
+
.prrow.slim { background: var(--card2); }
|
|
1404
|
+
.pr-main { flex: 1 1 auto; min-width: 0; }
|
|
1405
|
+
.pr-line1 { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 9px; font-size: 12.5px; }
|
|
1406
|
+
.pr-att { color: var(--red); font-weight: 600; }
|
|
1407
|
+
.pr-review { color: var(--muted); }
|
|
1408
|
+
.pr-review.approved { color: var(--green); }
|
|
1409
|
+
.pr-review.changes_requested { color: var(--red); }
|
|
1410
|
+
.prrow .pr-title { font-weight: 500; margin-top: 3px; overflow-wrap: anywhere; }
|
|
1411
|
+
.prrow .pr-title a { color: var(--text); text-decoration: none; }
|
|
1412
|
+
.prrow .pr-title a:hover { text-decoration: underline; }
|
|
1413
|
+
.pr-meta { font-size: 12px; color: var(--faint); margin-top: 2px; overflow-wrap: anywhere; }
|
|
1414
|
+
.pr-sessions { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
|
|
1415
|
+
.pr-sess { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; min-width: 0; }
|
|
1416
|
+
.pr-sess .sesslink { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
|
|
1417
|
+
.pr-sess .sesslink::before { content: '↳ '; color: var(--faint); }
|
|
1418
|
+
.pr-actions { display: flex; flex-direction: column; gap: 6px; flex: none; align-items: stretch; }
|
|
1419
|
+
.prlist { margin-bottom: 8px; }
|
|
1420
|
+
.prlink { display: flex; gap: 8px; margin: 6px 0 16px; }
|
|
1421
|
+
.prlink input { flex: 1 1 auto; min-width: 0; font-size: 16px; }
|
|
1422
|
+
.prs-gh { margin-top: 6px; }
|
|
1423
|
+
.prs-foot { font-size: 12px; margin-top: 6px; }
|
|
1424
|
+
@media (max-width: 639.98px) {
|
|
1425
|
+
.tabs { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
1426
|
+
.prrow { flex-direction: column; gap: 8px; }
|
|
1427
|
+
.pr-actions { flex-direction: row; flex-wrap: wrap; }
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/* 0.6.9: "Closed (N) →" under the Board's grid */
|
|
1431
|
+
.closed-link { margin: 18px 0 6px; text-align: center; }
|
|
1432
|
+
.closed-link .linkish { color: var(--muted); font-size: 13.5px; padding: 8px 12px; }
|
|
1433
|
+
.closed-link .linkish:hover { color: var(--text); }
|
|
1434
|
+
|
|
1435
|
+
/* 0.6.9: the Board's stat tiles read as filters you can tap: a lift on hover,
|
|
1436
|
+
a press on touch, and the picked one clearly on (accent ring, tint, number). */
|
|
1437
|
+
.stat { transition: border-color .15s, background .15s, transform .12s, box-shadow .15s; -webkit-tap-highlight-color: transparent; }
|
|
1438
|
+
@media (hover: hover) { .stat:hover { transform: translateY(-1px); border-color: var(--accent); box-shadow: var(--card-shadow), 0 4px 14px rgba(0, 0, 0, .12); } }
|
|
1439
|
+
.stat:active { transform: scale(.97); }
|
|
1440
|
+
.stat:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
1441
|
+
.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
|
+
.stat.on .n { color: var(--accent); }
|
|
1443
|
+
.stat.on .k { color: var(--text); }
|
|
1444
|
+
|
|
1445
|
+
/* ---- To do and Files (0.7.0) ---- */
|
|
1446
|
+
.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; }
|
|
1447
|
+
.stat .todo-subn { display: inline-block; margin-left: 6px; font-size: 11px; font-weight: 500; color: var(--yellow); vertical-align: middle; white-space: nowrap; }
|
|
1448
|
+
.todos-sec { margin-top: 18px; }
|
|
1449
|
+
.todo-head h2 { color: var(--yellow); }
|
|
1450
|
+
.todo-head .count { background: var(--yellow); color: #1a1405; }
|
|
1451
|
+
.todo-group { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; margin-top: 10px; }
|
|
1452
|
+
.todo-sess { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
|
1453
|
+
.todo-sess a { color: var(--text2); text-decoration: none; font-weight: 600; }
|
|
1454
|
+
ul.todos { list-style: none; margin: 0; padding: 0; }
|
|
1455
|
+
li.todo { display: flex; gap: 10px; align-items: flex-start; padding: 8px 0; border-top: 1px solid var(--line); }
|
|
1456
|
+
.todo-group li.todo:first-child, .stodos li.todo:first-child { border-top: 0; }
|
|
1457
|
+
li.todo.ticked { opacity: .45; }
|
|
1458
|
+
li.todo.ticked .todo-text { text-decoration: line-through; }
|
|
1459
|
+
.todo-check { position: relative; flex: none; width: 24px; height: 24px; margin-top: 1px; cursor: pointer; }
|
|
1460
|
+
.todo-check input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
|
|
1461
|
+
.todo-check .box { display: block; width: 22px; height: 22px; border: 2px solid var(--line-strong); border-radius: 6px; background: var(--field); }
|
|
1462
|
+
.todo-check input:checked + .box { background: var(--green); border-color: var(--green); }
|
|
1463
|
+
.todo-check input:focus-visible + .box { box-shadow: 0 0 0 3px var(--focus); }
|
|
1464
|
+
.todo-main { min-width: 0; flex: 1; }
|
|
1465
|
+
.todo-text { color: var(--text); font-size: 14.5px; line-height: 1.4; overflow-wrap: anywhere; }
|
|
1466
|
+
.todo-text code, .todo-code code { font-family: var(--mono); font-size: 12.5px; }
|
|
1467
|
+
.todo-codes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
|
1468
|
+
.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; }
|
|
1469
|
+
.todo-code code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
1470
|
+
.todo-code .todo-cp { flex: none; font-size: 11.5px; color: var(--accent); }
|
|
1471
|
+
.todo-meta { margin-top: 3px; font-size: 12px; color: var(--faint); }
|
|
1472
|
+
.todo-add { display: flex; gap: 8px; margin-top: 10px; }
|
|
1473
|
+
.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); }
|
|
1474
|
+
.todo-add input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
|
|
1475
|
+
.handoff-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; }
|
|
1476
|
+
.handoff-chips .chip { text-decoration: none; }
|
|
1477
|
+
.chip.todochip { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 35%, transparent); background: var(--tint-yellow); }
|
|
1478
|
+
.chip.fileschip { color: var(--cyan); border-color: color-mix(in srgb, var(--cyan) 35%, transparent); background: var(--tint-cyan); }
|
|
1479
|
+
.closed-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
1480
|
+
.chip.docs-filter { cursor: pointer; font: inherit; font-size: 13px; padding: 5px 12px; }
|
|
1481
|
+
.chip.docs-filter[aria-pressed="true"] { color: var(--cyan); border-color: var(--cyan); background: var(--tint-cyan); }
|
|
1482
|
+
ul.files-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); overflow: hidden; }
|
|
1483
|
+
ul.files-list li + li { border-top: 1px solid var(--line); }
|
|
1484
|
+
.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; }
|
|
1485
|
+
.f-row:hover { background: var(--subtle); }
|
|
1486
|
+
.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; }
|
|
1487
|
+
.f-ic.art { color: var(--accent); font-size: 13px; padding: 1px 4px; }
|
|
1488
|
+
.f-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
|
|
1489
|
+
.f-name.mono { font-family: var(--mono); font-size: 13px; }
|
|
1490
|
+
.f-kind { flex: none; font-size: 12px; color: var(--muted); }
|
|
1491
|
+
.f-kind.new { color: var(--green); }
|
|
1492
|
+
.f-at { flex: none; font-size: 12px; color: var(--faint); }
|
|
1493
|
+
.f-code { margin: 8px 2px 0; font-size: 13px; }
|
|
1494
|
+
.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; }
|
|
1495
|
+
.doc-viewer[hidden] { display: none; }
|
|
1496
|
+
html.dv-open, html.dv-open body { overflow: hidden; }
|
|
1497
|
+
.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; }
|
|
1498
|
+
.dv-head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
|
1499
|
+
.dv-title { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1500
|
+
.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; }
|
|
1501
|
+
.dv-sub { padding: 6px 14px 0; font-size: 12px; }
|
|
1502
|
+
.dv-body { flex: 1; overflow: auto; padding: 8px 16px 24px; -webkit-overflow-scrolling: touch; }
|
|
1503
|
+
.doc-md { font-size: 15px; line-height: 1.55; color: var(--text2); overflow-wrap: anywhere; }
|
|
1504
|
+
.doc-md h2, .doc-md h3, .doc-md h4 { color: var(--text); margin: 18px 0 8px; }
|
|
1505
|
+
.doc-md h2, .doc-md h3, .doc-md h4 { text-transform: none; letter-spacing: normal; }
|
|
1506
|
+
.doc-md h2 { font-size: 20px; } .doc-md h3 { font-size: 17px; } .doc-md h4 { font-size: 15px; }
|
|
1507
|
+
.doc-md code { font-family: var(--mono); font-size: 13px; background: var(--bg2); border-radius: 5px; padding: 1px 5px; }
|
|
1508
|
+
.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; }
|
|
1509
|
+
.doc-md pre.doc-code code { background: none; padding: 0; }
|
|
1510
|
+
.doc-md blockquote { margin: 10px 0; padding: 2px 12px; border-left: 3px solid var(--line-strong); color: var(--muted); }
|
|
1511
|
+
.doc-md li.sub { margin-left: 18px; }
|
|
1512
|
+
.doc-md li.task { list-style: none; margin-left: -18px; }
|
|
1513
|
+
.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; }
|
|
1514
|
+
.doc-md .tbox.on { background: var(--green); border-color: var(--green); }
|
|
1515
|
+
.doc-table { overflow-x: auto; }
|
|
1516
|
+
.doc-md table { border-collapse: collapse; font-size: 13.5px; }
|
|
1517
|
+
.doc-md table { overflow-wrap: normal; word-break: normal; }
|
|
1518
|
+
.doc-md th { white-space: nowrap; }
|
|
1519
|
+
.doc-md th, .doc-md td { border: 1px solid var(--line); padding: 5px 8px; text-align: left; }
|
|
1520
|
+
.doc-img { text-align: center; padding-top: 10px; }
|
|
1521
|
+
.doc-img img { max-width: 100%; height: auto; border-radius: 8px; background: #fff; }
|
package/ui/app.js
CHANGED
|
@@ -8,6 +8,8 @@ import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, to
|
|
|
8
8
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
9
9
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
10
10
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
11
|
+
import { cardPrsHtml, sessionPrsSection, prsPageHtml } from './prs.js'
|
|
12
|
+
import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
|
|
11
13
|
const $app = document.getElementById('app')
|
|
12
14
|
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
13
15
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
@@ -297,7 +299,7 @@ function card(s) {
|
|
|
297
299
|
'</div>' + pill(s.status) + '</div>' +
|
|
298
300
|
'<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>' +
|
|
299
301
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
300
|
-
listChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
302
|
+
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
301
303
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
302
304
|
'<div class="actions-row">' + goButton(s) +
|
|
303
305
|
'<button type="button" class="btn" data-action="resume">Copy resume</button>' +
|
|
@@ -370,7 +372,7 @@ function chipBar(chips, selected, allCount, emptyNote) {
|
|
|
370
372
|
async function viewBoard() {
|
|
371
373
|
if (!state.health) await refreshHealth()
|
|
372
374
|
if (state.health && state.health.update && state.health.update.available && !state.updates) { try { state.updates = await api('GET', '/v1/updates') } catch {} }
|
|
373
|
-
const { sessions, viewer, closedCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
375
|
+
const { sessions, viewer, closedCount, todoCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
374
376
|
state.sessions = sessions
|
|
375
377
|
state.viewerLocal = viewer ? viewer.local !== false : true
|
|
376
378
|
state.ghostty = viewer ? viewer.ghostty === true : false
|
|
@@ -413,8 +415,10 @@ async function viewBoard() {
|
|
|
413
415
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
414
416
|
const needs = needsItems(sessions)
|
|
415
417
|
html += needsStrip(needs, needOpts())
|
|
418
|
+
// 0.7.0: open to-dos are their own sub-count, not part of the Needs you number
|
|
419
|
+
const todoN = f.repos.length || sp ? inRepos.reduce((n, s) => n + ((s.todos && s.todos.length) || 0), 0) : (todoCount || 0)
|
|
416
420
|
html += '<div class="stats">' + Object.entries(FILTERS).map(([k, fl]) =>
|
|
417
|
-
'<button type="button" class="stat' + (filter === k ? ' on' : '') + '" data-action="filter" data-filter="' + k + '"><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>'
|
|
421
|
+
'<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>'
|
|
418
422
|
html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
|
|
419
423
|
if (filter === 'closed') {
|
|
420
424
|
html += await closedSection(f, sp)
|
|
@@ -432,6 +436,8 @@ async function viewBoard() {
|
|
|
432
436
|
html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
|
|
433
437
|
}
|
|
434
438
|
html += emptyFold(applyFilter(sp ? empties.filter((s) => s.space === sp) : empties, { repos: f.repos }))
|
|
439
|
+
// 0.6.9: the Closed view was only a small stat tile; a quiet way in under the grid too.
|
|
440
|
+
if (closedCount > 0) html += '<div class="closed-link"><button type="button" class="linkish" data-action="filter" data-filter="closed">Closed (' + esc(closedCount) + ') →</button></div>'
|
|
435
441
|
$app.innerHTML = banners() + html
|
|
436
442
|
revealStat()
|
|
437
443
|
}
|
|
@@ -440,10 +446,11 @@ async function viewBoard() {
|
|
|
440
446
|
// a search box and Show more. Each card has its last checkpoint and Resume,
|
|
441
447
|
// which reopens it in a new Ghostty tab (claude --resume) on the Mac that owns it.
|
|
442
448
|
const CLOSED_PAGE = 30
|
|
443
|
-
const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set() }
|
|
449
|
+
const closedUi = { q: '', limit: CLOSED_PAGE, data: null, seq: 0, timer: null, busy: new Set(), docs: false }
|
|
444
450
|
function closedQuery(f, sp) {
|
|
445
451
|
const qs = new URLSearchParams({ limit: String(closedUi.limit) })
|
|
446
452
|
if (closedUi.q.trim()) qs.set('q', closedUi.q.trim())
|
|
453
|
+
if (closedUi.docs) qs.set('docs', '1')
|
|
447
454
|
if (state.machine) qs.set('machine', state.machine)
|
|
448
455
|
if (f.repos.length) qs.set('repo', f.repos.join(','))
|
|
449
456
|
if (sp) qs.set('space', sp)
|
|
@@ -458,12 +465,14 @@ async function closedFetch(f, sp) {
|
|
|
458
465
|
async function closedSection(f, sp) {
|
|
459
466
|
const r = await closedFetch(f, sp)
|
|
460
467
|
return chipBar(r.repos.map((c) => ({ ...c, count: c.total })), f.repos, r.total, 'nothing closed yet') +
|
|
461
|
-
'<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
|
|
468
|
+
'<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>' +
|
|
469
|
+
'<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>' +
|
|
462
470
|
'<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
|
|
463
471
|
}
|
|
464
472
|
function closedListHtml(r, f, sp) {
|
|
465
473
|
if (!r.sessions.length) {
|
|
466
474
|
const inWhat = (f.repos.length ? ' in ' + f.repos.join(', ') : '') + (sp ? ' in ' + sp : '')
|
|
475
|
+
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>'
|
|
467
476
|
return '<div class="empty mt">' + (closedUi.q.trim() ? 'No closed sessions match “' + esc(closedUi.q.trim()) + '”' + esc(inWhat) + '.' : 'No closed sessions' + esc(inWhat) + ' yet.') +
|
|
468
477
|
(f.repos.length || sp || closedUi.q ? ' <button type="button" class="linkish" data-action="closed-clear">Clear</button>' : '') + '</div>'
|
|
469
478
|
}
|
|
@@ -494,7 +503,7 @@ function closedCard(s, viewer) {
|
|
|
494
503
|
'<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>' +
|
|
495
504
|
'<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
|
|
496
505
|
'<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>' +
|
|
497
|
-
cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
506
|
+
cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
498
507
|
'<div class="actions-row">' + resumeButton(s, viewer) +
|
|
499
508
|
'<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' +
|
|
500
509
|
'<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
|
|
@@ -611,11 +620,14 @@ async function viewWaiting() {
|
|
|
611
620
|
const f = boardFilter()
|
|
612
621
|
const shownSessions = applyFilter(all.map((n) => n.session), { repos: f.repos })
|
|
613
622
|
const items = all.filter((n) => shownSessions.includes(n.session))
|
|
614
|
-
|
|
623
|
+
const todoOv = needs.todos || { open: [], done: [] }
|
|
624
|
+
const nTodos = (todoOv.open || []).length
|
|
625
|
+
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.' +
|
|
615
626
|
(state.mayAnswer ? '' : ' Answering from this device is off.') + '</div></div></div>'
|
|
616
627
|
html += chipBar(repoChips(all.map((n) => n.session)), f.repos, all.length, 'nothing waiting')
|
|
617
628
|
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>'
|
|
618
629
|
else html += '<section class="needs-strip quiet mt" aria-label="Waiting"><div class="needs-list">' + items.map((n) => needPanel(n, needOpts())).join('') + '</div></section>'
|
|
630
|
+
html += todosSection(todoOv, { agoFn: ago, mayEdit: state.mayAnswer !== false, doneOpen: !!state.todoDoneOpen, repos: f.repos })
|
|
619
631
|
const recent = needs.recent || []
|
|
620
632
|
html += '<details class="recent"' + (state.recentOpen !== false ? ' open' : '') + '><summary>Recently answered <span class="count">' + recent.length + '</span></summary>' +
|
|
621
633
|
(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>' +
|
|
@@ -627,8 +639,10 @@ async function viewWaiting() {
|
|
|
627
639
|
html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
|
|
628
640
|
$app.innerHTML = banners() + html
|
|
629
641
|
// Open/closed survives live repaints (0.6.5).
|
|
630
|
-
const dz = $app.querySelector('details.dismissed')
|
|
642
|
+
const dz = $app.querySelector('details.dismissed:not(.todo-done-list)')
|
|
631
643
|
if (dz) dz.addEventListener('toggle', () => { state.dismissedOpen = dz.open })
|
|
644
|
+
const td = $app.querySelector('details.todo-done-list')
|
|
645
|
+
if (td) td.addEventListener('toggle', () => { state.todoDoneOpen = td.open })
|
|
632
646
|
const rc = $app.querySelector('details.recent')
|
|
633
647
|
if (rc) rc.addEventListener('toggle', () => { state.recentOpen = rc.open })
|
|
634
648
|
}
|
|
@@ -744,6 +758,10 @@ async function viewSession(id) {
|
|
|
744
758
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
745
759
|
fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
746
760
|
html += spacePicker(s, spv, 'session', s.id)
|
|
761
|
+
html += '<section class="sprs" id="prs">' + sessionPrsSection(s, { mayEdit: !s.agent }) + '</section>'
|
|
762
|
+
// 0.7.0: steps handed back to you, and the docs, plans and artifacts it made
|
|
763
|
+
html += '<section class="stodos" id="todos">' + sessionTodosSection(s, { agoFn: ago, mayEdit: state.mayAnswer !== false }) + '</section>'
|
|
764
|
+
html += '<section class="sfiles" id="files">' + sessionFilesSection(s, { agoFn: ago }) + '</section>'
|
|
747
765
|
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>'
|
|
748
766
|
if (s.statusChanges && s.statusChanges.length) {
|
|
749
767
|
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>'
|
|
@@ -782,6 +800,42 @@ function spacePicker(s, spv, kind, key) {
|
|
|
782
800
|
}
|
|
783
801
|
function fact(k, v, cls) { return '<div class="fact' + (cls ? ' ' + cls : '') + '"><div class="k">' + esc(k) + '</div><div class="v" title="' + esc(v) + '">' + esc(v) + '</div></div>' }
|
|
784
802
|
|
|
803
|
+
// PRs (0.6.9): Mine (PRs linked to your sessions, grouped) and Team (every open
|
|
804
|
+
// PR in the repos your sessions are in). The daemon fetches with gh (or asks
|
|
805
|
+
// the Mac that has it) and caches ~5 min; while it loads we look again shortly.
|
|
806
|
+
const prsUi = { fresh: false, polls: 0, timer: null, data: {} }
|
|
807
|
+
async function viewPrs() {
|
|
808
|
+
const params = (state.route && state.route.params) || new URLSearchParams()
|
|
809
|
+
let view = params.get('view')
|
|
810
|
+
if (!view) { try { view = localStorage.getItem('leftoff.prsView') } catch {} }
|
|
811
|
+
view = view === 'team' ? 'team' : 'mine'
|
|
812
|
+
const opts = { view, mayEdit: true, sessionAction: prSessionAction }
|
|
813
|
+
if (!prsUi.data[view]) $app.innerHTML = banners() + prsPageHtml(null, opts)
|
|
814
|
+
const fresh = prsUi.fresh; prsUi.fresh = false
|
|
815
|
+
let d
|
|
816
|
+
try { d = await api('GET', '/v1/prs?view=' + view + (fresh ? '&fresh=1' : ''), undefined, { timeoutMs: 30000 }) }
|
|
817
|
+
catch (e) { if (e.code === 'login_required') throw e; d = { error: 'Couldn’t load PRs: ' + e.message } }
|
|
818
|
+
if (!(state.route && state.route.name === 'prs')) return
|
|
819
|
+
if (d && d.groups) {
|
|
820
|
+
// Go / Resume on a row look the session up like a card does.
|
|
821
|
+
for (const g of d.groups) for (const r of g.rows || []) for (const x of r.sessions || []) if (!state.sessions.some((y) => y.id === x.id)) state.sessions.push(x)
|
|
822
|
+
d.multiMachine = new Set(state.sessions.map((x) => x.machine).filter(Boolean)).size > 1
|
|
823
|
+
}
|
|
824
|
+
prsUi.data[view] = d
|
|
825
|
+
$app.innerHTML = banners() + prsPageHtml(d, opts)
|
|
826
|
+
clearTimeout(prsUi.timer)
|
|
827
|
+
if (d && d.loading && prsUi.polls < 6) { prsUi.polls++; prsUi.timer = setTimeout(() => { if (state.route && state.route.name === 'prs') render() }, 2500) }
|
|
828
|
+
else if (!(d && d.loading)) prsUi.polls = 0
|
|
829
|
+
}
|
|
830
|
+
function prSessionAction(s) {
|
|
831
|
+
if (s.live === 'closed' || s.status === 'ended') {
|
|
832
|
+
const b = resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty })
|
|
833
|
+
return b ? b.replace('class="btn primary"', 'class="btn sm"') : ''
|
|
834
|
+
}
|
|
835
|
+
const g = goButton(s)
|
|
836
|
+
return g && !/disabled/.test(g) ? g.replace('class="btn go', 'class="btn sm go') : ''
|
|
837
|
+
}
|
|
838
|
+
|
|
785
839
|
// Timeline (0.4.3): the board's repo chips (same remembered pick), a session
|
|
786
840
|
// filter and a trigger filter. #/timeline?repo=a,b&session=<id>&trigger=manual
|
|
787
841
|
const TRIGGER_FILTERS = { manual: { label: 'Manual', test: (t) => t === 'manual' }, auto: { label: 'Automatic', test: (t) => t !== 'manual' && t !== 'session_end' }, closed: { label: 'Closed', test: (t) => t === 'session_end' } }
|
|
@@ -2374,6 +2428,7 @@ async function render() {
|
|
|
2374
2428
|
if (r.name === 's' && r.arg) await viewSession(r.arg)
|
|
2375
2429
|
else if (r.name === 'waiting') await viewWaiting()
|
|
2376
2430
|
else if (r.name === 'timeline') await viewTimeline()
|
|
2431
|
+
else if (r.name === 'prs') await viewPrs()
|
|
2377
2432
|
else if (r.name === 'digest') await viewDigest(r.arg, r.params.get('machine'))
|
|
2378
2433
|
else if (r.name === 'catchup') await viewCatchup(r.arg)
|
|
2379
2434
|
else if (r.name === 'lists') await viewLists(r.arg)
|
|
@@ -2551,7 +2606,7 @@ window.addEventListener('resize', () => { placeStatus() })
|
|
|
2551
2606
|
// ⌘K search: a centered panel with the results grouped as you type. Enter on
|
|
2552
2607
|
// nothing picked opens the full results page (#/search?q=…), as before.
|
|
2553
2608
|
const searchUi = { seq: 0, items: [], active: -1, timer: null, last: null }
|
|
2554
|
-
const PAGES = [['Board', '#/'], ['Needs you', '#/waiting'], ['Lists', '#/lists'], ['Timeline', '#/timeline'], ['Digest', '#/digest'], ['Catch me up', '#/catchup/new'], ['What’s new', '#/whats-new'], ['Settings', '#/settings']]
|
|
2609
|
+
const PAGES = [['Board', '#/'], ['Needs you', '#/waiting'], ['Lists', '#/lists'], ['PRs', '#/prs'], ['Timeline', '#/timeline'], ['Digest', '#/digest'], ['Catch me up', '#/catchup/new'], ['What’s new', '#/whats-new'], ['Settings', '#/settings']]
|
|
2555
2610
|
const searchIsOpen = () => !document.getElementById('cmdk').hidden
|
|
2556
2611
|
function openSearch(prefill) {
|
|
2557
2612
|
const box = document.getElementById('cmdk'), q = document.getElementById('q')
|
|
@@ -2662,6 +2717,14 @@ document.addEventListener('click', async (ev) => {
|
|
|
2662
2717
|
const s = state.sessions.find((x) => x.id === id) || (closedUi.data && closedUi.data.sessions.find((x) => x.id === id))
|
|
2663
2718
|
try {
|
|
2664
2719
|
if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
|
|
2720
|
+
// PRs (0.6.9)
|
|
2721
|
+
if (action === 'prs-view') { const v = btn.dataset.view === 'team' ? 'team' : 'mine'; try { localStorage.setItem('leftoff.prsView', v) } catch {} location.hash = v === 'team' ? '#/prs?view=team' : '#/prs'; return }
|
|
2722
|
+
if (action === 'prs-refresh') { btn.disabled = true; prsUi.fresh = true; return render() }
|
|
2723
|
+
if (action === 'pr-unlink') {
|
|
2724
|
+
btn.disabled = true
|
|
2725
|
+
try { await api('POST', '/v1/sessions/' + encodeURIComponent(btn.dataset.id) + '/prs', { ref: btn.dataset.url, unlink: true }); toast('Unlinked') } catch (e) { btn.disabled = false; toast(e.message, 5000); return }
|
|
2726
|
+
return render()
|
|
2727
|
+
}
|
|
2665
2728
|
if (action === 'gh-check') {
|
|
2666
2729
|
btn.disabled = true
|
|
2667
2730
|
try { state.gh = await api('GET', '/v1/gh?fresh=1') } catch { state.gh = null }
|
|
@@ -2908,6 +2971,15 @@ document.addEventListener('click', async (ev) => {
|
|
|
2908
2971
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
2909
2972
|
if (action === 'need-more') return needMoreClick(btn)
|
|
2910
2973
|
if (action === 'need-dismiss') return needDismissClick(btn)
|
|
2974
|
+
// ---- To do and Files (0.7.0)
|
|
2975
|
+
if (action === 'todo-restore' || action === 'todo-restore-all') {
|
|
2976
|
+
btn.disabled = true
|
|
2977
|
+
state.todoDoneOpen = true
|
|
2978
|
+
try { await api('POST', '/v1/todos/restore', action === 'todo-restore-all' ? { all: true } : { key: btn.dataset.todo }) } catch (e) { toast(e.message, 5000) }
|
|
2979
|
+
return render()
|
|
2980
|
+
}
|
|
2981
|
+
if (action === 'file-open') return openDoc(btn.dataset.session, { path: btn.dataset.path, plan: btn.dataset.plan })
|
|
2982
|
+
if (action === 'doc-close') return closeDoc()
|
|
2911
2983
|
if (action === 'need-restore' || action === 'need-restore-all') {
|
|
2912
2984
|
btn.disabled = true
|
|
2913
2985
|
state.dismissedOpen = true
|
|
@@ -2917,7 +2989,9 @@ document.addEventListener('click', async (ev) => {
|
|
|
2917
2989
|
if (action === 'go') return goClick(btn, id, s)
|
|
2918
2990
|
if (action === 'resume-open') return resumeOpen(btn, btn.dataset.id || id)
|
|
2919
2991
|
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 }
|
|
2920
|
-
|
|
2992
|
+
// 0.7.0: only sessions with docs, plans or artifacts
|
|
2993
|
+
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 }
|
|
2994
|
+
if (action === 'closed-clear') { closedUi.docs = false; closedUi.q = ''; closedUi.limit = CLOSED_PAGE; state.boardSpace = null; saveSpace(null); return setBoardFilter({ repos: [], status: 'closed' }) }
|
|
2921
2995
|
if (action && action.startsWith('st-')) return stAction(action, btn)
|
|
2922
2996
|
if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
|
|
2923
2997
|
if (action === 'resume') {
|
|
@@ -3060,6 +3134,65 @@ async function needMoreClick(btn) {
|
|
|
3060
3134
|
paint()
|
|
3061
3135
|
}
|
|
3062
3136
|
|
|
3137
|
+
// Tick a to-do (0.7.0): done on every board at once, kept in the folder, Undo for 5 s.
|
|
3138
|
+
async function todoDoneChange(cb) {
|
|
3139
|
+
const key = cb.dataset.todoDone
|
|
3140
|
+
if (!key || !cb.checked) return
|
|
3141
|
+
const li = cb.closest('li.todo')
|
|
3142
|
+
if (li) li.classList.add('ticked')
|
|
3143
|
+
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 }
|
|
3144
|
+
for (const s of state.sessions || []) if (s.todos) s.todos = s.todos.filter((t) => t.key !== key)
|
|
3145
|
+
toastUndo('Done', async () => {
|
|
3146
|
+
try { await api('POST', '/v1/todos/restore', { key }) } catch (e) { toast(e.message, 5000) }
|
|
3147
|
+
render()
|
|
3148
|
+
})
|
|
3149
|
+
setTimeout(() => { if (!busy()) render() }, 400)
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
// The in-app viewer (0.7.0): a doc or plan a session wrote, read from the Mac
|
|
3153
|
+
// that owns it (through another Mac or the relay from a phone). Markdown is
|
|
3154
|
+
// rendered from escaped text; HTML files show as source; anything else downloads.
|
|
3155
|
+
function docViewerEl() {
|
|
3156
|
+
let el = document.getElementById('docViewer')
|
|
3157
|
+
if (el) return el
|
|
3158
|
+
el = document.createElement('div')
|
|
3159
|
+
el.id = 'docViewer'
|
|
3160
|
+
el.className = 'doc-viewer'
|
|
3161
|
+
el.hidden = true
|
|
3162
|
+
el.setAttribute('role', 'dialog')
|
|
3163
|
+
el.setAttribute('aria-modal', 'true')
|
|
3164
|
+
el.setAttribute('aria-labelledby', 'docViewerTitle')
|
|
3165
|
+
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>'
|
|
3166
|
+
el.addEventListener('click', (ev) => { if (ev.target === el) closeDoc() })
|
|
3167
|
+
document.body.appendChild(el)
|
|
3168
|
+
return el
|
|
3169
|
+
}
|
|
3170
|
+
function closeDoc() { const el = document.getElementById('docViewer'); if (el) { el.hidden = true; el.querySelector('.dv-body').innerHTML = '' } document.documentElement.classList.remove('dv-open') }
|
|
3171
|
+
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && document.getElementById('docViewer') && !document.getElementById('docViewer').hidden) closeDoc() })
|
|
3172
|
+
async function openDoc(sessionId, { path, plan } = {}) {
|
|
3173
|
+
if (!sessionId || (!path && !plan)) return
|
|
3174
|
+
const el = docViewerEl()
|
|
3175
|
+
const name = plan ? 'Plan' : String(path).split('/').pop()
|
|
3176
|
+
el.querySelector('.dv-title').textContent = name
|
|
3177
|
+
el.querySelector('.dv-sub').textContent = ''
|
|
3178
|
+
el.querySelector('.dv-body').innerHTML = '<div class="empty">Opening…</div>'
|
|
3179
|
+
const dl = el.querySelector('.dv-dl'); dl.hidden = true; dl.removeAttribute('href')
|
|
3180
|
+
el.hidden = false
|
|
3181
|
+
document.documentElement.classList.add('dv-open')
|
|
3182
|
+
const qs = new URLSearchParams(plan ? { plan } : { path })
|
|
3183
|
+
try {
|
|
3184
|
+
const r = await api('GET', '/v1/sessions/' + encodeURIComponent(sessionId) + '/file?' + qs)
|
|
3185
|
+
if (el.hidden) return
|
|
3186
|
+
el.querySelector('.dv-title').textContent = r.name || name
|
|
3187
|
+
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(' · ')
|
|
3188
|
+
el.querySelector('.dv-body').innerHTML = docViewerBody(r)
|
|
3189
|
+
const href = docDownloadHref(r)
|
|
3190
|
+
if (href) { dl.href = href; dl.download = String(r.name || name).split('/').pop() + (r.plan ? '.md' : ''); dl.hidden = false }
|
|
3191
|
+
} catch (e) {
|
|
3192
|
+
el.querySelector('.dv-body').innerHTML = '<div class="empty">' + esc(e.message) + '</div>'
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3063
3196
|
// Dismiss a need (0.6.5): gone from every card and count at once, kept on
|
|
3064
3197
|
// the daemon (so your other devices and the relay see it too), Undo for 5 s.
|
|
3065
3198
|
async function needDismissClick(btn) {
|
|
@@ -3089,6 +3222,25 @@ function goClick(btn, id, s) {
|
|
|
3089
3222
|
}
|
|
3090
3223
|
|
|
3091
3224
|
document.addEventListener('submit', async (ev) => {
|
|
3225
|
+
if (ev.target.dataset && ev.target.dataset.todoAdd) {
|
|
3226
|
+
ev.preventDefault()
|
|
3227
|
+
const input = ev.target.querySelector('input[name=todo]')
|
|
3228
|
+
const text = input.value.trim()
|
|
3229
|
+
if (!text) { input.focus(); return }
|
|
3230
|
+
const sid = ev.target.dataset.todoAdd === '-' ? null : ev.target.dataset.todoAdd
|
|
3231
|
+
const b = ev.target.querySelector('button'); if (b) b.disabled = true
|
|
3232
|
+
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 }
|
|
3233
|
+
return
|
|
3234
|
+
}
|
|
3235
|
+
if (ev.target.dataset && ev.target.dataset.prLink) {
|
|
3236
|
+
ev.preventDefault()
|
|
3237
|
+
const input = ev.target.querySelector('input[name=ref]')
|
|
3238
|
+
const ref = input.value.trim()
|
|
3239
|
+
if (!ref) { input.focus(); return }
|
|
3240
|
+
const b = ev.target.querySelector('button'); if (b) b.disabled = true
|
|
3241
|
+
try { const r = await api('POST', '/v1/sessions/' + encodeURIComponent(ev.target.dataset.prLink) + '/prs', { ref }); input.value = ''; toast('Linked #' + String(r.url || '').split('/').pop()) ; await render() } catch (e) { toast(e.message, 5000) } finally { if (b) b.disabled = false }
|
|
3242
|
+
return
|
|
3243
|
+
}
|
|
3092
3244
|
if (ev.target.dataset && ev.target.dataset.listAdd) {
|
|
3093
3245
|
ev.preventDefault()
|
|
3094
3246
|
const input = ev.target.querySelector('input[name=text]')
|
|
@@ -3146,7 +3298,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
3146
3298
|
|
|
3147
3299
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
3148
3300
|
let pending = null
|
|
3149
|
-
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))
|
|
3301
|
+
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))
|
|
3150
3302
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
3151
3303
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
3152
3304
|
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) }
|
|
@@ -3154,7 +3306,7 @@ let stream = null
|
|
|
3154
3306
|
function connect() {
|
|
3155
3307
|
if (stream) { try { stream.close() } catch {} }
|
|
3156
3308
|
const es = stream = new EventSource('/v1/stream')
|
|
3157
|
-
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated']) es.addEventListener(t, soon)
|
|
3309
|
+
for (const t of ['session.updated', 'checkpoint.created', 'digest.created', 'spaces.updated', 'catchup.created', 'needs.updated', 'restore.updated', 'lists.updated', 'peers.updated', 'agents.updated', 'relay.updated', 'prs.updated']) es.addEventListener(t, soon)
|
|
3158
3310
|
// v0.5.8: standup text prefetched for a digest is stale once spaces or the digest change
|
|
3159
3311
|
for (const t of ['spaces.updated', 'digest.created', 'checkpoint.created']) es.addEventListener(t, () => { digestUi.standups = {} })
|
|
3160
3312
|
es.addEventListener('model.pull', (ev) => { try { onPull(JSON.parse(ev.data)) } catch {} })
|
|
@@ -3165,6 +3317,7 @@ function connect() {
|
|
|
3165
3317
|
}
|
|
3166
3318
|
|
|
3167
3319
|
document.addEventListener('change', async (ev) => {
|
|
3320
|
+
if (ev.target.dataset && ev.target.dataset.todoDone) return todoDoneChange(ev.target)
|
|
3168
3321
|
// Spaces (v0.5.8): a session's or agent's own space, a Mac's default.
|
|
3169
3322
|
if (ev.target.dataset && ev.target.dataset.spaceKind) {
|
|
3170
3323
|
const sel = ev.target
|