@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/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
+ }
package/ui/repos.js CHANGED
@@ -151,8 +151,15 @@ export function toggleRepo(selected, chip, multi = false) {
151
151
 
152
152
  // Status AND repo. `repos` are tokens (labels); an empty list means all repos.
153
153
  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))))
154
+ return sessions.filter((s) => (!status || inStatus(s, status)) && (!repos.length || repos.some((r) => repoMatches(s, r))))
155
+ }
156
+ // A session in a status filter. "Needs you" also counts anything with a Needs
157
+ // item (an agent feed asking you something, a turn that ended on a question),
158
+ // so the tile agrees with the strip and the tab's count (0.6.5).
159
+ export function inStatus(s, status) {
160
+ const statuses = STATUS_FILTERS[status]
161
+ if (!statuses) return true
162
+ return statuses.includes(s.status) || (status === 'needs' && !!s.needsYou && !s.archived)
156
163
  }
157
164
 
158
165
  // Which chips show in the row and which go behind "More ▾": the top `max` by
@@ -2,7 +2,7 @@
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' },