@leftoff/cli 0.6.2 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.6.2",
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"
@@ -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.2' // keep in step with .claude-plugin/plugin.json
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.2",
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; }
@@ -1262,3 +1283,73 @@ body.pairpage { background: var(--bg); color: var(--text); margin: 0; }
1262
1283
  .pmsg.ok { color: var(--green); }
1263
1284
  .statuspop .sp-theme .theme, .statuspop .macswitch { min-width: 0; flex: 0 1 auto; flex-wrap: wrap; }
1264
1285
  @media (max-width: 600px) { .statuspop .sp-k { width: 64px; } .statuspop .theme button { padding: 0 7px; } }
1286
+
1287
+ /* 0.6.3: iOS Safari zooms the page when a focused field's text is under 16px.
1288
+ On touch and small screens every text field is at least 16px, with padding
1289
+ trimmed where the bigger text would make a row taller. Fields that are
1290
+ already bigger (the sign-in code, search, pairing) keep their size. Page
1291
+ zoom itself stays on (no maximum-scale / user-scalable in the viewport).
1292
+ :where() keeps the catch-all at zero specificity so the bigger ones below win.
1293
+ test/unit/ui-ios-zoom.test.mjs guards this block. */
1294
+ @media (hover: none), (max-width: 700px) {
1295
+ :where(input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]):not([type=file]):not([type=hidden]),
1296
+ textarea, select, [contenteditable]:not([contenteditable=false])) { font-size: 16px !important; }
1297
+ .empty.login .codeform input { font-size: 22px !important; }
1298
+ .cmdk-field input, .pairbox input { font-size: 17px !important; }
1299
+ .addform input { padding: 5px 10px; }
1300
+ .deny-form input, .need .qother, .reply-text { padding: 6px 9px; }
1301
+ .card .title input { padding: 2px 6px; }
1302
+ .standup-out textarea { line-height: 1.4; }
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) => FILTERS[k].statuses.includes(s.status)).length
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({