@leftoff/cli 0.6.9 → 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 +7 -0
- package/bin/leftoff.js +304 -295
- 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 +78 -0
- package/ui/app.js +105 -10
- package/ui/handoffs.js +184 -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', 'prLinks', 'prUnlinked']
|
|
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
|
@@ -1441,3 +1441,81 @@ a.cpr:hover { border-color: var(--line-strong); }
|
|
|
1441
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
1442
|
.stat.on .n { color: var(--accent); }
|
|
1443
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
|
@@ -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)
|
|
@@ -298,7 +299,7 @@ function card(s) {
|
|
|
298
299
|
'</div>' + pill(s.status) + '</div>' +
|
|
299
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>' +
|
|
300
301
|
(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) +
|
|
302
|
+
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
302
303
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
303
304
|
'<div class="actions-row">' + goButton(s) +
|
|
304
305
|
'<button type="button" class="btn" data-action="resume">Copy resume</button>' +
|
|
@@ -371,7 +372,7 @@ function chipBar(chips, selected, allCount, emptyNote) {
|
|
|
371
372
|
async function viewBoard() {
|
|
372
373
|
if (!state.health) await refreshHealth()
|
|
373
374
|
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) : ''))
|
|
375
|
+
const { sessions, viewer, closedCount, todoCount } = await api('GET', '/v1/sessions?limit=200' + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : ''))
|
|
375
376
|
state.sessions = sessions
|
|
376
377
|
state.viewerLocal = viewer ? viewer.local !== false : true
|
|
377
378
|
state.ghostty = viewer ? viewer.ghostty === true : false
|
|
@@ -414,8 +415,10 @@ async function viewBoard() {
|
|
|
414
415
|
// Needs you (v0.4.4): every session waiting on you, whatever the filters, longest wait first.
|
|
415
416
|
const needs = needsItems(sessions)
|
|
416
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)
|
|
417
420
|
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>'
|
|
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>'
|
|
419
422
|
html += spaceTabs(spaces, sp, spCounts, 'board-space', allShown.length)
|
|
420
423
|
if (filter === 'closed') {
|
|
421
424
|
html += await closedSection(f, sp)
|
|
@@ -443,10 +446,11 @@ async function viewBoard() {
|
|
|
443
446
|
// a search box and Show more. Each card has its last checkpoint and Resume,
|
|
444
447
|
// which reopens it in a new Ghostty tab (claude --resume) on the Mac that owns it.
|
|
445
448
|
const CLOSED_PAGE = 30
|
|
446
|
-
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 }
|
|
447
450
|
function closedQuery(f, sp) {
|
|
448
451
|
const qs = new URLSearchParams({ limit: String(closedUi.limit) })
|
|
449
452
|
if (closedUi.q.trim()) qs.set('q', closedUi.q.trim())
|
|
453
|
+
if (closedUi.docs) qs.set('docs', '1')
|
|
450
454
|
if (state.machine) qs.set('machine', state.machine)
|
|
451
455
|
if (f.repos.length) qs.set('repo', f.repos.join(','))
|
|
452
456
|
if (sp) qs.set('space', sp)
|
|
@@ -461,12 +465,14 @@ async function closedFetch(f, sp) {
|
|
|
461
465
|
async function closedSection(f, sp) {
|
|
462
466
|
const r = await closedFetch(f, sp)
|
|
463
467
|
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
|
|
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>' +
|
|
465
470
|
'<div id="closedList">' + closedListHtml(r, f, sp) + '</div>'
|
|
466
471
|
}
|
|
467
472
|
function closedListHtml(r, f, sp) {
|
|
468
473
|
if (!r.sessions.length) {
|
|
469
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>'
|
|
470
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.') +
|
|
471
477
|
(f.repos.length || sp || closedUi.q ? ' <button type="button" class="linkish" data-action="closed-clear">Clear</button>' : '') + '</div>'
|
|
472
478
|
}
|
|
@@ -497,7 +503,7 @@ function closedCard(s, viewer) {
|
|
|
497
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>' +
|
|
498
504
|
'<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
|
|
499
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>' +
|
|
500
|
-
cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
506
|
+
cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
501
507
|
'<div class="actions-row">' + resumeButton(s, viewer) +
|
|
502
508
|
'<button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' +
|
|
503
509
|
'<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
|
|
@@ -614,11 +620,14 @@ async function viewWaiting() {
|
|
|
614
620
|
const f = boardFilter()
|
|
615
621
|
const shownSessions = applyFilter(all.map((n) => n.session), { repos: f.repos })
|
|
616
622
|
const items = all.filter((n) => shownSessions.includes(n.session))
|
|
617
|
-
|
|
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.' +
|
|
618
626
|
(state.mayAnswer ? '' : ' Answering from this device is off.') + '</div></div></div>'
|
|
619
627
|
html += chipBar(repoChips(all.map((n) => n.session)), f.repos, all.length, 'nothing waiting')
|
|
620
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>'
|
|
621
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 })
|
|
622
631
|
const recent = needs.recent || []
|
|
623
632
|
html += '<details class="recent"' + (state.recentOpen !== false ? ' open' : '') + '><summary>Recently answered <span class="count">' + recent.length + '</span></summary>' +
|
|
624
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>' +
|
|
@@ -630,8 +639,10 @@ async function viewWaiting() {
|
|
|
630
639
|
html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
|
|
631
640
|
$app.innerHTML = banners() + html
|
|
632
641
|
// Open/closed survives live repaints (0.6.5).
|
|
633
|
-
const dz = $app.querySelector('details.dismissed')
|
|
642
|
+
const dz = $app.querySelector('details.dismissed:not(.todo-done-list)')
|
|
634
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 })
|
|
635
646
|
const rc = $app.querySelector('details.recent')
|
|
636
647
|
if (rc) rc.addEventListener('toggle', () => { state.recentOpen = rc.open })
|
|
637
648
|
}
|
|
@@ -748,6 +759,9 @@ async function viewSession(id) {
|
|
|
748
759
|
fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
|
749
760
|
html += spacePicker(s, spv, 'session', s.id)
|
|
750
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>'
|
|
751
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>'
|
|
752
766
|
if (s.statusChanges && s.statusChanges.length) {
|
|
753
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>'
|
|
@@ -2957,6 +2971,15 @@ document.addEventListener('click', async (ev) => {
|
|
|
2957
2971
|
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
2958
2972
|
if (action === 'need-more') return needMoreClick(btn)
|
|
2959
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()
|
|
2960
2983
|
if (action === 'need-restore' || action === 'need-restore-all') {
|
|
2961
2984
|
btn.disabled = true
|
|
2962
2985
|
state.dismissedOpen = true
|
|
@@ -2966,7 +2989,9 @@ document.addEventListener('click', async (ev) => {
|
|
|
2966
2989
|
if (action === 'go') return goClick(btn, id, s)
|
|
2967
2990
|
if (action === 'resume-open') return resumeOpen(btn, btn.dataset.id || id)
|
|
2968
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 }
|
|
2969
|
-
|
|
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' }) }
|
|
2970
2995
|
if (action && action.startsWith('st-')) return stAction(action, btn)
|
|
2971
2996
|
if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
|
|
2972
2997
|
if (action === 'resume') {
|
|
@@ -3109,6 +3134,65 @@ async function needMoreClick(btn) {
|
|
|
3109
3134
|
paint()
|
|
3110
3135
|
}
|
|
3111
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
|
+
|
|
3112
3196
|
// Dismiss a need (0.6.5): gone from every card and count at once, kept on
|
|
3113
3197
|
// the daemon (so your other devices and the relay see it too), Undo for 5 s.
|
|
3114
3198
|
async function needDismissClick(btn) {
|
|
@@ -3138,6 +3222,16 @@ function goClick(btn, id, s) {
|
|
|
3138
3222
|
}
|
|
3139
3223
|
|
|
3140
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
|
+
}
|
|
3141
3235
|
if (ev.target.dataset && ev.target.dataset.prLink) {
|
|
3142
3236
|
ev.preventDefault()
|
|
3143
3237
|
const input = ev.target.querySelector('input[name=ref]')
|
|
@@ -3204,7 +3298,7 @@ document.addEventListener('submit', async (ev) => {
|
|
|
3204
3298
|
|
|
3205
3299
|
// Live updates over SSE; re-render the current view, at most every 800 ms.
|
|
3206
3300
|
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))
|
|
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))
|
|
3208
3302
|
// Settings isn't redrawn by live updates: it would wipe what you're editing,
|
|
3209
3303
|
// and the QR code you're scanning (v0.4.6). Its sections fetch their own state.
|
|
3210
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) }
|
|
@@ -3223,6 +3317,7 @@ function connect() {
|
|
|
3223
3317
|
}
|
|
3224
3318
|
|
|
3225
3319
|
document.addEventListener('change', async (ev) => {
|
|
3320
|
+
if (ev.target.dataset && ev.target.dataset.todoDone) return todoDoneChange(ev.target)
|
|
3226
3321
|
// Spaces (v0.5.8): a session's or agent's own space, a Mac's default.
|
|
3227
3322
|
if (ev.target.dataset && ev.target.dataset.spaceKind) {
|
|
3228
3323
|
const sel = ev.target
|
package/ui/handoffs.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// To do and Files (0.7.0). Pure HTML builders over what the daemon sends:
|
|
2
|
+
// /v1/needs .todos ({ open, done }) and a session's todos / files / filesSummary.
|
|
3
|
+
// app.js does the fetching and wires the clicks. renderDoc is the viewer's
|
|
4
|
+
// Markdown: everything is escaped first, so raw HTML in a doc shows as text and
|
|
5
|
+
// never runs; links open only for https. Unit tests: test/unit/handoffs-ui.test.mjs.
|
|
6
|
+
import { mdInline } from './changelog-md.js'
|
|
7
|
+
|
|
8
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
9
|
+
const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many || one + 's')
|
|
10
|
+
const safeArtifact = (u) => (/^https:\/\/claude\.ai\/code\/artifact\/[A-Za-z0-9_-]{4,80}$/.test(String(u || '')) ? String(u) : '')
|
|
11
|
+
|
|
12
|
+
// ------------------------------------------------------------------ to-dos
|
|
13
|
+
|
|
14
|
+
export function todoRow(t, { agoFn = null, mayEdit = true } = {}) {
|
|
15
|
+
const codes = (t.codes || []).map((c) => '<button type="button" class="todo-code" data-action="copy-cmd" data-cmd="' + esc(c) + '" title="Copy"><code>' + esc(c.length > 60 ? c.slice(0, 57) + '…' : c) + '</code><span class="todo-cp" aria-hidden="true">Copy</span></button>').join('')
|
|
16
|
+
const meta = [t.manual ? 'you added' : null, agoFn && t.at ? agoFn(t.at) : null].filter(Boolean).join(' · ')
|
|
17
|
+
return '<li class="todo" data-todo="' + esc(t.key) + '"><label class="todo-check"><input type="checkbox" data-todo-done="' + esc(t.key) + '"' + (mayEdit ? '' : ' disabled') + ' aria-label="Done: ' + esc(t.text) + '"><span class="box" aria-hidden="true"></span></label>' +
|
|
18
|
+
'<div class="todo-main"><div class="todo-text">' + mdInline(t.text) + '</div>' + (codes ? '<div class="todo-codes">' + codes + '</div>' : '') + (meta ? '<div class="todo-meta">' + esc(meta) + '</div>' : '') + '</div></li>'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function todoAddForm(sessionId = '') {
|
|
22
|
+
return '<form class="todo-add addform" data-todo-add="' + esc(sessionId || '-') + '" autocomplete="off"><input type="text" name="todo" maxlength="200" placeholder="Add a to-do" aria-label="Add a to-do" enterkeyhint="done"><button type="submit" class="btn sm">Add</button></form>'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Open to-dos grouped by session (your own, with no session, last).
|
|
26
|
+
export function groupTodos(open = []) {
|
|
27
|
+
const groups = new Map()
|
|
28
|
+
for (const t of open) {
|
|
29
|
+
const id = (t.session && t.session.id) || ''
|
|
30
|
+
if (!groups.has(id)) groups.set(id, { session: t.session || null, items: [] })
|
|
31
|
+
groups.get(id).items.push(t)
|
|
32
|
+
}
|
|
33
|
+
return [...groups.values()].sort((a, b) => (!a.session) - (!b.session) || String(b.items[b.items.length - 1].at || '').localeCompare(String(a.items[a.items.length - 1].at || '')))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function doneTodosHtml(done = [], { open = false, agoFn = null } = {}) {
|
|
37
|
+
if (!done.length) return ''
|
|
38
|
+
return '<details class="dismissed todo-done-list"' + (open ? ' open' : '') + '><summary>Done (' + done.length + ')</summary><ul>' +
|
|
39
|
+
done.slice(0, 100).map((d) => '<li><div class="dl-main"><span class="dl-title">' + mdInline(d.text) + '</span>' + (d.session ? '<span class="dl-line">' + esc(d.session.title) + '</span>' : '') +
|
|
40
|
+
(agoFn && d.doneAt ? '<span class="dl-at">done ' + esc(agoFn(d.doneAt)) + '</span>' : '') + '</div>' +
|
|
41
|
+
'<button type="button" class="btn sm" data-action="todo-restore" data-todo="' + esc(d.key) + '">Restore</button></li>').join('') +
|
|
42
|
+
'</ul>' + (done.length > 1 ? '<button type="button" class="linkish" data-action="todo-restore-all">Restore all</button>' : '') + '</details>'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The Needs view's "To do" section.
|
|
46
|
+
export function todosSection(ov, { agoFn = null, mayEdit = true, doneOpen = false, repos = [] } = {}) {
|
|
47
|
+
const open = ((ov && ov.open) || []).filter((t) => !repos.length || (t.session && repos.includes(t.session.repoLabel)) || !t.session)
|
|
48
|
+
const head = '<div class="needs-head todo-head"><h2>To do <span class="count">' + open.length + '</span></h2><span class="sub">Steps sessions handed back to you, and your own</span></div>'
|
|
49
|
+
const body = open.length
|
|
50
|
+
? groupTodos(open).map((g) => '<div class="todo-group">' + (g.session ? '<div class="todo-sess"><a href="#/s/' + encodeURIComponent(g.session.id) + '">' + esc(g.session.title) + '</a>' + (g.session.repoLabel ? '<span class="chip mono">' + esc(g.session.repoLabel) + '</span>' : '') + '</div>' : '<div class="todo-sess"><span>Your to-dos</span></div>') +
|
|
51
|
+
'<ul class="todos">' + g.items.map((t) => todoRow(t, { agoFn, mayEdit })).join('') + '</ul></div>').join('')
|
|
52
|
+
: '<div class="nocp">No to-dos. When a session says you need to do something yourself (run a command, set a secret, add DNS records), it shows up here.</div>'
|
|
53
|
+
return '<section class="todos-sec mt" aria-label="To do">' + head + (mayEdit ? todoAddForm('') : '') + body + doneTodosHtml((ov && ov.done) || [], { open: doneOpen, agoFn }) + '</section>'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A session page's to-dos.
|
|
57
|
+
export function sessionTodosSection(s, { agoFn = null, mayEdit = true } = {}) {
|
|
58
|
+
const list = s.todos || []
|
|
59
|
+
if (!list.length && !mayEdit) return ''
|
|
60
|
+
return '<h2>To do <span class="count">' + list.length + '</span></h2>' +
|
|
61
|
+
(list.length ? '<ul class="todos">' + list.map((t) => todoRow(t, { agoFn, mayEdit })).join('') + '</ul>' : '') + (mayEdit ? todoAddForm(s.id) : '')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ------------------------------------------------------------------ chips
|
|
65
|
+
|
|
66
|
+
export function filesChipLabel(fs, { compact = false } = {}) {
|
|
67
|
+
if (!fs) return ''
|
|
68
|
+
const parts = []
|
|
69
|
+
const docs = (fs.docs || 0) + (fs.plans || 0)
|
|
70
|
+
if (docs) parts.push(compact ? docs + (docs === 1 ? ' doc' : ' docs') : plural(docs, 'doc'))
|
|
71
|
+
if (fs.artifacts) parts.push(plural(fs.artifacts, 'artifact'))
|
|
72
|
+
return parts.join(' · ')
|
|
73
|
+
}
|
|
74
|
+
// The card's small chips: "2 to-dos" and "2 docs · 1 artifact".
|
|
75
|
+
export function cardHandoffChips(s) {
|
|
76
|
+
const n = (s.todos || []).length
|
|
77
|
+
const fl = filesChipLabel(s.filesSummary)
|
|
78
|
+
if (!n && !fl) return ''
|
|
79
|
+
const href = '#/s/' + encodeURIComponent(s.id)
|
|
80
|
+
return '<div class="handoff-chips">' + (n ? '<a class="chip todochip" href="' + href + '" title="Steps handed back to you">☐ ' + esc(plural(n, 'to-do')) + '</a>' : '') +
|
|
81
|
+
(fl ? '<a class="chip fileschip" href="' + href + '" title="Docs and artifacts from this session">' + esc(fl) + '</a>' : '') + '</div>'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ------------------------------------------------------------------ files
|
|
85
|
+
|
|
86
|
+
const TYPE_ICON = { markdown: 'MD', image: 'IMG', pdf: 'PDF', html: 'HTML', text: 'TXT', other: 'FILE' }
|
|
87
|
+
export function sessionFilesSection(s, { agoFn = null } = {}) {
|
|
88
|
+
const f = s.files
|
|
89
|
+
if (!f) return ''
|
|
90
|
+
const arts = (f.artifacts || []).filter((a) => safeArtifact(a.url))
|
|
91
|
+
const plans = f.plans || []
|
|
92
|
+
const docs = f.docs || []
|
|
93
|
+
if (!arts.length && !plans.length && !docs.length && !f.code) return ''
|
|
94
|
+
const when = (at) => (agoFn && at ? '<span class="f-at">' + esc(agoFn(at)) + '</span>' : '')
|
|
95
|
+
let h = '<h2>Files</h2><ul class="files-list">'
|
|
96
|
+
for (const a of arts) h += '<li class="f-art"><a class="f-row" href="' + esc(safeArtifact(a.url)) + '" target="_blank" rel="noopener noreferrer"><span class="f-ic art" aria-hidden="true">◆</span><span class="f-name">' + esc(a.title) + '</span><span class="f-kind">Artifact ↗</span>' + when(a.at) + '</a></li>'
|
|
97
|
+
for (const p of plans) h += '<li><button type="button" class="f-row" data-action="file-open" data-plan="' + esc(p.id) + '" data-session="' + esc(s.id) + '"><span class="f-ic" aria-hidden="true">PLAN</span><span class="f-name">' + esc(p.title) + '</span><span class="f-kind">Plan</span>' + when(p.at) + '</button></li>'
|
|
98
|
+
for (const d of docs) h += '<li><button type="button" class="f-row" data-action="file-open" data-path="' + esc(d.path) + '" data-session="' + esc(s.id) + '"><span class="f-ic" aria-hidden="true">' + (d.plan ? 'PLAN' : TYPE_ICON[d.type] || 'FILE') + '</span><span class="f-name mono">' + esc(d.name) + '</span><span class="f-kind ' + (d.kind === 'new' ? 'new' : '') + '">' + (d.plan ? 'Plan' : d.kind === 'new' ? 'new' : 'edited') + '</span>' + when(d.at) + '</button></li>'
|
|
99
|
+
h += '</ul>'
|
|
100
|
+
if (f.code) h += '<div class="f-code faint">' + esc(plural(f.code, 'code file')) + ' changed</div>'
|
|
101
|
+
return h
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ------------------------------------------------------------------ viewer
|
|
105
|
+
|
|
106
|
+
// Markdown -> safe HTML: headings, paragraphs, lists, task boxes, quotes,
|
|
107
|
+
// fenced code, tables, rules. Inline markup via mdInline (escaped; https links only).
|
|
108
|
+
export function renderDoc(md) {
|
|
109
|
+
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').replace(/^---\n[\s\S]*?\n---\n/, '').split('\n')
|
|
110
|
+
const out = []
|
|
111
|
+
let i = 0
|
|
112
|
+
const para = []
|
|
113
|
+
const flush = () => { if (para.length) { out.push('<p>' + para.map((x) => mdInline(x)).join('<br>') + '</p>'); para.length = 0 } }
|
|
114
|
+
while (i < lines.length) {
|
|
115
|
+
const ln = lines[i]
|
|
116
|
+
let m
|
|
117
|
+
if ((m = ln.match(/^\s*(```|~~~)\s*([\w+-]*)/))) {
|
|
118
|
+
flush()
|
|
119
|
+
const fence = m[1]; const body = []
|
|
120
|
+
i++
|
|
121
|
+
while (i < lines.length && !lines[i].trim().startsWith(fence)) body.push(lines[i++])
|
|
122
|
+
i++
|
|
123
|
+
out.push('<pre class="doc-code"><code>' + esc(body.join('\n')) + '</code></pre>')
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (!ln.trim()) { flush(); i++; continue }
|
|
127
|
+
if ((m = ln.match(/^(#{1,6})\s+(.*)$/))) { flush(); const n = Math.min(6, m[1].length + 1); out.push('<h' + n + '>' + mdInline(m[2].replace(/\s+#+\s*$/, '')) + '</h' + n + '>'); i++; continue }
|
|
128
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(ln)) { flush(); out.push('<hr>'); i++; continue }
|
|
129
|
+
if (/^\s*>/.test(ln)) {
|
|
130
|
+
flush(); const q = []
|
|
131
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, ''))
|
|
132
|
+
out.push('<blockquote>' + renderDoc(q.join('\n')) + '</blockquote>'); continue
|
|
133
|
+
}
|
|
134
|
+
if (/^\s*\|.*\|\s*$/.test(ln) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
|
|
135
|
+
flush()
|
|
136
|
+
const cells = (r) => r.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
|
|
137
|
+
const head = cells(ln); i += 2
|
|
138
|
+
const rows = []
|
|
139
|
+
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(cells(lines[i++]))
|
|
140
|
+
out.push('<div class="doc-table"><table><thead><tr>' + head.map((c) => '<th>' + mdInline(c) + '</th>').join('') + '</tr></thead><tbody>' + rows.map((r) => '<tr>' + r.map((c) => '<td>' + mdInline(c) + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>')
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(ln)) {
|
|
144
|
+
flush()
|
|
145
|
+
const ordered = /^\s*\d/.test(ln)
|
|
146
|
+
const items = []
|
|
147
|
+
while (i < lines.length && (/^\s*(?:[-*+]|\d{1,3}[.)])\s+/.test(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length))) {
|
|
148
|
+
const l = lines[i++]
|
|
149
|
+
const mm = l.match(/^(\s*)(?:[-*+]|\d{1,3}[.)])\s+(.*)$/)
|
|
150
|
+
if (mm) items.push({ depth: Math.floor(mm[1].replace(/\t/g, ' ').length / 2), text: mm[2] })
|
|
151
|
+
else items[items.length - 1].text += ' ' + l.trim()
|
|
152
|
+
}
|
|
153
|
+
const li = (t) => { const tm = t.match(/^\[([ xX])\]\s+(.*)$/); return tm ? '<li class="task"><span class="tbox' + (tm[1] !== ' ' ? ' on' : '') + '" aria-hidden="true"></span>' + mdInline(tm[2]) + '</li>' : '<li>' + mdInline(t) + '</li>' }
|
|
154
|
+
out.push('<' + (ordered ? 'ol' : 'ul') + '>' + items.map((it) => (it.depth ? li(it.text).replace('<li', '<li class="sub"') : li(it.text))).join('') + '</' + (ordered ? 'ol' : 'ul') + '>')
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
para.push(ln.trim()); i++
|
|
158
|
+
}
|
|
159
|
+
flush()
|
|
160
|
+
return out.join('\n')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The viewer's body for a file from GET /v1/sessions/:id/file.
|
|
164
|
+
export function docViewerBody(r) {
|
|
165
|
+
if (!r) return ''
|
|
166
|
+
if (r.missing) return '<div class="empty">File no longer exists</div>'
|
|
167
|
+
if (r.tooBig) return '<div class="empty">' + esc(r.message || 'Too big to open here.') + '</div>'
|
|
168
|
+
if (r.type === 'markdown') return '<article class="doc-md">' + renderDoc(r.text || '') + '</article>'
|
|
169
|
+
if (r.type === 'image') {
|
|
170
|
+
const src = r.base64 ? 'data:' + (/^image\/(png|jpeg|gif|webp)$/.test(r.mime) ? r.mime : 'application/octet-stream') + ';base64,' + r.base64
|
|
171
|
+
: r.mime === 'image/svg+xml' && typeof r.text === 'string' ? 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(r.text) : ''
|
|
172
|
+
return src ? '<div class="doc-img"><img alt="' + esc(r.name) + '" src="' + esc(src) + '"></div>' : '<div class="empty">Can’t show this image.</div>'
|
|
173
|
+
}
|
|
174
|
+
if (r.type === 'text' || r.type === 'html') return '<pre class="doc-pre">' + esc(r.text || '') + '</pre>'
|
|
175
|
+
return '<div class="empty">No preview for this kind of file.</div>'
|
|
176
|
+
}
|
|
177
|
+
// A download for anything (an HTML file is offered as a file, never rendered live).
|
|
178
|
+
export function docDownloadHref(r) {
|
|
179
|
+
if (!r || r.missing || r.tooBig) return ''
|
|
180
|
+
const mime = r.type === 'html' ? 'text/plain' : r.mime || 'application/octet-stream'
|
|
181
|
+
if (typeof r.base64 === 'string') return 'data:' + mime + ';base64,' + r.base64
|
|
182
|
+
if (typeof r.text === 'string') return 'data:' + mime + ';charset=utf-8,' + encodeURIComponent(r.text)
|
|
183
|
+
return ''
|
|
184
|
+
}
|