@leftoff/cli 0.9.0 → 0.9.3
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 +28 -0
- package/bin/leftoff.js +273 -273
- package/docs/SECURITY.md +4 -0
- package/docs/peer-protocol.md +3 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +7 -0
- package/mod/hooks/register.js +4 -2
- package/package.json +1 -1
- package/ui/app.css +29 -0
- package/ui/app.js +132 -29
- package/ui/direct.js +114 -0
- package/ui/icons.js +3 -0
- package/ui/picker.js +10 -1
- package/ui/relay-client.js +105 -10
package/docs/SECURITY.md
CHANGED
|
@@ -89,6 +89,10 @@ None of this touches the board's data: everything under [What the relay can see]
|
|
|
89
89
|
|
|
90
90
|
A relayed request is replayed against the Mac's own board from inside the daemon (proved with a secret that never leaves memory) and counts as a **remote device**, with the same rules as a phone over Tailscale: it can read the board; answering prompts and pairing new devices need Settings → Privacy & security → *Trust my paired devices* on that Mac (`remote.allowAnswers`; 403 `trust_off` while it's off); revoking a paired device works from any paired device, itself included, since it only cuts access; Go, moving the folder, Syncthing, turning the relay on or off and the trust switch itself are refused (this Mac's business, loopback only). The daemon still listens only on 127.0.0.1: the relay connection is outbound.
|
|
91
91
|
|
|
92
|
+
### Direct when on the same Mac (0.9.3)
|
|
93
|
+
|
|
94
|
+
When your hosted board is open on a Mac that runs Leftoff, the page sends the same sealed requests straight to that Mac's daemon at `http://127.0.0.1:7429/v1/relay/direct` instead of to the relay. Nothing new is trusted: the daemon opens them with the same per-device key (and the same replay and time checks), runs them the same way (a remote device, the rules above), and seals the answer. The page only switches after the daemon says it's on your account and a sealed round trip through it works, so another Leftoff on that port (someone else's account) can't serve your board: it can't open the request or forge the answer. The door is loopback only, never through a proxy, off when Use from anywhere is off, and answers CORS for exactly one origin, `https://<your name>.leftoff.dev` (with Chrome's Private Network Access header); any other page gets a 403 it can't read. If the direct path fails, the request goes through the relay once more and the board stays on the relay until a later check finds the direct path again.
|
|
95
|
+
|
|
92
96
|
### Logging
|
|
93
97
|
|
|
94
98
|
The Worker never logs request or response bodies (it can't read them anyway) and stores only what's listed above, in your Durable Object. `wrangler.toml` turns Workers observability (request logs) off. Cloudflare's edge keeps its usual metadata for any site (IP, hostname, path such as `/_relay/api`, status), under its own policies.
|
package/docs/peer-protocol.md
CHANGED
|
@@ -55,6 +55,9 @@ Each command is one envelope. It's JSON and travels the same way over every tran
|
|
|
55
55
|
| `ask.answer` | `askId`, `body` | `engine.answerAsk()`: the board's Allow / Deny / answer code path |
|
|
56
56
|
| `session.reply` | `sessionId`, `text` | `engine.postReply()`: the board's reply box |
|
|
57
57
|
| `session.go` | `sessionId` | `engine.jump()`: the board's Go |
|
|
58
|
+
| `session.checkpoint` | `sessionId`, `length?`, `userNote?`, `next?` | `engine.generateCheckpoint()`: Checkpoint now (0.9.2); returns the checkpoint, which the asking board lists at once |
|
|
59
|
+
| `session.patch` | `sessionId`, `title?`, `pinned?`, `archived?` | `engine.patchSession()`: rename, Pin, Archive (0.9.2) |
|
|
60
|
+
| `session.title.auto` | `sessionId` | `engine.regenerateTitle()`: Regenerate title (0.9.2) |
|
|
58
61
|
|
|
59
62
|
Answers are recorded in the session's `answers.md` as *from studio's board (direct)* or *(through the relay)*.
|
|
60
63
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
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
|
@@ -1354,6 +1354,13 @@ export function parseCheckpointReply(text) {
|
|
|
1354
1354
|
return { ...out, ...parseSections(rest) }
|
|
1355
1355
|
}
|
|
1356
1356
|
|
|
1357
|
+
// 0.9.2: a checkpoint written with nothing to go on: no prompt and no reply were
|
|
1358
|
+
// found (the heuristic's placeholder lines). Never saved; old ones are set aside.
|
|
1359
|
+
export function isEmptyCheckpoint(cp) {
|
|
1360
|
+
return !!cp && /^No prompt captured yet\.?$/.test(String(cp.doing || '').trim()) && /^No reply captured yet\.?$/.test(String(cp.stoppedAt || '').trim())
|
|
1361
|
+
}
|
|
1362
|
+
export const NOTHING_TO_CHECKPOINT = 'Nothing to checkpoint yet — no messages found for this session'
|
|
1363
|
+
|
|
1357
1364
|
// No model available: build a checkpoint from the last prompt and the last reply.
|
|
1358
1365
|
// Informative / long add what can be read off the transcript without a model:
|
|
1359
1366
|
// the files edited (with the tool), the failed tool calls, the questions asked.
|
package/mod/hooks/register.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// each event is registered once per matcher.
|
|
15
15
|
import {
|
|
16
16
|
DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
|
|
17
|
-
parseCheckpointReply, heuristicCheckpoint, renderCheckpointMd, checkpointId, checkpointFileName,
|
|
17
|
+
parseCheckpointReply, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
|
|
18
18
|
sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
|
|
19
19
|
shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
|
|
20
20
|
parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
|
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.
|
|
31
|
+
const MOD_VERSION = '0.9.3' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
|
@@ -652,6 +652,8 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
|
|
|
652
652
|
parsed = heuristicCheckpoint(messages, record, { length: ctx.length })
|
|
653
653
|
generator = { by: 'heuristic' }
|
|
654
654
|
}
|
|
655
|
+
// 0.9.2: nothing to go on (no prompt, no reply): don't save a placeholder over the last real one
|
|
656
|
+
if (isEmptyCheckpoint(parsed)) { if (trigger === 'manual') $.ui.toast(NOTHING_TO_CHECKPOINT, { timeoutMs: 6000 }); return null }
|
|
655
657
|
if (opts.next) parsed = { ...parsed, next: opts.next }
|
|
656
658
|
const cp = {
|
|
657
659
|
sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
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
|
@@ -1775,3 +1775,32 @@ html.dv-open .lo-menu { z-index: 62; }
|
|
|
1775
1775
|
.pr-more-sess > summary { list-style: none; cursor: pointer; display: inline; }
|
|
1776
1776
|
.pr-more-sess > summary::-webkit-details-marker { display: none; }
|
|
1777
1777
|
.pr-more-sess[open] > summary { display: none; }
|
|
1778
|
+
|
|
1779
|
+
/* 0.9.2: the ⋯ menu's sub lines, its plain note (a Mac that can't be reached),
|
|
1780
|
+
the row of other checkpoint lengths, and the split "Checkpoint now" button. */
|
|
1781
|
+
.lo-act.has-sub { align-items: flex-start; white-space: normal; }
|
|
1782
|
+
.lo-act.has-sub .ic { margin-top: 2px; }
|
|
1783
|
+
.lo-act-txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
1784
|
+
.lo-act-sub { font-size: 12.5px; color: var(--muted); font-weight: 400; line-height: 1.35; }
|
|
1785
|
+
.lo-act-note { display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px 10px; margin-bottom: 4px; border-bottom: 1px solid var(--line); color: var(--text2); font-size: 13.5px; line-height: 1.4; white-space: normal; }
|
|
1786
|
+
.lo-act-note .ic { width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--muted); }
|
|
1787
|
+
.lo-act-row { display: flex; align-items: center; gap: 6px; padding: 2px 10px 6px 36px; }
|
|
1788
|
+
.lo-act-rowlabel { font-size: 12.5px; color: var(--muted); margin-right: 2px; }
|
|
1789
|
+
.lo-act.alt { width: auto; flex: 0 0 auto; padding: 5px 12px; border: 1px solid var(--line-strong); border-radius: 999px; font-size: 13px; color: var(--text2); }
|
|
1790
|
+
.lo-menu.sheet .lo-act-note { font-size: 15px; padding: 10px 10px 12px; }
|
|
1791
|
+
.lo-menu.sheet .lo-act-sub { font-size: 14px; }
|
|
1792
|
+
.lo-menu.sheet .lo-act-row { padding: 0 10px 10px 36px; gap: 8px; }
|
|
1793
|
+
.lo-menu.sheet .lo-act.alt { flex: 1 1 0; justify-content: center; padding: 11px 12px; font-size: 16px; min-height: 44px; }
|
|
1794
|
+
.split { display: inline-flex; align-items: stretch; }
|
|
1795
|
+
.split .btn.split-main { border-top-right-radius: 0; border-bottom-right-radius: 0; }
|
|
1796
|
+
.split .btn.split-more { border-top-left-radius: 0; border-bottom-left-radius: 0; padding: 0 8px; margin-left: 1px; display: inline-flex; align-items: center; }
|
|
1797
|
+
.split .btn.split-more .ic { width: 15px; height: 15px; margin: 0; }
|
|
1798
|
+
@media (pointer: coarse) { .split .btn.split-more { min-width: 40px; justify-content: center; } }
|
|
1799
|
+
.more-wrap { display: inline-flex; }
|
|
1800
|
+
/* 0.9.2: the popover's Theme control never wraps: it gets its own row under
|
|
1801
|
+
the label, three equal segments, whatever the popover's width. */
|
|
1802
|
+
.statuspop .sp-theme { flex-wrap: wrap; row-gap: 6px; }
|
|
1803
|
+
.statuspop .sp-theme .sp-k { width: 100%; }
|
|
1804
|
+
.statuspop .sp-theme .theme { flex: 1 1 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); flex-wrap: nowrap; }
|
|
1805
|
+
.statuspop .sp-theme .theme button { justify-content: center; white-space: nowrap; min-width: 0; height: 30px; padding: 0 6px; }
|
|
1806
|
+
@media (pointer: coarse) { .statuspop .sp-theme .theme button { height: 36px; font-size: 13px; } }
|
package/ui/app.js
CHANGED
|
@@ -370,11 +370,12 @@ function goButton(s) {
|
|
|
370
370
|
// Another machine's session (v0.5.2): Go runs there, through its Leftoff, when it allows it.
|
|
371
371
|
if (!s.isLocal) {
|
|
372
372
|
if (!j.peer) return ''
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (
|
|
373
|
+
// 0.9.2: "Go ↗" like a local card (a phone cut "Go on <machine>" short); the machine is in its title and label
|
|
374
|
+
const label = 'Go ↗', on = 'Go on ' + s.machine
|
|
375
|
+
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(on + ': Go only works from a Mac’s own browser') + '">' + esc(label) + '</button>'
|
|
376
|
+
if (!j.supported) return '<span class="go-off"><button type="button" class="btn go" disabled aria-label="' + esc(on) + '" title="' + esc(j.reason || '') + '">' + esc(label) + '</button><span class="why">' + esc(peerWhy(j)) + '</span></span>'
|
|
376
377
|
const st = goState.get(s.id)
|
|
377
|
-
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
|
|
378
|
+
return '<button type="button" class="btn go' + (st && st.cls ? ' ' + st.cls : '') + '" data-action="go" data-id="' + esc(s.id) + '"' + (st && st.disabled ? ' disabled' : '') + ' data-idle="' + esc(label) + '" aria-label="' + esc(on) + '" title="' + esc('Bring this session’s tab to the front on ' + s.machine) + '">' + esc(st ? st.label : label) + '</button>'
|
|
378
379
|
}
|
|
379
380
|
if (state.viewerLocal === false) return '<button type="button" class="btn go" disabled title="Go switches tabs on the machine running Leftoff, so it only works from that machine’s own browser (localhost)">Go ↗</button>'
|
|
380
381
|
if (!j.supported) return '<button type="button" class="btn go" disabled title="' + esc('Can’t jump: ' + (j.reason || 'unsupported terminal')) + '">Go ↗</button>'
|
|
@@ -419,35 +420,105 @@ function copyResumeIcon(s) {
|
|
|
419
420
|
return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
|
|
420
421
|
}
|
|
421
422
|
// items: [{ action, label, icon, title, attrs }]
|
|
422
|
-
|
|
423
|
-
|
|
423
|
+
// 0.9.2: an item may also be { note } (a plain line, no button) or { row: [items], label }
|
|
424
|
+
// (small buttons side by side, e.g. the other checkpoint lengths), and carry a sub line.
|
|
425
|
+
function moreMenu(s, items, { project = true, title = '' } = {}) {
|
|
426
|
+
items = items.concat([project ? projectMenuItem(s) : null]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
|
|
424
427
|
if (!items.length) return ''
|
|
425
|
-
|
|
426
|
-
'
|
|
427
|
-
|
|
428
|
-
|
|
428
|
+
const btn = (it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
|
|
429
|
+
Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + '>' +
|
|
430
|
+
(it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>'
|
|
431
|
+
return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '"' + (title ? ' data-menu-title="' + esc(title) + '"' : '') + ' aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
|
|
432
|
+
'<template class="more-items">' + items.map((it) => it.note ? '<div class="more-note">' + (it.icon ? icon(it.icon) : '') + esc(it.note) + '</div>'
|
|
433
|
+
: it.row ? '<div class="more-row" data-label="' + esc(it.label || '') + '">' + it.row.map(btn).join('') + '</div>' : btn(it)).join('') + '</template>'
|
|
429
434
|
}
|
|
430
435
|
function openCardMenu(btn) {
|
|
431
436
|
const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
|
|
432
437
|
if (!tpl) return
|
|
433
438
|
const card = btn.closest('.card')
|
|
434
|
-
const title = card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : ''
|
|
435
|
-
const
|
|
439
|
+
const title = btn.dataset.menuTitle || (card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : '')
|
|
440
|
+
const toItem = (b) => {
|
|
436
441
|
const svg = b.querySelector('svg')
|
|
437
442
|
const attrs = {}
|
|
438
|
-
for (const a of b.attributes) if (a.name.startsWith('data-') || a.name === 'title') attrs[a.name] = a.value
|
|
439
|
-
return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs }
|
|
440
|
-
}
|
|
443
|
+
for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub') || a.name === 'title') attrs[a.name] = a.value
|
|
444
|
+
return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}) }
|
|
445
|
+
}
|
|
446
|
+
const items = [...tpl.content.children].map((el) => {
|
|
447
|
+
if (el.classList.contains('more-note')) { const svg = el.querySelector('svg'); return { note: el.textContent.trim(), icon: svg ? svg.outerHTML : '' } }
|
|
448
|
+
if (el.classList.contains('more-row')) return { row: [...el.querySelectorAll('button')].map(toItem), label: el.dataset.label || '' }
|
|
449
|
+
return el.tagName === 'BUTTON' ? toItem(el) : null
|
|
450
|
+
}).filter(Boolean)
|
|
441
451
|
lastMenuAnchor = btn
|
|
442
452
|
openActionMenu(btn, items, { title })
|
|
443
453
|
}
|
|
444
|
-
|
|
445
|
-
|
|
454
|
+
// 0.9.2: what this board can do with a session: 'local'; 'peer', through the
|
|
455
|
+
// Mac it lives on (the same path as answering and Go); 'away', when that Mac
|
|
456
|
+
// can't be reached from here. Agent records are read-only (null).
|
|
457
|
+
function reachOf(s) {
|
|
458
|
+
if (!s || s.agent || /^agent:/.test(String(s.id || ''))) return null
|
|
459
|
+
if (s.isLocal) return 'local'
|
|
460
|
+
const p = s.peer || {}
|
|
461
|
+
return p.ok || p.code === 'checking' ? 'peer' : 'away'
|
|
462
|
+
}
|
|
463
|
+
// The length "Checkpoint now" writes: yours from Settings → Summaries.
|
|
464
|
+
function defaultLength() { const k = state.cfg && state.cfg.summary && state.cfg.summary.length; return LENGTH_CHOICES.some((o) => o.key === k) ? k : 'informative' }
|
|
465
|
+
const lengthLabel = (k) => (LENGTH_CHOICES.find((o) => o.key === k) || LENGTH_CHOICES[1]).label
|
|
466
|
+
// Checkpoint now at your length, then the other two lengths side by side.
|
|
467
|
+
function checkpointItems() {
|
|
468
|
+
const def = defaultLength()
|
|
469
|
+
return [
|
|
470
|
+
{ action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus', sub: lengthLabel(def) + ' · your default', attrs: { 'data-length': def } },
|
|
471
|
+
{ row: LENGTH_CHOICES.filter((o) => o.key !== def).map((o) => ({ action: 'checkpoint', label: o.label, title: o.label + ' checkpoint: ' + o.blurb, attrs: { 'data-length': o.key } })), label: 'Or' },
|
|
472
|
+
]
|
|
473
|
+
}
|
|
474
|
+
// The ⋯ menu when the Mac a session lives on can't be reached: why, in plain
|
|
475
|
+
// words, and what you can do (switch the board to that Mac, or check again).
|
|
476
|
+
function awayNote(s) {
|
|
477
|
+
const m = s.machine || 'another Mac', c = s.peer && s.peer.code
|
|
478
|
+
return c === 'not_allowed' ? 'Lives on ' + m + ', which doesn’t take actions from other Macs (Settings → Privacy & security on ' + m + ')'
|
|
479
|
+
: c === 'key_changed' ? 'Lives on ' + m + ', whose key changed; confirm it in Settings → Privacy & security'
|
|
480
|
+
: c === 'no_key' ? 'Lives on ' + m + ', which needs a newer Leftoff for this'
|
|
481
|
+
: c === 'off' ? 'Lives on ' + m + '; actions on other Macs are off here'
|
|
482
|
+
: asleepOnRelay(m) ? 'Lives on ' + m + ', which is asleep right now'
|
|
483
|
+
: 'Lives on ' + m + ', which isn’t reachable right now'
|
|
484
|
+
}
|
|
485
|
+
function asleepOnRelay(m) { const st = window.leftoffRelay && state.relayStatus; const x = st && (st.macs || []).find((y) => y.machine === m); return !!(x && !x.online) }
|
|
486
|
+
function awayMenuItems(s) {
|
|
487
|
+
const m = s.machine
|
|
488
|
+
const canSwitch = !!window.leftoffRelay && m && m !== window.leftoffRelay.mac && !asleepOnRelay(m)
|
|
489
|
+
return [
|
|
490
|
+
{ note: awayNote(s), icon: 'wifi-off' },
|
|
491
|
+
canSwitch ? { action: 'relay-mac', label: 'Switch to ' + m + '’s board', icon: 'monitor', attrs: { 'data-mac': m } } : null,
|
|
492
|
+
{ action: 'peer-recheck', label: 'Check again', icon: 'refresh-cw', attrs: { 'data-machine': m || '' } },
|
|
493
|
+
].filter(Boolean)
|
|
494
|
+
}
|
|
495
|
+
const sessionMenuItems = (s) => { const r = reachOf(s); return r === 'local' || r === 'peer' ? [
|
|
496
|
+
...checkpointItems(),
|
|
446
497
|
{ action: 'pin', label: s.pinned ? 'Unpin' : 'Pin', icon: s.pinned ? 'pin-off' : 'pin' },
|
|
447
498
|
{ action: 'title-auto', label: 'Regenerate title', icon: 'refresh-cw', title: 'A new title from the latest messages (like /cp-name --auto)' },
|
|
448
499
|
{ action: 'archive', label: 'Archive', icon: 'archive' },
|
|
449
|
-
] : []
|
|
450
|
-
|
|
500
|
+
] : r === 'away' ? awayMenuItems(s) : [] }
|
|
501
|
+
// 0.9.2: "Checkpoint now" on a session's page: the main part writes your
|
|
502
|
+
// length; the chevron opens Short / Informative / Long (our menu, a sheet on a phone).
|
|
503
|
+
function checkpointSplit(s) {
|
|
504
|
+
const def = defaultLength()
|
|
505
|
+
return '<span class="split" role="group" aria-label="Checkpoint now">' +
|
|
506
|
+
'<button type="button" class="btn primary split-main" data-action="checkpoint" data-id="' + esc(s.id) + '" data-length="' + esc(def) + '" title="' + esc('Checkpoint now · ' + lengthLabel(def) + (s.isLocal ? '' : ' · written on ' + s.machine)) + '">Checkpoint now</button>' +
|
|
507
|
+
'<button type="button" class="btn primary split-more" data-action="cp-lengths" data-id="' + esc(s.id) + '" aria-label="Checkpoint length" title="Choose the length" aria-haspopup="menu" aria-expanded="false">' + icon('chevron-down') + '</button></span>'
|
|
508
|
+
}
|
|
509
|
+
function openLengthMenu(btn, id) {
|
|
510
|
+
const def = defaultLength()
|
|
511
|
+
lastMenuAnchor = btn
|
|
512
|
+
openActionMenu(btn, LENGTH_CHOICES.map((o) => ({ label: o.label + (o.key === def ? ' · your default' : ''), sub: o.blurb, icon: o.key === def ? icon('check') : icon('bookmark-plus'), cls: o.key === def ? 'on' : '',
|
|
513
|
+
attrs: { 'data-action': 'checkpoint', 'data-id': id, 'data-length': o.key } })), { title: 'Checkpoint now' })
|
|
514
|
+
}
|
|
515
|
+
const roNote = (t, tip) => '<span class="ro" title="' + esc(tip || t) + '">' + esc(t) + '</span>'
|
|
516
|
+
// 0.9.2: a short line on another Mac's card (the full story is its title and the ⋯ menu)
|
|
517
|
+
function remoteNote(s) {
|
|
518
|
+
if (reachOf(s) === 'peer') return roNote('via ' + s.machine, 'Lives on ' + s.machine + ' · checkpoints, answering and Go work through it')
|
|
519
|
+
if (/class="go-off"/.test(goButton(s))) return '' // the grey Go already says why
|
|
520
|
+
return roNote(s.machine + ' unreachable', awayNote(s) + ' · read-only here')
|
|
521
|
+
}
|
|
451
522
|
|
|
452
523
|
function card(s) {
|
|
453
524
|
if (s.agent) return agentCard(s)
|
|
@@ -465,7 +536,7 @@ function card(s) {
|
|
|
465
536
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
466
537
|
'<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
|
|
467
538
|
copyResumeIcon(s) +
|
|
468
|
-
(s.isLocal ?
|
|
539
|
+
(s.isLocal ? '' : remoteNote(s)) + moreMenu(s, sessionMenuItems(s)) +
|
|
469
540
|
'</div></article>'
|
|
470
541
|
}
|
|
471
542
|
|
|
@@ -1102,11 +1173,11 @@ async function viewSession(id) {
|
|
|
1102
1173
|
const [spv] = await Promise.all([api('GET', '/v1/spaces').catch(() => null), loadProjects()])
|
|
1103
1174
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
1104
1175
|
// 0.8.9: rename in place: tap the title or the pencil (this Mac's sessions; like /cp-name, your name wins over automatic ones)
|
|
1105
|
-
const canRename = s
|
|
1176
|
+
const canRename = reachOf(s) === 'local' || reachOf(s) === 'peer' // 0.9.2: another Mac's session renames through it
|
|
1106
1177
|
html += '<div class="pagehead"><div>' + (canRename
|
|
1107
1178
|
? '<div class="title s-title" data-id="' + esc(s.id) + '"><h1><span class="s-title-text" role="button" tabindex="0" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" title="Rename">' + esc(s.title || 'Untitled session') + '</span></h1><button type="button" class="icon rename-pen" data-action="rename" data-id="' + esc(s.id) + '" data-title="' + esc(s.title || '') + '" aria-label="Rename" title="Rename">' + icon('pencil') + '</button></div>'
|
|
1108
1179
|
: '<h1>' + esc(s.title || 'Untitled session') + '</h1>') + '<div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + projChip(s) + wtChips(s) + (canProject(s) ? '<button type="button" class="chip proj-add" data-action="project-menu" data-key="' + esc(s.repoKey) + '" data-repo="' + esc(s.repoLabel || s.repoName || '') + '" title="' + esc(s.repoProject ? 'Move ' + (s.repoLabel || 'this repo') + ' to another project, or out of it' : 'Add ' + (s.repoLabel || 'this repo') + ' to a project') + '">' + (s.repoProject ? 'Change project' : '+ Project') + '</button>' : '') + ' ' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
|
|
1109
|
-
'<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s
|
|
1180
|
+
'<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (reachOf(s) === 'local' || reachOf(s) === 'peer' ? checkpointSplit(s) : reachOf(s) === 'away' ? '<span class="more-wrap">' + moreMenu(s, awayMenuItems(s), { project: false, title: s.title || 'This session' }) + '</span>' : '') + '</div></div>'
|
|
1110
1181
|
html += '<div class="facts">' +
|
|
1111
1182
|
fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (synced)'))) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.repoProject ? fact('Project', projectLabel(s.repoProject)) : '') + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
1112
1183
|
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
|
|
@@ -1135,8 +1206,10 @@ async function viewSession(id) {
|
|
|
1135
1206
|
if (!s.checkpoints.length) html += '<div class="empty">No checkpoints yet.</div>'
|
|
1136
1207
|
else html += '<div class="timeline">' + cpTimeline(s.checkpoints.filter((cp) => !cp.doubt), newestCp) + '</div>'
|
|
1137
1208
|
// 0.9.0: checkpoints written while Leftoff had another session's branch for this one: kept, folded away
|
|
1138
|
-
const doubted = s.checkpoints.filter((cp) => cp.doubt)
|
|
1209
|
+
const doubted = s.checkpoints.filter((cp) => cp.doubt && cp.doubt !== 'empty')
|
|
1139
1210
|
if (doubted.length) html += '<details class="tl-doubt"><summary>' + doubted.length + ' checkpoint' + (doubted.length === 1 ? '' : 's') + ' set aside: written with another session’s branch</summary><div class="timeline">' + cpTimeline(doubted, null) + '</div></details>'
|
|
1211
|
+
const empties = s.checkpoints.filter((cp) => cp.doubt === 'empty') // 0.9.2
|
|
1212
|
+
if (empties.length) html += '<details class="tl-doubt"><summary>' + empties.length + ' empty checkpoint' + (empties.length === 1 ? '' : 's') + ' set aside: no messages were found when ' + (empties.length === 1 ? 'it was' : 'they were') + ' written</summary><div class="timeline">' + cpTimeline(empties, null) + '</div></details>'
|
|
1140
1213
|
html += answeredList(s)
|
|
1141
1214
|
$app.innerHTML = html
|
|
1142
1215
|
}
|
|
@@ -2960,19 +3033,27 @@ function paintStatus() {
|
|
|
2960
3033
|
const pref = window.cpTheme ? window.cpTheme.pref() : 'system'
|
|
2961
3034
|
const row = (k, v, cls) => '<div class="sp-row"><span class="sp-k">' + esc(k) + '</span><span class="sp-v' + (cls ? ' ' + cls : '') + '">' + v + '</span></div>'
|
|
2962
3035
|
const syncWarn = !!settingsDots().sync
|
|
2963
|
-
pop.innerHTML = '<div class="sp-head"><span class="dot ' + (h ? 'up' : 'down') + '"></span><b>' + esc(h ? h.machine : 'Leftoff') + '</b><span class="faint">' + (h ? (
|
|
3036
|
+
pop.innerHTML = '<div class="sp-head"><span class="dot ' + (h ? 'up' : 'down') + '"></span><b>' + esc(h ? h.machine : 'Leftoff') + '</b><span class="faint sp-route">' + (h ? routeLabel() : 'offline') + '</span></div>' +
|
|
2964
3037
|
(h ? row('Sessions', esc(h.sessionsOpen + ' open') + ' <span class="faint">of ' + esc(h.sessionsTotal) + '</span>') +
|
|
2965
3038
|
(h.needsYou ? row('Needs you', '<a href="#/waiting">' + esc(h.needsYou) + ' waiting</a>') : '') +
|
|
2966
3039
|
row('Summaries', esc(h.summarizer + (h.summarizerDetail && h.summarizerDetail.model ? ' · ' + h.summarizerDetail.model : '')) + ' <a href="#/settings/models">Change</a>') +
|
|
2967
3040
|
relayRows(row) +
|
|
2968
3041
|
((h.machines || []).length > 1 && !window.leftoffRelay ? row('Macs', esc(h.machines.join(', '))) : '') +
|
|
2969
3042
|
row('Sync', '<a href="#/settings/sync">' + esc(syncLine()) + '</a>', syncWarn ? 'warnish' : '') +
|
|
2970
|
-
row('Version', esc('v' + h.version) + (h.update && h.update.available ? ' · <a href="#/settings/updates">v' + esc(h.update.remoteVersion || '?') + ' available</a>' : '
|
|
3043
|
+
row('Version', esc('v' + h.version) + (h.update && h.update.available ? ' · <a href="#/settings/updates">v' + esc(h.update.remoteVersion || '?') + ' available</a>' : '')) // 0.9.2: What’s new is the button below
|
|
2971
3044
|
: '<p class="faint">The daemon isn’t answering.</p>') +
|
|
2972
3045
|
'<div class="sp-row sp-theme"><span class="sp-k">Theme</span><div class="theme" role="group" aria-label="Theme">' + ['light', 'dark', 'system'].map((t) => '<button type="button" data-theme-set="' + t + '" aria-pressed="' + (pref === t) + '">' + THEME_ICONS[t] + (t === 'system' ? 'System' : t[0].toUpperCase() + t.slice(1)) + '</button>').join('') + '</div></div>' +
|
|
2973
3046
|
'<div class="sp-links"><a href="#/settings">' + icon('settings') + 'Settings</a><a href="#/whats-new">' + icon('sparkles') + 'What’s new</a></div>'
|
|
2974
3047
|
placeStatus()
|
|
2975
3048
|
}
|
|
3049
|
+
// 0.9.3: the quiet marker for how this board reaches its Mac. On your hosted
|
|
3050
|
+
// board: "direct · this Mac" (its own daemon over 127.0.0.1) or "via relay".
|
|
3051
|
+
function routeLabel() {
|
|
3052
|
+
const lr = window.leftoffRelay
|
|
3053
|
+
if (!lr) return 'this Mac'
|
|
3054
|
+
return lr.route === 'direct' ? 'direct · this Mac' : 'via relay'
|
|
3055
|
+
}
|
|
3056
|
+
window.addEventListener('leftoff-route', () => { if (statusOpen()) paintStatus() })
|
|
2976
3057
|
// Through the relay (v0.6.0): which Mac this board comes from, and a switcher.
|
|
2977
3058
|
function relayRows(row) {
|
|
2978
3059
|
const lr = window.leftoffRelay
|
|
@@ -3055,7 +3136,11 @@ function paletteActions() {
|
|
|
3055
3136
|
const sid = state.route && state.route.name === 's' ? state.route.arg : null
|
|
3056
3137
|
const s = sid ? (state.sessions || []).find((x) => x.id === sid) : null
|
|
3057
3138
|
if (s) {
|
|
3058
|
-
if (s
|
|
3139
|
+
if (reachOf(s) === 'local' || reachOf(s) === 'peer') { // 0.9.2: any length, and through the Mac it lives on
|
|
3140
|
+
const def = defaultLength()
|
|
3141
|
+
out.push({ action: 'checkpoint', id: s.id, length: def, title: 'Checkpoint now', sub: (s.title || 'this session') + ' · ' + lengthLabel(def), icon: 'bookmark-plus' })
|
|
3142
|
+
for (const o of LENGTH_CHOICES) if (o.key !== def) out.push({ action: 'checkpoint', id: s.id, length: o.key, title: 'Checkpoint now (' + o.label.toLowerCase() + ')', sub: o.blurb, icon: 'bookmark-plus' })
|
|
3143
|
+
}
|
|
3059
3144
|
if (!s.agent) out.push({ action: 'resume', id: s.id, title: 'Copy resume command', sub: s.title || 'this session', icon: 'copy' })
|
|
3060
3145
|
if (canProject(s)) out.push({ action: 'project-menu', key: s.repoKey, repo: s.repoLabel || '', title: s.repoProject ? 'Change project…' : 'Add to project…', sub: s.repoLabel || '', icon: 'folder' })
|
|
3061
3146
|
}
|
|
@@ -3097,6 +3182,7 @@ function runPaletteItem(it) {
|
|
|
3097
3182
|
if (it.id) b.dataset.id = it.id
|
|
3098
3183
|
if (it.key) b.dataset.key = it.key
|
|
3099
3184
|
if (it.repo) b.dataset.repo = it.repo
|
|
3185
|
+
if (it.length) b.dataset.length = it.length
|
|
3100
3186
|
b.className = 'palette-proxy'
|
|
3101
3187
|
const anchor = document.getElementById('searchBtn')
|
|
3102
3188
|
if (anchor) { lastMenuAnchor = anchor }
|
|
@@ -3542,10 +3628,27 @@ document.addEventListener('click', async (ev) => {
|
|
|
3542
3628
|
const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
|
|
3543
3629
|
if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
|
|
3544
3630
|
} else if (action === 'checkpoint') {
|
|
3631
|
+
// 0.9.2: any length (data-length), and another Mac's session too: its Leftoff writes it
|
|
3632
|
+
const length = btn.dataset.length || null
|
|
3633
|
+
const away = s && !s.isLocal ? s.machine : null
|
|
3634
|
+
const was = btn.innerHTML
|
|
3545
3635
|
btn.disabled = true; btn.textContent = 'Writing…'
|
|
3546
|
-
if (!btn.isConnected || btn.classList.contains('palette-proxy')) toast('Writing a checkpoint…',
|
|
3547
|
-
|
|
3548
|
-
|
|
3636
|
+
if (!btn.isConnected || btn.classList.contains('palette-proxy') || !btn.closest('.split')) toast('Writing ' + (length ? lengthLabel(length).toLowerCase() + ' ' : 'a ') + 'checkpoint' + (away ? ' on ' + away : '') + '…', 40000) // from a menu (closed) or ⌘K
|
|
3637
|
+
let cp
|
|
3638
|
+
try { cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true, ...(length ? { length } : {}) }) }
|
|
3639
|
+
catch (e) { toast(e.message, 6000); if (btn.isConnected) { btn.disabled = false; btn.innerHTML = was } return }
|
|
3640
|
+
toast('Checkpoint saved' + (cp.peer ? ' on ' + cp.peer : '') + ' · Next: ' + cp.next)
|
|
3641
|
+
render()
|
|
3642
|
+
} else if (action === 'cp-lengths') {
|
|
3643
|
+
ev.preventDefault(); return openLengthMenu(btn, id)
|
|
3644
|
+
} else if (action === 'peer-recheck') {
|
|
3645
|
+
const m = btn.dataset.machine
|
|
3646
|
+
toast('Checking ' + (m || 'the other Mac') + '…', 8000)
|
|
3647
|
+
try {
|
|
3648
|
+
const o = await api('POST', '/v1/peers/check', {})
|
|
3649
|
+
const p = (o.peers || []).find((x) => x.name === m || x.machine === m)
|
|
3650
|
+
toast(p && p.usable && p.usable.ok ? m + ' is reachable again' : 'Still can’t reach ' + (m || 'it') + (p && p.usable && p.usable.reason ? ': ' + p.usable.reason : ''), 6000)
|
|
3651
|
+
} catch (e) { toast(e.message, 5000) }
|
|
3549
3652
|
render()
|
|
3550
3653
|
} else if (action === 'pin') {
|
|
3551
3654
|
await api('PATCH', '/v1/sessions/' + encodeURIComponent(id), { pinned: !(s && s.pinned) }); render()
|
package/ui/direct.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// 0.9.3: "direct when on the same Mac". The hosted board (<user>.leftoff.dev)
|
|
2
|
+
// open on a Mac that runs Leftoff asks that Mac's own daemon over
|
|
3
|
+
// http://127.0.0.1:7429 instead of through the relay; the address bar stays.
|
|
4
|
+
//
|
|
5
|
+
// The request is the very envelope the relay would carry (ui/relay-crypto.js),
|
|
6
|
+
// posted to /v1/relay/direct (src/direct.js) instead of /_relay/api. So going
|
|
7
|
+
// direct needs no new trust: the daemon opens it with this browser's paired
|
|
8
|
+
// key, and the board only believes an answer that opens with the same key.
|
|
9
|
+
// Before it switches, the board checks: the daemon says it is on this account
|
|
10
|
+
// (user) and a Mac you didn't steer away from (picked), and a sealed round
|
|
11
|
+
// trip through it works (verify). A stranger's Leftoff on the port fails all
|
|
12
|
+
// of it, and the browser refuses it anyway (CORS is for this Mac's own name).
|
|
13
|
+
//
|
|
14
|
+
// When anything about the direct path fails (the daemon stopped, Safari
|
|
15
|
+
// refusing http://127.0.0.1 from an https page, a Local Network Access
|
|
16
|
+
// "Block"), the call goes again through the relay once, quietly, and the board
|
|
17
|
+
// stays on the relay until a later probe (every minute, or when the tab comes
|
|
18
|
+
// back) finds the direct path again. Plain logic, no DOM: unit tested.
|
|
19
|
+
import { LOCAL_PORT, isPhoneUa } from './viewer.js'
|
|
20
|
+
|
|
21
|
+
export const DIRECT_PATH = '/v1/relay/direct'
|
|
22
|
+
export const PROBE_EVERY_MS = 60000
|
|
23
|
+
export const FOCUS_GAP_MS = 10000
|
|
24
|
+
export function directUrl(port = LOCAL_PORT) { return 'http://127.0.0.1:' + port + DIRECT_PATH }
|
|
25
|
+
|
|
26
|
+
export class DirectError extends Error { constructor(code, message) { super(message || code); this.code = code } }
|
|
27
|
+
|
|
28
|
+
// What the local daemon said -> its machine when it may serve this board, else null.
|
|
29
|
+
export function directMatch(info, { user, picked } = {}) {
|
|
30
|
+
if (!info || info.app !== 'leftoff' || !info.direct || typeof info.machine !== 'string' || !info.machine) return null
|
|
31
|
+
if (!user || String(info.user || '').toLowerCase() !== String(user).toLowerCase()) return null
|
|
32
|
+
if (picked && picked !== info.machine) return null
|
|
33
|
+
return info.machine
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A sealed request's body may only be read once; a fallback needs it again.
|
|
37
|
+
export function aborted(signal) { return !!(signal && signal.aborted) }
|
|
38
|
+
|
|
39
|
+
export class DirectRoute {
|
|
40
|
+
constructor({ user, port = LOCAL_PORT, fetchImpl, now = () => Date.now(), ua = '', maxTouchPoints = 0, every = PROBE_EVERY_MS, focusGap = FOCUS_GAP_MS } = {}) {
|
|
41
|
+
this.user = user; this.port = port; this.fetchImpl = fetchImpl; this.now = now
|
|
42
|
+
// a phone or tablet never runs Leftoff: don't ask (and don't trip a permission prompt)
|
|
43
|
+
this.never = isPhoneUa(ua, { maxTouchPoints })
|
|
44
|
+
this.every = every; this.focusGap = focusGap
|
|
45
|
+
this.on = false; this.machine = null; this.checkedAt = 0; this.inflight = null; this.lastError = null; this.latencyMs = null
|
|
46
|
+
}
|
|
47
|
+
get url() { return directUrl(this.port) }
|
|
48
|
+
|
|
49
|
+
// -> the machine (direct confirmed, not yet switched on) or null. Never throws.
|
|
50
|
+
probe({ picked = null, verify = null, timeoutMs = 1500 } = {}) {
|
|
51
|
+
if (this.never) return Promise.resolve(null)
|
|
52
|
+
if (this.inflight) return this.inflight
|
|
53
|
+
this.inflight = (async () => {
|
|
54
|
+
this.checkedAt = this.now()
|
|
55
|
+
const t0 = this.now()
|
|
56
|
+
let info
|
|
57
|
+
try {
|
|
58
|
+
const r = await this.fetchImpl(this.url, { mode: 'cors', credentials: 'omit', cache: 'no-store', signal: timeoutSignal(timeoutMs) })
|
|
59
|
+
if (r.status !== 200) return null
|
|
60
|
+
info = await r.json()
|
|
61
|
+
} catch { return null }
|
|
62
|
+
const m = directMatch(info, { user: this.user, picked })
|
|
63
|
+
if (!m) return null
|
|
64
|
+
if (verify) { let ok = false; try { ok = await verify(m) } catch { ok = false } if (!ok) return null }
|
|
65
|
+
this.latencyMs = Math.max(0, Math.round(this.now() - t0))
|
|
66
|
+
return m
|
|
67
|
+
})().finally(() => { this.inflight = null })
|
|
68
|
+
return this.inflight
|
|
69
|
+
}
|
|
70
|
+
use(machine) { this.on = true; this.machine = machine; this.lastError = null }
|
|
71
|
+
drop(err) { this.on = false; this.lastError = err ? (err.code || err.message || String(err)) : null; this.checkedAt = this.now() }
|
|
72
|
+
// time for another look? (focus: sooner, but not on every click into the window)
|
|
73
|
+
due({ focus = false } = {}) {
|
|
74
|
+
if (this.never || this.on || this.inflight) return false
|
|
75
|
+
return this.now() - this.checkedAt >= (focus ? this.focusGap : this.every)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// POST the sealed envelope; -> the sealed response envelope. Throws DirectError.
|
|
79
|
+
async send(env, { signal } = {}) {
|
|
80
|
+
let r
|
|
81
|
+
try {
|
|
82
|
+
r = await this.fetchImpl(this.url, { method: 'POST', mode: 'cors', credentials: 'omit', cache: 'no-store', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ env }), signal })
|
|
83
|
+
} catch (e) { if (aborted(signal)) throw e; throw new DirectError('unreachable', e && e.message) }
|
|
84
|
+
if (r.status !== 200) throw new DirectError('http_' + r.status)
|
|
85
|
+
let j
|
|
86
|
+
try { j = await r.json() } catch { throw new DirectError('bad_response') }
|
|
87
|
+
if (!j || !j.env) throw new DirectError('bad_response')
|
|
88
|
+
return j.env
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// One call: direct when it's on, else (or when direct fails) the relay, once.
|
|
93
|
+
// A call the page itself aborted is not retried.
|
|
94
|
+
export async function directOrRelay({ route, direct, relay, signal, onDrop = () => {} }) {
|
|
95
|
+
if (route && route.on) {
|
|
96
|
+
try { return await direct() } catch (e) {
|
|
97
|
+
if (aborted(signal)) throw e
|
|
98
|
+
route.drop(e)
|
|
99
|
+
try { onDrop(e) } catch {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return relay()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A sealed response -> a fetch Response (204/304 carry no body).
|
|
106
|
+
export function toResponse(out) {
|
|
107
|
+
const nullBody = out.s === 204 || out.s === 205 || out.s === 304
|
|
108
|
+
return new Response(nullBody ? null : out.b, { status: out.s, headers: out.h })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function timeoutSignal(ms) {
|
|
112
|
+
if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) return AbortSignal.timeout(ms)
|
|
113
|
+
const c = new AbortController(); setTimeout(() => c.abort(), ms); return c.signal
|
|
114
|
+
}
|
package/ui/icons.js
CHANGED
|
@@ -31,6 +31,9 @@ export const ICONS = {
|
|
|
31
31
|
"shield": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/>",
|
|
32
32
|
"info": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"M12 16v-4\"/> <path d=\"M12 8h.01\"/>",
|
|
33
33
|
"chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
|
|
34
|
+
"chevron-down": "<path d=\"m6 9 6 6 6-6\"/>",
|
|
35
|
+
"wifi-off": "<path d=\"M12 20h.01\"/> <path d=\"M8.5 16.429a5 5 0 0 1 7 0\"/> <path d=\"M5 12.859a10 10 0 0 1 5.17-2.69\"/> <path d=\"M19 12.859a10 10 0 0 0-2.007-1.523\"/> <path d=\"M2 8.82a15 15 0 0 1 4.177-2.643\"/> <path d=\"M22 8.82a15 15 0 0 0-11.288-3.764\"/> <path d=\"m2 2 20 20\"/>",
|
|
36
|
+
"monitor": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\"/> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\"/> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\"/>",
|
|
34
37
|
"chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
|
|
35
38
|
"mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
|
|
36
39
|
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>",
|
package/ui/picker.js
CHANGED
|
@@ -324,8 +324,17 @@ export function openActionMenu(anchor, items, { title = '' } = {}) {
|
|
|
324
324
|
menu.id = id
|
|
325
325
|
menu.setAttribute('role', 'menu')
|
|
326
326
|
if (title) menu.setAttribute('aria-label', title)
|
|
327
|
+
// 0.9.2: an item may carry a sub line ({ sub }), be a plain line ({ note }:
|
|
328
|
+
// what the menu is about, read out as its description) or a row of small
|
|
329
|
+
// buttons ({ row: [items], label }), e.g. the other checkpoint lengths.
|
|
330
|
+
const one = (it, extra = '') => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + extra + (it.sub ? ' has-sub' : '') + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') +
|
|
331
|
+
(it.sub ? '<span class="lo-act-txt"><span>' + esc(it.label) + '</span><span class="lo-act-sub">' + esc(it.sub) + '</span></span>' : '<span>' + esc(it.label) + '</span>') + '</button>'
|
|
332
|
+
const notes = []
|
|
327
333
|
menu.innerHTML = (sheet && title ? '<div class="lo-sheet-head"><span>' + esc(title) + '</span><button type="button" class="lo-sheet-done" data-act-close>Done</button></div>' : '') +
|
|
328
|
-
items.map((it) => '
|
|
334
|
+
items.map((it, i) => it.note ? (notes.push(id + '-n' + i), '<div class="lo-act-note" id="' + id + '-n' + i + '" role="none">' + (it.icon || '') + '<span>' + esc(it.note) + '</span></div>')
|
|
335
|
+
: it.row ? '<div class="lo-act-row" role="group"' + (it.label ? ' aria-label="' + esc(it.label) + '"' : '') + '>' + (it.label ? '<span class="lo-act-rowlabel" aria-hidden="true">' + esc(it.label) + '</span>' : '') + it.row.map((x) => one(x, ' alt')).join('') + '</div>'
|
|
336
|
+
: one(it)).join('')
|
|
337
|
+
if (notes.length) menu.setAttribute('aria-describedby', notes.join(' '))
|
|
329
338
|
let backdrop = null
|
|
330
339
|
if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
|
|
331
340
|
document.body.appendChild(menu)
|