@leftoff/cli 0.6.3 → 0.6.5
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 +20 -0
- package/README.md +2 -1
- package/bin/leftoff.js +324 -308
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +73 -0
- package/ui/app.js +121 -6
- package/ui/needs.js +237 -5
- package/ui/repos.js +9 -2
- package/ui/settings-nav.js +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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/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.6.
|
|
30
|
+
const MOD_VERSION = '0.6.5' // 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.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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
|
@@ -510,6 +510,23 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
510
510
|
}
|
|
511
511
|
.tabs a { white-space: nowrap; }
|
|
512
512
|
.recent .hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
|
513
|
+
/* Dismiss a need (0.6.5): a 44px target in the card's top-right corner. */
|
|
514
|
+
.need { position: relative; }
|
|
515
|
+
.need:has(.need-dismiss) > .need-head { padding-right: 34px; }
|
|
516
|
+
.need-dismiss { position: absolute; top: 2px; right: 2px; width: 44px; height: 44px; display: grid; place-items: center; border: 0; background: transparent; color: var(--muted); font-size: 15px; line-height: 1; border-radius: 10px; cursor: pointer; padding: 0; }
|
|
517
|
+
.need-dismiss:hover { color: var(--text); background: var(--subtle); }
|
|
518
|
+
.need-dismiss:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; }
|
|
519
|
+
.toast.has-action { pointer-events: auto; display: flex; align-items: center; gap: 14px; }
|
|
520
|
+
.toast-undo { border: 0; background: transparent; color: var(--accent); font: inherit; font-weight: 700; padding: 10px 6px; margin: -10px -6px; min-height: 44px; cursor: pointer; }
|
|
521
|
+
.dismissed { margin-top: 14px; }
|
|
522
|
+
.dismissed summary { cursor: pointer; font-size: 13px; color: var(--muted); padding: 10px 0; }
|
|
523
|
+
.dismissed ul { list-style: none; padding: 0; margin: 4px 0 8px; display: grid; gap: 6px; }
|
|
524
|
+
.dismissed li { display: flex; gap: 10px; align-items: center; font-size: 13px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); }
|
|
525
|
+
.dismissed .dl-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
|
526
|
+
.dismissed .dl-title { font-weight: 600; }
|
|
527
|
+
.dismissed .dl-line { color: var(--text2); overflow-wrap: anywhere; }
|
|
528
|
+
.dismissed .dl-at { color: var(--muted); font-size: 12px; }
|
|
529
|
+
.dismissed li .btn { min-height: 44px; }
|
|
513
530
|
|
|
514
531
|
/* ---------------------------------------------------------------- v0.4.6 */
|
|
515
532
|
/* Safe areas (viewport-fit=cover): notches and the home indicator, in the
|
|
@@ -750,6 +767,10 @@ html { background: var(--bg); }
|
|
|
750
767
|
.chip.warn { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 45%, transparent); background: var(--tint-yellow); }
|
|
751
768
|
.chip .ico, .okbox .ico { width: 13px; height: 13px; }
|
|
752
769
|
.joinline code.cmd { font-family: var(--mono); font-size: 12px; background: var(--bg2); border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; overflow-wrap: anywhere; min-width: 0; }
|
|
770
|
+
.ghbox { display: contents; }
|
|
771
|
+
.ghcell { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
|
772
|
+
.ghrow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; }
|
|
773
|
+
.ghrow .ghwhat { color: var(--text2); min-width: 0; flex: 1 1 200px; }
|
|
753
774
|
.btn.danger { color: var(--red); border-color: color-mix(in srgb, var(--red) 50%, transparent); }
|
|
754
775
|
.wizard { border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: var(--subtle); display: flex; flex-direction: column; gap: 12px; min-width: 0; width: 100%; }
|
|
755
776
|
.form .full .wizard { justify-content: flex-start; }
|
|
@@ -1280,3 +1301,55 @@ body.pairpage { background: var(--bg); color: var(--text); margin: 0; }
|
|
|
1280
1301
|
.card .title input { padding: 2px 6px; }
|
|
1281
1302
|
.standup-out textarea { line-height: 1.4; }
|
|
1282
1303
|
}
|
|
1304
|
+
|
|
1305
|
+
/* Needs you: context (0.6.4). What Claude said just before it asked, the
|
|
1306
|
+
branch and how long ago, "Show more" with the last turns, the branch's open
|
|
1307
|
+
PR and tap-to-send quick replies. Long code scrolls sideways, never the page. */
|
|
1308
|
+
.need-ctx { display: grid; gap: 6px; min-width: 0; }
|
|
1309
|
+
.ctx-lines { display: grid; gap: 3px; color: var(--text2); font-size: 13.5px; line-height: 1.45; border-left: 3px solid var(--line-strong); padding: 1px 0 1px 10px; min-width: 0; }
|
|
1310
|
+
.ctx-lines p { margin: 0; overflow-wrap: anywhere; }
|
|
1311
|
+
.ctx-lines code, .turn-text code { font-size: 12px; background: var(--field); border: 1px solid var(--line); border-radius: 5px; padding: 0 4px; }
|
|
1312
|
+
.ctx-lines a, .turn-text a { color: var(--accent); }
|
|
1313
|
+
.ctx-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 2px 4px; color: var(--muted); font-size: 12px; min-width: 0; }
|
|
1314
|
+
.ctx-meta .mono { font-size: 12px; overflow-wrap: anywhere; }
|
|
1315
|
+
.linkbtn { background: none; border: 0; padding: 6px 2px; margin-left: auto; font: inherit; font-size: 12.5px; font-weight: 600; color: var(--accent); cursor: pointer; min-height: 32px; }
|
|
1316
|
+
.linkbtn:hover { text-decoration: underline; }
|
|
1317
|
+
.linkbtn:focus-visible { outline: 2px solid var(--focus); border-radius: 4px; }
|
|
1318
|
+
.need-turns { border-top: 1px solid var(--line); padding-top: 8px; min-width: 0; max-height: 70vh; overflow-y: auto; overscroll-behavior: contain; }
|
|
1319
|
+
.turns { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; min-width: 0; }
|
|
1320
|
+
.turn { display: grid; gap: 4px; min-width: 0; }
|
|
1321
|
+
.turn-who { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
|
|
1322
|
+
.turn.you .turn-who { color: var(--accent); }
|
|
1323
|
+
.turn.you .turn-text { background: var(--subtle); border-radius: 8px; padding: 6px 9px; }
|
|
1324
|
+
.turn-text { font-size: 13.5px; line-height: 1.45; color: var(--text); min-width: 0; overflow-wrap: anywhere; }
|
|
1325
|
+
.turn-text p { margin: 0 0 6px; }
|
|
1326
|
+
.turn-text p:last-child { margin-bottom: 0; }
|
|
1327
|
+
.turn-asked { font-size: 12px; color: var(--muted); font-style: italic; }
|
|
1328
|
+
.turn.asked-only { gap: 0; }
|
|
1329
|
+
.turn-text .th { font-weight: 700; }
|
|
1330
|
+
.turn-text ul, .turn-text ol { margin: 0 0 6px; padding-left: 20px; }
|
|
1331
|
+
.turn-text pre.tcode { margin: 0 0 6px; max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; background: var(--field); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-family: var(--mono); font-size: 12px; line-height: 1.45; white-space: pre; overflow-wrap: normal; }
|
|
1332
|
+
.turn-text pre.tcode code { background: none; border: 0; padding: 0; font-size: inherit; }
|
|
1333
|
+
.tchips { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
|
|
1334
|
+
.tchip { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; color: var(--text2); background: var(--card2); border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px; }
|
|
1335
|
+
.tchip.err { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); }
|
|
1336
|
+
.need-pr { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; align-items: center; text-decoration: none; color: var(--text); background: var(--card2); border: 1px solid var(--line); border-radius: 10px; padding: 7px 10px; min-width: 0; }
|
|
1337
|
+
a.need-pr:hover { border-color: var(--line-strong); }
|
|
1338
|
+
.need-pr .pr-top { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; font-size: 12px; min-width: 0; }
|
|
1339
|
+
.need-pr .pr-num { font-weight: 700; font-family: var(--mono); }
|
|
1340
|
+
.need-pr .pr-state { font-weight: 600; color: var(--green); }
|
|
1341
|
+
.need-pr .pr-state.draft { color: var(--muted); }
|
|
1342
|
+
.need-pr .pr-checks { color: var(--muted); }
|
|
1343
|
+
.need-pr .pr-checks.pass { color: var(--green); }
|
|
1344
|
+
.need-pr .pr-checks.fail { color: var(--red); }
|
|
1345
|
+
.need-pr .pr-checks.pending { color: var(--yellow); }
|
|
1346
|
+
.need-pr .pr-title { grid-column: 1; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
|
|
1347
|
+
.need-pr .pr-go { grid-column: 2; grid-row: 1 / span 2; color: var(--muted); }
|
|
1348
|
+
.quick-replies { display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; }
|
|
1349
|
+
.quick-replies .qr { font: inherit; font-size: 13px; color: var(--text); background: var(--card2); border: 1px solid var(--line-strong); border-radius: 999px; padding: 6px 12px; min-height: 34px; cursor: pointer; max-width: 100%; text-align: left; }
|
|
1350
|
+
.quick-replies .qr:hover:not(:disabled) { border-color: var(--accent); }
|
|
1351
|
+
.quick-replies .qr:focus-visible { outline: 2px solid var(--focus); }
|
|
1352
|
+
.quick-replies .qr.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--card2)); }
|
|
1353
|
+
.quick-replies .qr:disabled { opacity: .55; cursor: default; }
|
|
1354
|
+
@media (hover: none), (max-width: 700px) { .quick-replies .qr { min-height: 40px; font-size: 14px; } .linkbtn { min-height: 40px; } }
|
|
1355
|
+
.need-ctx.open .ctx-lines { display: none; }
|
package/ui/app.js
CHANGED
|
@@ -4,12 +4,12 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
5
|
import { selectHtml, comboHtml, closeMenu } from './picker.js'
|
|
6
6
|
import { SETTINGS, settingsKeyFor } from './settings-nav.js'
|
|
7
|
-
import { repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches } from './repos.js'
|
|
8
|
-
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer } from './needs.js'
|
|
7
|
+
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches } from './repos.js'
|
|
8
|
+
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
9
9
|
import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
|
|
10
10
|
import { mdInline, renderSection } from './changelog-md.js'
|
|
11
11
|
const $app = document.getElementById('app')
|
|
12
|
-
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true }
|
|
12
|
+
const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
|
|
13
13
|
const multi = () => !!(state.health && state.health.machines && state.health.machines.length > 1)
|
|
14
14
|
const agentNames = () => [...new Set(((state.health && state.health.agents) || []).map((a) => a.display || a.name))]
|
|
15
15
|
|
|
@@ -74,10 +74,25 @@ function clock(iso) { return iso ? new Date(iso).toLocaleString(undefined, { mon
|
|
|
74
74
|
function toast(msg, ms = 2600) {
|
|
75
75
|
const t = document.getElementById('toast')
|
|
76
76
|
t.textContent = msg
|
|
77
|
+
t.classList.remove('has-action')
|
|
77
78
|
t.classList.add('show')
|
|
78
79
|
clearTimeout(toast.t)
|
|
79
80
|
toast.t = setTimeout(() => t.classList.remove('show'), ms)
|
|
80
81
|
}
|
|
82
|
+
// A toast with an Undo button (0.6.5, dismissing a need): about 5 s.
|
|
83
|
+
function toastUndo(msg, onUndo, ms = 5000) {
|
|
84
|
+
const t = document.getElementById('toast')
|
|
85
|
+
t.innerHTML = ''
|
|
86
|
+
const span = document.createElement('span')
|
|
87
|
+
span.textContent = msg
|
|
88
|
+
const b = document.createElement('button')
|
|
89
|
+
b.type = 'button'; b.className = 'toast-undo'; b.textContent = 'Undo'
|
|
90
|
+
b.onclick = () => { t.classList.remove('show', 'has-action'); clearTimeout(toast.t); onUndo() }
|
|
91
|
+
t.append(span, b)
|
|
92
|
+
t.classList.add('show', 'has-action')
|
|
93
|
+
clearTimeout(toast.t)
|
|
94
|
+
toast.t = setTimeout(() => t.classList.remove('show', 'has-action'), ms)
|
|
95
|
+
}
|
|
81
96
|
async function copy(text) {
|
|
82
97
|
try { await navigator.clipboard.writeText(text); toast('Copied: ' + text) } catch { prompt('Copy this:', text) }
|
|
83
98
|
}
|
|
@@ -376,7 +391,7 @@ async function viewBoard() {
|
|
|
376
391
|
const allShown = shown
|
|
377
392
|
const inSpace = sp ? shown.filter((s) => s.space === sp) : shown
|
|
378
393
|
const inRepos = applyFilter(inSpace, { repos: f.repos })
|
|
379
|
-
const count = (k) => inRepos.filter((s) =>
|
|
394
|
+
const count = (k) => inRepos.filter((s) => inStatus(s, k)).length
|
|
380
395
|
const list = applyFilter(inSpace, f)
|
|
381
396
|
let html = '<div class="pagehead"><div><h1>Sessions</h1><div class="sub">Every Claude Code session on ' + (multi() ? 'your machines' : 'this machine') + (agentNames().length ? ', plus ' + esc(agentNames().join(' and ')) : '') + ', grouped by what needs you.</div></div>' +
|
|
382
397
|
'<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
|
|
@@ -484,7 +499,13 @@ async function viewWaiting() {
|
|
|
484
499
|
(r.where ? '<span class="where">' + esc(r.where) + '</span>' : '') + '</li>').join('') + '</ul>'
|
|
485
500
|
: '<div class="nocp">Nothing yet since the daemon started.</div>') +
|
|
486
501
|
'<div class="hint">Board answers are also written to each session’s answers.md. This list is kept since the daemon last started.</div></details>'
|
|
502
|
+
html += dismissedHtml(needs.dismissed || [], { open: !!state.dismissedOpen, agoFn: ago })
|
|
487
503
|
$app.innerHTML = banners() + html
|
|
504
|
+
// Open/closed survives live repaints (0.6.5).
|
|
505
|
+
const dz = $app.querySelector('details.dismissed')
|
|
506
|
+
if (dz) dz.addEventListener('toggle', () => { state.dismissedOpen = dz.open })
|
|
507
|
+
const rc = $app.querySelector('details.recent')
|
|
508
|
+
if (rc) rc.addEventListener('toggle', () => { state.recentOpen = rc.open })
|
|
488
509
|
}
|
|
489
510
|
|
|
490
511
|
// The nav's count, the page title and the favicon dot: visible from a background tab.
|
|
@@ -507,7 +528,7 @@ function paintNeedsCount(n) {
|
|
|
507
528
|
|
|
508
529
|
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
|
|
509
530
|
function needOpts(extra = {}) {
|
|
510
|
-
return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', ...extra }
|
|
531
|
+
return { mayAnswer: state.mayAnswer, drafts: state.drafts, pending: state.pendingAsk, expanded: state.needOpen, turns: state.needTurns, goButton: (s) => goButton(s), agoFn: ago, kbd: IS_MAC ? '⌘↵' : 'Ctrl↵', ...extra }
|
|
511
532
|
}
|
|
512
533
|
|
|
513
534
|
// ⌘↵ (Ctrl+↵ off a Mac) in a Needs-you text box (the Deny note, a question's
|
|
@@ -1424,9 +1445,26 @@ function showSettingsSection(r) {
|
|
|
1424
1445
|
if (!wideSettings() || wrap.getBoundingClientRect().top < 0) window.scrollTo(0, 0)
|
|
1425
1446
|
}
|
|
1426
1447
|
|
|
1448
|
+
// Settings → General → PR cards (0.6.5): is GitHub's gh installed and signed
|
|
1449
|
+
// in on this Mac, and the one-line fix to copy. Nothing installs from here.
|
|
1450
|
+
const GH_LABEL = { missing: 'Missing', 'signed-out': 'Signed out', ready: 'Ready' }
|
|
1451
|
+
function ghSection(g) {
|
|
1452
|
+
let h = '<label>PR cards</label>'
|
|
1453
|
+
if (!g || !g.status) return h + '<div class="hint">Couldn’t check for gh on this Mac.</div>'
|
|
1454
|
+
const ok = g.status === 'ready'
|
|
1455
|
+
h += '<div class="ghcell"><div class="ghrow"><span class="chip ' + (ok ? 'ok' : 'warn') + '">' + esc(GH_LABEL[g.status] || g.status) + '</span>' +
|
|
1456
|
+
'<span class="ghwhat">' + (ok ? 'gh is signed in' + (g.account ? ' as <b>' + esc(g.account) + '</b>' : '') + '. Cards that need you show the branch’s pull request.' : g.status === 'signed-out' ? 'gh is installed but not signed in to GitHub.' : 'GitHub’s gh isn’t installed on this Mac.') + '</span>' +
|
|
1457
|
+
'<button type="button" class="linkish" data-action="gh-check">Check again</button></div>'
|
|
1458
|
+
if (g.fix && g.fix.cmd) h += '<div class="joinline ghfix"><code class="cmd">' + esc(g.fix.cmd) + '</code><button type="button" class="btn sm" data-action="copy-cmd" data-cmd="' + esc(g.fix.cmd) + '">Copy</button></div>'
|
|
1459
|
+
h += '</div>'
|
|
1460
|
+
if (!ok) h += '<div class="hint">' + (g.fix && g.fix.url ? 'Optional. Install gh from <a href="' + esc(g.fix.url) + '" target="_blank" rel="noopener">cli.github.com</a>, then run this in Terminal.' : 'Optional. Run this in Terminal on this Mac.') + ' Without gh, cards just skip the pull request.</div>'
|
|
1461
|
+
return h
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1427
1464
|
async function viewSettings() {
|
|
1428
|
-
const [c, , remote, sync, peers, st, , , relay] = await Promise.all([api('GET', '/v1/config'), spacesLoad(), api('GET', '/v1/remote').catch(() => null), api('GET', '/v1/sync').catch(() => null), api('GET', '/v1/peers').catch(() => null), api('GET', '/v1/syncthing').catch(() => null), refreshHealth().catch(() => null), agentsLoad(), api('GET', '/v1/relay').catch(() => null)])
|
|
1465
|
+
const [c, , remote, sync, peers, st, , , relay, gh] = await Promise.all([api('GET', '/v1/config'), spacesLoad(), api('GET', '/v1/remote').catch(() => null), api('GET', '/v1/sync').catch(() => null), api('GET', '/v1/peers').catch(() => null), api('GET', '/v1/syncthing').catch(() => null), refreshHealth().catch(() => null), agentsLoad(), api('GET', '/v1/relay').catch(() => null), api('GET', '/v1/gh').catch(() => null)])
|
|
1429
1466
|
state.relay = relay
|
|
1467
|
+
state.gh = gh
|
|
1430
1468
|
syncUi.data = sync; syncUi.peers = peers; if (st) stUi.data = st
|
|
1431
1469
|
state.cfg = c
|
|
1432
1470
|
const sm = c.summarizer || {}
|
|
@@ -1443,6 +1481,7 @@ async function viewSettings() {
|
|
|
1443
1481
|
'<div class="section">Needs you</div>' +
|
|
1444
1482
|
'<label for="f-notify">Notify me</label><label class="check"><input id="f-notify" type="checkbox"' + (c.notify && c.notify.needsYou === false ? '' : ' checked') + '><span>Show a macOS notification when a session has waited on a permission prompt or question for ' + esc((c.notify && c.notify.delaySec != null) ? c.notify.delaySec : 10) + ' seconds</span></label>' +
|
|
1445
1483
|
'<div class="hint">Answered sooner (in the terminal or here), no notification. With <code>terminal-notifier</code> installed (<code>brew install terminal-notifier</code>), clicking the notification jumps to the session.</div>' +
|
|
1484
|
+
'<div id="ghBox" class="ghbox">' + ghSection(gh) + '</div>' +
|
|
1446
1485
|
'<div class="section">Appearance</div>' +
|
|
1447
1486
|
'<label>Theme</label><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>',
|
|
1448
1487
|
digest: digestSettings(c),
|
|
@@ -2452,6 +2491,13 @@ document.addEventListener('click', async (ev) => {
|
|
|
2452
2491
|
const s = state.sessions.find((x) => x.id === id)
|
|
2453
2492
|
try {
|
|
2454
2493
|
if (action === 'copy-cmd') { await copy(btn.dataset.cmd); return }
|
|
2494
|
+
if (action === 'gh-check') {
|
|
2495
|
+
btn.disabled = true
|
|
2496
|
+
try { state.gh = await api('GET', '/v1/gh?fresh=1') } catch { state.gh = null }
|
|
2497
|
+
const box = document.getElementById('ghBox')
|
|
2498
|
+
if (box) box.innerHTML = ghSection(state.gh)
|
|
2499
|
+
return
|
|
2500
|
+
}
|
|
2455
2501
|
// Use from anywhere (v0.6.0)
|
|
2456
2502
|
if (action === 'relay-toggle') {
|
|
2457
2503
|
const on = btn.checked
|
|
@@ -2460,6 +2506,7 @@ document.addEventListener('click', async (ev) => {
|
|
|
2460
2506
|
try { await api('POST', on ? '/v1/relay/enable' : '/v1/relay/disable', on ? { user } : undefined); toast(on ? 'Use from anywhere is on: connecting to ' + user + '.leftoff.dev' : 'Use from anywhere is off for this Mac') }
|
|
2461
2507
|
catch (e) { btn.checked = !on; toast(e.message, 5000); return }
|
|
2462
2508
|
setTimeout(() => { if (state.route && state.route.name === 'settings') render() }, 1200)
|
|
2509
|
+
refreshHealth().catch(() => {})
|
|
2463
2510
|
return render()
|
|
2464
2511
|
}
|
|
2465
2512
|
if (action === 'relay-user') {
|
|
@@ -2684,6 +2731,15 @@ document.addEventListener('click', async (ev) => {
|
|
|
2684
2731
|
}
|
|
2685
2732
|
if (action === 'ask-answer' || action === 'ask-pick-send') return answerClick(btn, action)
|
|
2686
2733
|
if (action === 'reply-send') return replyClick(btn)
|
|
2734
|
+
if (action === 'quick-reply') return quickReplyClick(btn)
|
|
2735
|
+
if (action === 'need-more') return needMoreClick(btn)
|
|
2736
|
+
if (action === 'need-dismiss') return needDismissClick(btn)
|
|
2737
|
+
if (action === 'need-restore' || action === 'need-restore-all') {
|
|
2738
|
+
btn.disabled = true
|
|
2739
|
+
state.dismissedOpen = true
|
|
2740
|
+
try { await api('POST', '/v1/needs/restore', action === 'need-restore-all' ? { all: true } : { key: btn.dataset.need }); toast(action === 'need-restore-all' ? 'Restored' : 'Restored to Needs you') } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
|
|
2741
|
+
return render()
|
|
2742
|
+
}
|
|
2687
2743
|
if (action === 'go') return goClick(btn, id, s)
|
|
2688
2744
|
if (action && action.startsWith('st-')) return stAction(action, btn)
|
|
2689
2745
|
if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
|
|
@@ -2784,6 +2840,65 @@ async function replyClick(btn) {
|
|
|
2784
2840
|
} catch (e) { toast(e.message, 4200) } finally { render() }
|
|
2785
2841
|
}
|
|
2786
2842
|
|
|
2843
|
+
// A quick reply (0.6.4): the chip's words go in the answer box and are sent
|
|
2844
|
+
// by its own button, exactly as if typed: same request, same checks.
|
|
2845
|
+
function quickReplyClick(btn) {
|
|
2846
|
+
const need = btn.closest('.need')
|
|
2847
|
+
if (!need) return
|
|
2848
|
+
const text = btn.dataset.text || ''
|
|
2849
|
+
const box = need.querySelector('.reply-text') || need.querySelector('.qother')
|
|
2850
|
+
const send = need.querySelector('[data-action="reply-send"]') || need.querySelector('[data-action="ask-answer"][data-choice="answer"]')
|
|
2851
|
+
if (!box || !send || send.disabled) return
|
|
2852
|
+
box.value = text
|
|
2853
|
+
if (box.dataset.draft) state.drafts[box.dataset.draft] = text
|
|
2854
|
+
need.querySelectorAll('.quick-replies .qr').forEach((b) => { b.disabled = true; b.classList.toggle('on', b === btn) })
|
|
2855
|
+
return send.dataset.action === 'reply-send' ? replyClick(send) : answerClick(send, 'ask-answer')
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// "Show more" on a Needs-you card (0.6.4): the session's last ~20 turns,
|
|
2859
|
+
// fetched when opened (through the relay when that's how the board is open).
|
|
2860
|
+
async function needMoreClick(btn) {
|
|
2861
|
+
const id = btn.dataset.id
|
|
2862
|
+
const panels = () => document.querySelectorAll('.need[data-id="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"] .need-ctx')
|
|
2863
|
+
const paint = () => panels().forEach((ctx) => {
|
|
2864
|
+
const open = !!state.needOpen[id]
|
|
2865
|
+
const b = ctx.querySelector('.need-more')
|
|
2866
|
+
if (b) { b.textContent = open ? 'Show less' : 'Show more'; b.setAttribute('aria-expanded', String(open)) }
|
|
2867
|
+
let box = ctx.querySelector('.need-turns')
|
|
2868
|
+
const t = state.needTurns[id]
|
|
2869
|
+
// Open, the turns replace the three lines (the last turn ends with them).
|
|
2870
|
+
ctx.classList.toggle('open', open && !!t && !t.error)
|
|
2871
|
+
if (!open) { if (box) box.remove(); return }
|
|
2872
|
+
if (!box) { box = document.createElement('div'); box.className = 'need-turns'; ctx.appendChild(box) }
|
|
2873
|
+
box.innerHTML = !t ? '<div class="need-note">Loading the last turns…</div>' : t.error ? '<div class="need-note warn">' + esc(t.error) + '</div>' : turnsHtml(t.turns, { need: needsItems(state.sessions).find((n) => n.session && n.session.id === id) })
|
|
2874
|
+
})
|
|
2875
|
+
if (state.needOpen[id]) { delete state.needOpen[id]; return paint() }
|
|
2876
|
+
state.needOpen[id] = true
|
|
2877
|
+
if (!state.needTurns[id] || state.needTurns[id].error) delete state.needTurns[id]
|
|
2878
|
+
paint()
|
|
2879
|
+
try {
|
|
2880
|
+
const r = await api('GET', '/v1/sessions/' + encodeURIComponent(id) + '/turns?limit=20')
|
|
2881
|
+
state.needTurns[id] = { turns: r.turns || [], at: Date.now() }
|
|
2882
|
+
} catch (e) { state.needTurns[id] = { error: 'Couldn’t load this session’s turns: ' + e.message } }
|
|
2883
|
+
paint()
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
// Dismiss a need (0.6.5): gone from every card and count at once, kept on
|
|
2887
|
+
// the daemon (so your other devices and the relay see it too), Undo for 5 s.
|
|
2888
|
+
async function needDismissClick(btn) {
|
|
2889
|
+
const key = btn.dataset.need
|
|
2890
|
+
if (!key) return
|
|
2891
|
+
const cards = [...document.querySelectorAll('.need[data-need="' + (window.CSS && CSS.escape ? CSS.escape(key) : key) + '"]')]
|
|
2892
|
+
cards.forEach((c) => { c.hidden = true })
|
|
2893
|
+
for (const s of state.sessions || []) if (s.needsYou && s.needsYou.key === key) s.needsYou = null
|
|
2894
|
+
try { await api('POST', '/v1/needs/dismiss', { key }) } catch (e) { cards.forEach((c) => { c.hidden = false }); toast('Couldn’t dismiss it: ' + e.message, 5000); return }
|
|
2895
|
+
toastUndo('Dismissed', async () => {
|
|
2896
|
+
try { await api('POST', '/v1/needs/restore', { key }) } catch (e) { toast(e.message, 5000) }
|
|
2897
|
+
render(); refreshHealth().catch(() => {})
|
|
2898
|
+
})
|
|
2899
|
+
render(); refreshHealth().catch(() => {})
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2787
2902
|
// Go: "Switching…" at once, a note if it's slow, the result (with timings when slow) after.
|
|
2788
2903
|
function goClick(btn, id, s) {
|
|
2789
2904
|
return goWithFeedback({
|