@leftoff/cli 0.6.3 → 0.6.8

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/ui/needs.js CHANGED
@@ -7,6 +7,8 @@
7
7
  // the terminal can answer: Go), reply (the turn ended on a question: a reply box
8
8
  // when the mod can type it in). Nothing is ever answered without a click.
9
9
 
10
+ import { mdInline } from './changelog-md.js'
11
+
10
12
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
11
13
  export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
12
14
 
@@ -35,6 +37,12 @@ export function needsLine(n) {
35
37
  return n.question || ''
36
38
  }
37
39
 
40
+ // Dismiss (0.6.5): hides this one need on every board; a new question shows again.
41
+ export function dismissButton(n) {
42
+ if (!n || !n.key) return ''
43
+ return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss" title="Dismiss: hide this one (a new question shows again)">✕</button>'
44
+ }
45
+
38
46
  const KIND_LABEL = { permission: 'Needs your OK', question: 'Question', terminal: 'Waiting in the terminal', reply: 'Asked you', agent: 'Waiting on you' }
39
47
 
40
48
  export function cardBadge(n) {
@@ -59,12 +67,13 @@ export function needPanel(n, opts = {}) {
59
67
  const dis = !may || busy ? ' disabled' : ''
60
68
  if (n.kind === 'agent') return agentNeedPanel(n, s)
61
69
  const go = opts.goButton ? opts.goButton(s) : ''
62
- let h = '<article class="need k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '">'
70
+ let h = '<article class="need k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '"' + (n.key ? ' data-need="' + esc(n.key) + '"' : '') + '>'
63
71
  h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
64
72
  (opts.compact ? '' : '<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' + (s.repoLabel ? '<span class="chip mono">' + esc(s.repoLabel) + '</span>' : '')) +
65
73
  (n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" title="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
66
74
  '<span class="need-kind">' + esc(KIND_LABEL[n.kind] || '') + '</span>' +
67
- (n.waitSec != null ? '<span class="need-wait" title="since ' + esc(n.since || '') + '">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + '</div>'
75
+ (n.waitSec != null ? '<span class="need-wait" title="since ' + esc(n.since || '') + '">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
76
+ h += needContextHtml(n, opts) + prCardHtml(n.pr)
68
77
  if (n.kind === 'permission' && n.ask) {
69
78
  const d = n.ask.describe || {}
70
79
  h += '<div class="need-what"><span class="verb">' + esc(d.verb || n.ask.tool) + '</span><code>' + esc(d.detail || '') + '</code></div>'
@@ -88,6 +97,7 @@ export function needPanel(n, opts = {}) {
88
97
  h += '<input type="text" class="qother" maxlength="500" placeholder="' + (q.options.length ? 'Or type your own answer' : 'Your answer') + '" value="' + esc(drafts['q:' + askId + ':' + qi] || '') + '" data-draft="q:' + esc(askId) + ':' + qi + '" data-submit="answer" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>'
89
98
  h += '</div>'
90
99
  })
100
+ h += quickRepliesHtml(n, dis)
91
101
  h += '<div class="need-actions">' + (oneTap ? '' : '') + '<button type="button" class="btn primary" data-action="ask-answer" data-choice="answer" data-submit-for="answer"' + dis + '>' + (oneTap ? 'Send typed answer' : 'Send answers') + kbdHint(opts) + '</button>' +
92
102
  '<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + ' title="Decline to answer; Claude carries on without it">Skip</button>' + go + '</div>'
93
103
  } else if (n.kind === 'terminal') {
@@ -98,7 +108,7 @@ export function needPanel(n, opts = {}) {
98
108
  if (n.replyPending) h += '<div class="need-note ok">Reply sent ' + esc(opts.agoFn ? opts.agoFn(n.replyPending.at) : '') + ': “' + esc(n.replyPending.text) + '”. Claude reads it as its next prompt.</div>'
99
109
  if (n.canReply) {
100
110
  h += '<div class="reply-row"><textarea class="reply-text" rows="2" maxlength="4000" placeholder="Reply to Claude…" data-draft="reply:' + esc(s.id) + '" data-submit="reply" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>' + esc(drafts['reply:' + s.id] || '') + '</textarea>' +
101
- '<button type="button" class="btn primary" data-action="reply-send" data-submit-for="reply"' + dis + '>Send' + kbdHint(opts) + '</button></div><div class="need-actions">' + go + '</div>'
111
+ '<button type="button" class="btn primary" data-action="reply-send" data-submit-for="reply"' + dis + '>Send' + kbdHint(opts) + '</button></div>' + quickRepliesHtml(n, dis) + '<div class="need-actions">' + go + '</div>'
102
112
  } else {
103
113
  h += '<div class="need-note">' + esc(n.why ? 'No reply box: ' + n.why + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
104
114
  }
@@ -165,16 +175,27 @@ export function agentNeedPanel(n, s = n.session || {}) {
165
175
  if (a.reply == null && n.reply) a.reply = n.reply
166
176
  if (a.replyHint == null && n.replyHint) a.replyHint = n.replyHint
167
177
  const btn = agentReplyButton(a)
168
- let h = '<article class="need k-agent" data-id="' + esc(s.id) + '" data-key="' + esc('agent:' + s.id) + '">' +
178
+ let h = '<article class="need k-agent" data-id="' + esc(s.id) + '" data-key="' + esc('agent:' + s.id) + '"' + (n.key ? ' data-need="' + esc(n.key) + '"' : '') + '>' +
169
179
  '<div class="need-head">' + agentChipHtml(a) +
170
180
  '<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled') + '</a>' +
171
- '<span class="need-kind">' + esc(KIND_LABEL.agent) + '</span>' + (n.waitSec != null ? '<span class="need-wait">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + '</div>' +
181
+ '<span class="need-kind">' + esc(KIND_LABEL.agent) + '</span>' + (n.waitSec != null ? '<span class="need-wait">' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>' +
172
182
  '<ul class="agent-needs">' + (n.items || []).map((x) => '<li class="plain">' + esc(x.text) + '</li>').join('') + '</ul>' +
173
183
  (btn ? '<div class="need-actions">' + btn + '</div>' : '') +
174
184
  '<div class="need-note">' + esc(agentReplyNote(a)) + '</div>'
175
185
  return h + '</article>'
176
186
  }
177
187
 
188
+ // "Dismissed (n)" at the bottom of the Needs tab (0.6.5): what you hid that is
189
+ // still waiting, each with Restore. list: [{ key, title, line, kind, at }]
190
+ export function dismissedHtml(list = [], { open = false, agoFn = null } = {}) {
191
+ if (!list.length) return ''
192
+ return '<details class="dismissed"' + (open ? ' open' : '') + '><summary>Dismissed (' + list.length + ')</summary><ul>' +
193
+ list.map((d) => '<li><div class="dl-main"><span class="dl-title">' + esc(d.title || 'Untitled') + '</span><span class="dl-line">' + esc(String(d.line || '').slice(0, 200)) + '</span>' +
194
+ (agoFn && d.at ? '<span class="dl-at">dismissed ' + esc(agoFn(d.at)) + '</span>' : '') + '</div>' +
195
+ '<button type="button" class="btn sm" data-action="need-restore" data-need="' + esc(d.key) + '">Restore</button></li>').join('') +
196
+ '</ul>' + (list.length > 1 ? '<button type="button" class="linkish" data-action="need-restore-all">Restore all</button>' : '') + '</details>'
197
+ }
198
+
178
199
  export function needsStrip(items, opts = {}) {
179
200
  if (!items.length) return ''
180
201
  const blocking = items.filter((i) => i.blocking).length
@@ -195,3 +216,214 @@ export function questionAnswer(questions = [], picks = {}, typed = {}) {
195
216
  })
196
217
  return { answers, allowOther: other }
197
218
  }
219
+
220
+ // ---------------------------------------------------------------- context (0.6.4)
221
+ // What Claude was doing when it asked: its last lines before the question,
222
+ // the branch and how long ago, "Show more" for the last ~20 turns (loaded
223
+ // from GET /v1/sessions/:id/turns, so it works through the relay too), the
224
+ // branch's open PR, and tap-to-send quick replies.
225
+
226
+ export function agoLabel(sec) {
227
+ if (sec == null || !isFinite(sec)) return ''
228
+ if (sec < 60) return 'just now'
229
+ if (sec < 3600) return Math.round(sec / 60) + 'm ago'
230
+ if (sec < 86400) return Math.round(sec / 3600) + 'h ago'
231
+ return Math.round(sec / 86400) + 'd ago'
232
+ }
233
+
234
+ // Claude's markdown, safely: everything is escaped first (mdInline), fenced
235
+ // code becomes a block that scrolls sideways, "- " / "1. " lines become lists.
236
+ export function mdBlocks(text) {
237
+ const out = []
238
+ let para = [], list = null, code = null
239
+ const flushPara = () => { if (para.length) { out.push('<p>' + para.map((l) => mdInline(l)).join('<br>') + '</p>'); para = [] } }
240
+ const flushList = () => { if (list) { out.push('<' + list.tag + '>' + list.items.map((t) => '<li>' + mdInline(t) + '</li>').join('') + '</' + list.tag + '>'); list = null } }
241
+ for (const raw of String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n')) {
242
+ if (code) {
243
+ if (/^\s*(```|~~~)\s*$/.test(raw)) { out.push('<pre class="tcode"><code>' + esc(code.join('\n')) + '</code></pre>'); code = null } else code.push(raw)
244
+ continue
245
+ }
246
+ if (/^\s*(```|~~~)/.test(raw)) { flushPara(); flushList(); code = []; continue }
247
+ const li = raw.match(/^\s*(?:([-*+•])|(\d{1,3})[.)])\s+(.*)$/)
248
+ if (li) {
249
+ flushPara()
250
+ const tag = li[1] ? 'ul' : 'ol'
251
+ if (list && list.tag !== tag) flushList()
252
+ if (!list) list = { tag, items: [] }
253
+ list.items.push(li[3])
254
+ continue
255
+ }
256
+ if (!raw.trim()) { flushPara(); flushList(); continue }
257
+ flushList()
258
+ const hd = raw.match(/^\s*#{1,6}\s+(.*)$/)
259
+ if (hd) { flushPara(); out.push('<p class="th">' + mdInline(hd[1]) + '</p>'); continue }
260
+ para.push(raw.trim())
261
+ }
262
+ if (code) out.push('<pre class="tcode"><code>' + esc(code.join('\n')) + '</code></pre>')
263
+ flushPara(); flushList()
264
+ return out.join('')
265
+ }
266
+
267
+ // The turns "Show more" lists. turns: [{ role, text, chips: [{ label, error, n }] }]
268
+ // The question the card already shows in its own block (0.6.5): the reply
269
+ // kind's trailing question, or AskUserQuestion's first question.
270
+ export function askedText(n) {
271
+ if (!n) return ''
272
+ if (n.kind === 'reply') return n.question || ''
273
+ if (n.kind === 'question' && n.ask) return ((n.ask.questions || [])[0] || {}).question || ''
274
+ return ''
275
+ }
276
+
277
+ // Loose form for matching: no markup, one space, lower case, no leading "…"
278
+ // (a long question is kept by its end).
279
+ const flatQ = (s) => String(s == null ? '' : s).replace(/[*_`#>]+/g, '').replace(/\s+/g, ' ').trim().toLowerCase().replace(/^…\s*/, '')
280
+
281
+ // The text with the question cut off its end, or null when it doesn't end with it.
282
+ function cutQuestion(text, question) {
283
+ const q = flatQ(question)
284
+ if (q.length < 4) return null
285
+ const t = String(text || '')
286
+ const map = [] // each char of the flat copy -> its index in t
287
+ let flat = ''
288
+ for (let i = 0; i < t.length; i++) {
289
+ const c = t[i]
290
+ if (/[*_`#>]/.test(c)) continue
291
+ if (/\s/.test(c)) { if (!flat || flat.endsWith(' ')) continue; flat += ' '; map.push(i); continue }
292
+ flat += c.toLowerCase(); map.push(i)
293
+ }
294
+ flat = flat.trimEnd()
295
+ if (!flat.endsWith(q.slice(-Math.min(q.length, 40)))) return null
296
+ let at = flat.lastIndexOf(q)
297
+ if (at < 0) at = flat.lastIndexOf(q.slice(0, 40))
298
+ if (at < 0 || at + q.length < flat.length - 2 || /^\s*…/.test(String(question))) {
299
+ // Only the end matched (the question was clipped): drop the last paragraph.
300
+ const paras = t.trimEnd().split(/\n\s*\n/)
301
+ paras.pop()
302
+ return tidyEnd(paras.join('\n\n'))
303
+ }
304
+ return tidyEnd(t.slice(0, map[at]))
305
+ }
306
+ // Markup the cut left open ("**" before a bold question, a "> "), not a code fence.
307
+ const tidyEnd = (s) => s.replace(/(?:\s|[*_#>]|(?<!`)`(?!`))+$/, '')
308
+
309
+ // "Show more" turns without the question the card shows below them (0.6.5):
310
+ // when the last assistant turn ends with that question (or is the
311
+ // AskUserQuestion call), it's cut from the turn and the turn carries a small
312
+ // "↓ asked below" marker instead. A turn left empty is just the marker.
313
+ export function withoutAsked(turns = [], n = null) {
314
+ const list = turns.slice()
315
+ const last = list[list.length - 1]
316
+ if (!n || !last || last.role !== 'assistant') return list
317
+ const t = { ...last, chips: (last.chips || []).slice() }
318
+ let asked = false
319
+ if (n.kind === 'question') {
320
+ const c = t.chips[t.chips.length - 1]
321
+ if (c && c.label === 'Asked you') {
322
+ if (c.n > 1) t.chips[t.chips.length - 1] = { ...c, n: c.n - 1 }
323
+ else t.chips.pop()
324
+ asked = true
325
+ }
326
+ }
327
+ const q = askedText(n)
328
+ if (q && t.text && !t.chips.length) {
329
+ const cut = cutQuestion(t.text, q)
330
+ if (cut != null) { t.text = cut; asked = true }
331
+ }
332
+ if (!asked) return list
333
+ t.asked = true
334
+ list[list.length - 1] = t
335
+ return list
336
+ }
337
+
338
+ // opts.need: the Needs item these turns belong to (its question isn't repeated).
339
+ export function turnsHtml(turns = [], opts = {}) {
340
+ if (!turns.length) return '<div class="need-note">Nothing in this session’s transcript yet.</div>'
341
+ return '<ol class="turns">' + withoutAsked(turns, opts.need).map((t) => {
342
+ const body = (t.text ? '<div class="turn-text">' + mdBlocks(t.text) + '</div>' : '') +
343
+ ((t.chips || []).length ? '<div class="tchips">' + t.chips.map((c) => '<span class="tchip' + (c.error ? ' err' : '') + '">' + esc(c.label) + (c.n > 1 ? ' <b>×' + c.n + '</b>' : '') + '</span>').join('') + '</div>' : '')
344
+ const mark = t.asked ? '<div class="turn-asked">↓ asked below</div>' : ''
345
+ if (t.asked && !body) return '<li class="turn claude asked-only">' + mark + '</li>'
346
+ return '<li class="turn ' + (t.role === 'user' ? 'you' : 'claude') + '"><div class="turn-who">' + (t.role === 'user' ? 'You' : 'Claude') + '</div>' + body + mark + '</li>'
347
+ }).join('') + '</ol>'
348
+ }
349
+
350
+ // opts.expanded: { [sessionId]: true }, opts.turns: { [sessionId]: { turns } | { error } }
351
+ export function needContextHtml(n, opts = {}) {
352
+ const c = n.context
353
+ const s = n.session || {}
354
+ if (!c || n.kind === 'agent') return ''
355
+ const lines = (c.lines || []).filter(Boolean)
356
+ const where = [opts.compact && s.repoLabel ? s.repoLabel : '', c.branch ? '⎇ ' + c.branch : ''].filter(Boolean)
357
+ const when = n.waitSec != null ? (n.kind === 'reply' ? 'asked ' : '') + agoLabel(n.waitSec) : ''
358
+ const canMore = !!c.transcript || !!n.peer
359
+ if (!lines.length && !where.length && !canMore) return ''
360
+ const open = !!(opts.expanded && opts.expanded[s.id])
361
+ const loaded = opts.turns && opts.turns[s.id]
362
+ let h = '<div class="need-ctx' + (open && loaded && !loaded.error ? ' open' : '') + '">'
363
+ if (lines.length) h += '<div class="ctx-lines">' + lines.map((l) => '<p>' + mdInline(String(l).replace(/^\s*(?:[-*+]|\d{1,3}[.)])\s+/, (m) => /\d/.test(m) ? m.trim() + ' ' : '• ')) + '</p>').join('') + '</div>'
364
+ h += '<div class="ctx-meta">' + (where.length ? '<span class="mono">' + esc(where.join(' · ')) + '</span>' : '') + (where.length && when ? '<span aria-hidden="true"> · </span>' : '') + (when ? '<span>' + esc(when) + '</span>' : '') +
365
+ (canMore ? '<button type="button" class="linkbtn need-more" data-action="need-more" data-id="' + esc(s.id) + '" aria-expanded="' + open + '">' + (open ? 'Show less' : 'Show more') + '</button>' : '') + '</div>'
366
+ if (open) h += '<div class="need-turns">' + (!loaded ? '<div class="need-note">Loading the last turns…</div>' : loaded.error ? '<div class="need-note warn">' + esc(loaded.error) + '</div>' : turnsHtml(loaded.turns, { need: n })) + '</div>'
367
+ return h + '</div>'
368
+ }
369
+
370
+ // The branch's open pull request (from gh on the machine running the session).
371
+ export function prCardHtml(pr) {
372
+ if (!pr || !pr.number) return ''
373
+ const url = httpsOnly(pr.url)
374
+ const ck = pr.checks || {}
375
+ const cls = ck.failed ? 'fail' : ck.pending ? 'pending' : ck.total ? 'pass' : 'none'
376
+ const icon = { fail: '✕', pending: '◌', pass: '✓', none: '' }[cls]
377
+ const inner = '<span class="pr-top"><span class="pr-num">#' + esc(pr.number) + '</span><span class="pr-state ' + esc(pr.state || 'open') + '">' + (pr.state === 'draft' ? 'Draft' : 'Open') + '</span>' +
378
+ (ck.summary ? '<span class="pr-checks ' + cls + '">' + (icon ? '<span aria-hidden="true">' + icon + '</span> ' : '') + esc(ck.summary) + '</span>' : '') + '</span>' +
379
+ '<span class="pr-title">' + esc(pr.title || '') + '</span>'
380
+ return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" title="Open the pull request on GitHub">' + inner + '<span class="pr-go" aria-hidden="true">↗</span></a>'
381
+ : '<div class="need-pr">' + inner + '</div>'
382
+ }
383
+
384
+ export const GENERIC_REPLIES = ['Yes, go ahead', 'No', 'Show me the diff first']
385
+ const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1)
386
+
387
+ // "Should I also add Google sign-in, or keep it email-only?" -> the two choices.
388
+ export function orChoices(question) {
389
+ const q = String(question || '').replace(/\s+/g, ' ').trim()
390
+ const last = (q.match(/[^.!?]*\?\s*$/) || [''])[0].trim()
391
+ const m = last.match(/^(?:so\s+|and\s+)?(?:should|shall|do you want me to|want me to|would you like me to|would you rather I|can|could)\s+(?:i|we)?\s*(?:also\s+|just\s+|first\s+)?(.+?),?\s+or\s+(?:should\s+(?:i|we)\s+|do you want me to\s+|would you rather\s+)?(.+?)\s*\?$/i)
392
+ if (!m) return []
393
+ const a = m[1].trim(), b = m[2].trim().replace(/^(?:i|we)\s+/i, '')
394
+ if (!a || !b || /^(not|no|something else|neither|anything else)\b/i.test(b) || a.length > 60 || b.length > 60) return []
395
+ return [cap(a), cap(b)]
396
+ }
397
+
398
+ // 3–4 tap-to-send replies: a question's own options when it has them
399
+ // (AskUserQuestion choices, numbered options, "X, or Y?"), else generic ones.
400
+ export function quickReplies(n) {
401
+ if (!n) return []
402
+ if (n.kind === 'question' && n.ask) {
403
+ const qs = n.ask.questions || []
404
+ if (qs.length !== 1) return []
405
+ const q = qs[0]
406
+ if (q.options && q.options.length) return q.multiSelect ? [] : q.options.map((o) => o.label).slice(0, 4)
407
+ return orChoices(q.question).length ? [...orChoices(q.question), GENERIC_REPLIES[2]] : GENERIC_REPLIES
408
+ }
409
+ if (n.kind !== 'reply') return []
410
+ const listed = (n.context && n.context.options) || []
411
+ if (listed.length >= 2) return listed.slice(0, 4)
412
+ const alt = orChoices(n.question)
413
+ if (alt.length) return [...alt, GENERIC_REPLIES[2]]
414
+ if (/\(\s*y\s*\/\s*n\s*\)|\byes or no\b/i.test(String(n.question || ''))) return ['Yes', 'No']
415
+ return GENERIC_REPLIES
416
+ }
417
+
418
+ // Under the answer box. A question whose options are already one-tap buttons
419
+ // gets none (they'd be the same buttons twice); nothing to type into, none.
420
+ export function quickRepliesHtml(n, dis = '') {
421
+ if (n.kind === 'reply' && !n.canReply) return ''
422
+ if (n.kind === 'question' && n.ask) {
423
+ const qs = n.ask.questions || []
424
+ if (qs.length !== 1 || (qs[0].options || []).length) return ''
425
+ }
426
+ const list = quickReplies(n)
427
+ if (!list.length) return ''
428
+ return '<div class="quick-replies" role="group" aria-label="Quick replies">' + list.map((t) => '<button type="button" class="qr" data-action="quick-reply" data-text="' + esc(t) + '"' + dis + '>' + esc(t) + '</button>').join('') + '</div>'
429
+ }
@@ -152,9 +152,13 @@ function needsKey() {
152
152
  screen('Pair this browser again', '<p>This browser has a pass for ' + esc(RELAY_USER) + '.leftoff.dev but not the key that unlocks your board (it lives only in this browser, and it’s gone: private browsing, cleared website data, or another browser).</p>' +
153
153
  '<p>On a Mac: Settings → Phone &amp; remote → <b>Pair a phone</b>, then scan the QR code.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
154
154
  }
155
+ let notPairedShown = false
155
156
  function notPaired(revoked) {
157
+ // once shown, a later 401 (the event stream, a poll) doesn't redraw it
158
+ if (notPairedShown) return
159
+ notPairedShown = true
156
160
  forgetDeviceKey()
157
- screen('This browser isn’t paired anymore', '<p>' + (revoked ? 'It was revoked on one of your Macs.' : 'It was revoked, or its pass expired.') + ' Pair it again from a Mac: Settings → Phone &amp; remote → <b>Pair a phone</b>.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
161
+ screen('This browser isn’t paired anymore', '<p>' + (revoked === 'self' ? 'You revoked it.' : revoked ? 'It was revoked on one of your devices.' : 'It was revoked, or its pass expired.') + ' Pair it again from a Mac: Settings → Phone &amp; remote → <b>Pair a phone</b>.</p><p><a class="btn primary" href="/pair.html">I have a code</a></p>')
158
162
  }
159
163
  let asleepShown = false
160
164
  function asleep() {
@@ -175,6 +179,8 @@ export const leftoffRelay = {
175
179
  status: () => refreshStatus(),
176
180
  // Switch Macs: the board reloads from the other one.
177
181
  pick(mac) { remember(mac); cookieMac(mac); location.reload() },
182
+ // This browser revoked itself (0.6.6).
183
+ unpaired() { notPaired('self') },
178
184
  }
179
185
 
180
186
  if (RELAY_USER) {
package/ui/repos.js CHANGED
@@ -95,6 +95,8 @@ export const STATUS_FILTERS = {
95
95
  waiting: ['waiting'],
96
96
  dormant: ['dormant', 'stale'],
97
97
  ended: ['ended'],
98
+ // 0.6.7: every ended session, not just today's (the Board's Closed view)
99
+ closed: ['ended'],
98
100
  }
99
101
 
100
102
  export const STORE_KEY = 'cp.repos'
@@ -151,8 +153,15 @@ export function toggleRepo(selected, chip, multi = false) {
151
153
 
152
154
  // Status AND repo. `repos` are tokens (labels); an empty list means all repos.
153
155
  export function applyFilter(sessions, { repos = [], status = null } = {}) {
154
- const statuses = status ? STATUS_FILTERS[status] : null
155
- return sessions.filter((s) => (!statuses || statuses.includes(s.status)) && (!repos.length || repos.some((r) => repoMatches(s, r))))
156
+ return sessions.filter((s) => (!status || inStatus(s, status)) && (!repos.length || repos.some((r) => repoMatches(s, r))))
157
+ }
158
+ // A session in a status filter. "Needs you" also counts anything with a Needs
159
+ // item (an agent feed asking you something, a turn that ended on a question),
160
+ // so the tile agrees with the strip and the tab's count (0.6.5).
161
+ export function inStatus(s, status) {
162
+ const statuses = STATUS_FILTERS[status]
163
+ if (!statuses) return true
164
+ return statuses.includes(s.status) || (status === 'needs' && !!s.needsYou && !s.archived)
156
165
  }
157
166
 
158
167
  // Which chips show in the row and which go behind "More ▾": the top `max` by
@@ -2,17 +2,17 @@
2
2
  // box also matches, and the old names a link may still use. Pure data, so the
3
3
  // unit tests read it too (test/unit/settings-nav.test.mjs).
4
4
  export const SETTINGS = [
5
- { key: 'general', label: 'General', icon: 'sliders-horizontal', sub: 'Checkpoints, notifications and theme.', words: 'dormant gap checkpoint notify notification needs you macos theme appearance light dark system', save: true },
5
+ { key: 'general', label: 'General', icon: 'sliders-horizontal', sub: 'Checkpoints, notifications and theme.', words: 'dormant gap checkpoint notify notification needs you macos theme appearance light dark system pr cards pull request github gh', save: true },
6
6
  { key: 'digest', label: 'Digest', icon: 'newspaper', sub: 'When the daily digest is written.', words: 'time days weekdays every day quiet skip empty time zone schedule morning automatic', save: true },
7
7
  { key: 'spaces', label: 'Spaces', icon: 'layers', sub: 'Work, Personal or your own names, per Mac and per repo.', words: 'work personal repo override mac default standup agent feed' },
8
8
  { key: 'sync', label: 'Sync', icon: 'folder-sync', sub: 'One folder, every Mac.', words: 'syncthing pair pairing join code icloud dropbox folder move backup other macs install accept' },
9
9
  { key: 'agents', label: 'Agents', icon: 'bot', sub: 'Work from AI assistants outside Claude Code.', words: 'tab feed agent key url host hosting add refresh remove' },
10
10
  { key: 'models', label: 'Models & summaries', icon: 'sparkles', sub: 'Who writes your checkpoints, and how long they are.', words: 'summarizer provider model ollama anthropic openai claude haiku sonnet opus api key server url length short long informative', save: true },
11
11
  { key: 'phone', label: 'Phone & remote', icon: 'smartphone', sub: 'Your board on your iPhone, from anywhere or over Tailscale.', words: 'use from anywhere relay leftoff.dev subdomain pair pairing device devices revoke tailscale remote access qr code sign in signed iphone home screen url app' },
12
- { key: 'security', label: 'Privacy & security', icon: 'shield', sub: 'Who can answer prompts here, and machine keys.', words: 'answer answering permission prompts other machines go keys fingerprint trust privacy data protected encryption', save: true },
12
+ { key: 'security', label: 'Privacy & security', icon: 'shield', sub: 'Trust my paired devices, and machine keys.', words: 'answer answering permission prompts trust trusted paired devices phone other machines go keys fingerprint privacy data protected encryption advanced', save: true },
13
13
  { key: 'updates', label: 'Updates & about', icon: 'info', sub: 'Version, updates and this Mac.', words: 'update upgrade version check about machine folder what’s new whats new changelog', save: true },
14
14
  ]
15
- export const SETTINGS_ALIAS = { anywhere: 'phone', relay: 'phone', remote: 'phone', tailscale: 'phone', iphone: 'phone', machines: 'security', answering: 'security', privacy: 'security', summaries: 'models', summarizer: 'models', model: 'models', ai: 'models', syncthing: 'sync', pairing: 'sync', pair: 'sync', about: 'updates', update: 'updates', version: 'updates', checkpoints: 'general', appearance: 'general', theme: 'general', notify: 'general' }
15
+ export const SETTINGS_ALIAS = { anywhere: 'phone', relay: 'phone', remote: 'phone', tailscale: 'phone', iphone: 'phone', machines: 'security', answering: 'security', trust: 'security', privacy: 'security', summaries: 'models', summarizer: 'models', model: 'models', ai: 'models', syncthing: 'sync', pairing: 'sync', pair: 'sync', about: 'updates', update: 'updates', version: 'updates', checkpoints: 'general', appearance: 'general', theme: 'general', notify: 'general' }
16
16
  // The section a route asks for: #/settings/<key>, or the old ?<key> form
17
17
  // (#/settings?spaces, ?sync, ?remote…); null means the list.
18
18
  export function settingsKeyFor(arg, paramKeys = []) {