@leftoff/cli 0.8.9 → 0.9.1

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/picker.js CHANGED
@@ -367,12 +367,49 @@ export function actionMenuKey(anchor, e, open) {
367
367
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(); if (act && e.key === 'ArrowUp') actFocus(-1) }
368
368
  }
369
369
 
370
+ // 0.9.0: a one-field form in the same popover / bottom sheet ("New project…",
371
+ // "Rename project…"): a text box (16px on touch, like every input) and one
372
+ // button. Enter submits, Escape or a tap outside cancels. onSubmit(value) may
373
+ // return a promise; an error it throws shows under the box and keeps it open.
374
+ export function openNameSheet(anchor, { title = '', label = 'Name', value = '', placeholder = '', submit = 'Save', onSubmit } = {}) {
375
+ closeActionMenu(); closeMenu()
376
+ const sheet = narrow()
377
+ const id = 'lo-act' + (++uid)
378
+ const menu = document.createElement('div')
379
+ menu.className = 'lo-menu lo-actmenu lo-namesheet' + (sheet ? ' sheet' : '')
380
+ menu.id = id
381
+ menu.setAttribute('role', 'dialog')
382
+ menu.setAttribute('aria-label', title || label)
383
+ menu.innerHTML = (title ? '<div class="lo-sheet-head"><span>' + esc(title) + '</span>' + (sheet ? '<button type="button" class="lo-sheet-done" data-act-close>Cancel</button>' : '') + '</div>' : '') +
384
+ '<form class="lo-nameform"><input type="text" class="lo-name" aria-label="' + esc(label) + '" placeholder="' + esc(placeholder) + '" value="' + esc(value) + '" maxlength="40" autocomplete="off" spellcheck="false">' +
385
+ '<button type="submit" class="btn primary lo-name-ok">' + esc(submit) + '</button></form><div class="lo-name-err" role="alert" hidden></div>'
386
+ let backdrop = null
387
+ if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
388
+ document.body.appendChild(menu)
389
+ const input = menu.querySelector('input'), errBox = menu.querySelector('.lo-name-err'), ok = menu.querySelector('.lo-name-ok')
390
+ act = { anchor, menu, backdrop, items: [input, ok], form: true }
391
+ anchor.setAttribute('aria-expanded', 'true')
392
+ menu.addEventListener('click', (e) => { if (e.target.closest('[data-act-close]')) closeActionMenu({ focus: true }) })
393
+ menu.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeActionMenu({ focus: true }) } })
394
+ menu.querySelector('form').addEventListener('submit', async (e) => {
395
+ e.preventDefault()
396
+ const v = input.value.trim()
397
+ if (!v) { input.focus(); return }
398
+ ok.disabled = true
399
+ try { await (onSubmit && onSubmit(v)); closeActionMenu() } catch (err) { errBox.textContent = (err && err.message) || String(err); errBox.hidden = false; ok.disabled = false; input.focus() }
400
+ })
401
+ placeAction()
402
+ input.focus()
403
+ try { input.setSelectionRange(input.value.length, input.value.length) } catch {}
404
+ return menu
405
+ }
406
+
370
407
  if (typeof window !== 'undefined' && window.customElements && !customElements.get('lo-select')) {
371
408
  customElements.define('lo-select', LoSelect)
372
409
  customElements.define('lo-combo', LoCombo)
373
410
  // a click outside (or a tap on the page behind a sheet) closes; so does the page moving under a desktop menu
374
411
  document.addEventListener('pointerdown', (e) => { if (open && !open.menu.contains(e.target) && !open.host.contains(e.target)) closeMenu() }, true)
375
- window.addEventListener('resize', () => { if (open) closeMenu(); if (act) closeActionMenu() })
412
+ window.addEventListener('resize', () => { if (open) closeMenu(); if (act && !act.form) closeActionMenu() }) // a form stays open while a phone's keyboard comes up
376
413
  document.addEventListener('scroll', (e) => { if (open && !open.menu.contains(e.target)) place(); if (act && !act.menu.contains(e.target)) placeAction() }, true)
377
414
  // a tap outside a card's ⋯ menu closes it (and goes nowhere: a tap on the backdrop, or anywhere on a desktop page, only closes)
378
415
  document.addEventListener('pointerdown', (e) => {
package/ui/prs.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // are many-to-many; a row shows every session it is linked to.
4
4
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
5
5
  import { repoChips, labelRepos, repoMatches, chipSelected, orderSelectedFirst } from './repos.js'
6
+ import { renderDoc } from './handoffs.js'
6
7
  const safeUrl = (u) => (/^https:\/\/github\.com\//.test(String(u || '')) ? String(u) : '')
7
8
 
8
9
  export function shortAgo(iso, now = Date.now()) {
@@ -79,12 +80,15 @@ export function ghLineHtml(g) {
79
80
  }
80
81
 
81
82
  function sessionLinks(r, opts) {
82
- const ss = r.sessions || []
83
+ // 0.9.0: never scripted runs (claude -p / codex exec, archived as such); two, then "+N more" that opens
84
+ const ss = (r.sessions || []).filter((s) => s && !s.nonInteractive && s.archivedReason !== 'non_interactive')
83
85
  if (!ss.length) return ''
84
- return '<div class="pr-sessions">' + ss.slice(0, 4).map((s) => {
86
+ const one = (s) => {
85
87
  const act = opts.sessionAction ? opts.sessionAction(s) : ''
86
88
  return '<span class="pr-sess"><a href="#/s/' + encodeURIComponent(s.id) + '" class="sesslink">' + esc(s.title || 'Untitled session') + '</a>' + (s.machine && opts.showMachine ? '<span class="faint"> · ' + esc(s.machine) + '</span>' : '') + act + '</span>'
87
- }).join('') + (ss.length > 4 ? '<span class="faint">+' + (ss.length - 4) + '</span>' : '') + '</div>'
89
+ }
90
+ return '<div class="pr-sessions">' + ss.slice(0, 2).map(one).join('') +
91
+ (ss.length > 2 ? '<details class="pr-more-sess"><summary class="linkish">+' + (ss.length - 2) + ' more</summary>' + ss.slice(2).map(one).join('') + '</details>' : '') + '</div>'
88
92
  }
89
93
 
90
94
  // One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
@@ -122,6 +126,20 @@ export function plainBody(md) {
122
126
  .replace(/<\/?[a-z][^>]*>/gi, '').replace(/^#{1,6}\s+/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
123
127
  .replace(/^[ \t]*[-*][ \t]+\[x\][ \t]+/gim, '☑ ').replace(/^[ \t]*[-*][ \t]+\[ \][ \t]+/gm, '☐ ').replace(/^[ \t]*[-*][ \t]+/gm, '• ').replace(/\n{3,}/g, '\n\n').trim()
124
128
  }
129
+ // 0.9.0: a PR's description as markdown, with the file viewer's renderer and
130
+ // styles (headings, lists, task boxes, tables, code, quotes). Safe: GitHub's
131
+ // raw HTML is dropped to its text (renderDoc escapes everything else), an
132
+ // image becomes a link to it (never hotlinked), links open in a new tab.
133
+ export function prBodyMd(md) {
134
+ return String(md || '').replace(/\r\n?/g, '\n').replace(/<!--[\s\S]*?-->/g, '').replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '')
135
+ .replace(/<img\b[^>]*?\bsrc=["']([^"']+)["'][^>]*>/gi, (m, u) => '[🖼 image](' + u + ')')
136
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/g, (m, alt, u) => '[🖼 ' + (alt.trim() || 'image') + '](' + u + ')')
137
+ .replace(/<br\s*\/?>/gi, '\n').replace(/<\/?(p|div|details|summary|h[1-6]|li|ul|ol|table|tr)\b[^>]*>/gi, '\n')
138
+ .replace(/<\/?[a-z][^>]*>/gi, '').replace(/\n{3,}/g, '\n\n').trim()
139
+ }
140
+ export function prBodyHtml(md) {
141
+ return renderDoc(prBodyMd(md)).replace(/<a href="/g, '<a target="_blank" rel="noopener noreferrer" href="').replace(/(<a target="_blank" rel="noopener noreferrer" href="[^"]*") target="_blank" rel="noopener"/g, '$1')
142
+ }
125
143
  export function prDetailHtml(p, st, { now = Date.now() } = {}) {
126
144
  if (!st || st.loading) return '<div class="pr-detail loading" aria-busy="true">Loading details…</div>'
127
145
  if (!st.ok) return '<div class="pr-detail none">' + (st.code === 'no_gh' ? 'Details need gh on a Mac (none of yours has it signed in).' : 'Couldn’t load the details right now. <button type="button" class="linkish" data-action="pr-expand-retry" data-url="' + esc(p.url) + '">Try again</button>') + '</div>'
@@ -146,7 +164,7 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
146
164
  const stats = [d.changedFiles != null ? d.changedFiles + ' file' + (d.changedFiles === 1 ? '' : 's') : '', d.additions != null ? '<span class="pd-add">+' + d.additions + '</span>' : '', d.deletions != null ? '<span class="pd-del">−' + d.deletions + '</span>' : ''].filter(Boolean)
147
165
  const body = plainBody(d.body)
148
166
  h += '<div class="pd-sec"><div class="pd-h">Description' + (stats.length ? ' <span class="pd-stats">' + stats.join(' ') + '</span>' : '') + '</div>' +
149
- (body ? (body.length > 280 || body.split('\n').length > 5 ? '<details class="pd-body"><summary><span class="pd-clip">' + esc(body.split('\n').slice(0, 4).join('\n').slice(0, 280)) + '…</span><span class="linkish pd-more">more</span></summary><div class="pd-text">' + esc(body) + '</div></details>' : '<div class="pd-text">' + esc(body) + '</div>') : '<div class="faint">No description.</div>') + '</div>'
167
+ (body ? (body.length > 280 || body.split('\n').length > 5 ? '<details class="pd-body"><summary><span class="pd-clip">' + esc(body.split('\n').slice(0, 4).join('\n').slice(0, 280)) + '…</span><span class="linkish pd-more">more</span></summary><div class="doc-md pd-md">' + prBodyHtml(d.body) + '</div></details>' : '<div class="doc-md pd-md">' + prBodyHtml(d.body) + '</div>') : '<div class="faint">No description.</div>') + '</div>'
150
168
  if ((d.comments || []).length) {
151
169
  h += '<div class="pd-sec"><div class="pd-h">Latest comments' + (d.commentCount > d.comments.length ? ' <span class="faint">' + d.comments.length + ' of ' + d.commentCount + '</span>' : '') + '</div><ul class="pd-comments">' + d.comments.map((c) => {
152
170
  const first = String(c.body || '').split('\n').filter((x) => x.trim()).slice(0, 2).join(' ')
@@ -214,17 +232,19 @@ export function readPrFilter(params) {
214
232
  repos: [...new Set(p.getAll('repo').flatMap(splitList))],
215
233
  state,
216
234
  q: String(p.get('q') || '').slice(0, 200),
235
+ project: p.get('project') || null, // 0.9.0
217
236
  }
218
237
  }
219
238
  export function prsHash(view, f = {}) {
220
239
  const parts = []
221
240
  if (view === 'team') parts.push('view=team')
241
+ if (f.project) parts.push('project=' + encodeURIComponent(f.project))
222
242
  if (f.repos && f.repos.length) parts.push('repo=' + f.repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
223
243
  if (f.state) parts.push('state=' + f.state)
224
244
  if (f.q && f.q.trim()) parts.push('q=' + encodeURIComponent(f.q.trim()))
225
245
  return '#/prs' + (parts.length ? '?' + parts.join('&') : '')
226
246
  }
227
- export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || (f.q && f.q.trim())))
247
+ export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || f.project || (f.q && f.q.trim())))
228
248
 
229
249
  // Every row, flat: the daemon's `rows`, or an older daemon's groups.
230
250
  export function prRows(d) {
@@ -254,9 +274,19 @@ export function inPrRepos(r, repos, labels = null) {
254
274
  const key = r.pr.repo || 'no-repo'
255
275
  return repos.some((t) => repoMatches({ repoKey: key, repoLabel: labels ? labels.get(key) : null }, t))
256
276
  }
257
- export function filterPrRows(rows, f = {}, { skip = null, me = null } = {}) {
277
+ // 0.9.0: the project a PR is in: the one holding its GitHub repo, else the
278
+ // project of a session it's linked to. projects: GET /v1/projects' list.
279
+ export function prProjectOf(r, projects = []) {
280
+ const repo = String((r && r.pr && r.pr.repo) || '').toLowerCase()
281
+ const p = repo && (projects || []).find((x) => (x.repos || []).some((y) => y.github && y.github.toLowerCase() === repo))
282
+ if (p) return p.id
283
+ const s = ((r && r.sessions) || []).find((x) => x && x.repoProject)
284
+ return s ? s.repoProject.id : null
285
+ }
286
+ export const inPrProject = (r, id, projects = []) => !id || prProjectOf(r, projects) === id
287
+ export function filterPrRows(rows, f = {}, { skip = null, me = null, projects = [] } = {}) {
258
288
  const labels = labelRepos(rows.map((r) => r.pr.repo || 'no-repo'))
259
- return rows.filter((r) => (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state, me)) && inPrSearch(r, f.q))
289
+ return rows.filter((r) => (skip === 'project' || inPrProject(r, f.project, projects)) && (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state, me)) && inPrSearch(r, f.q))
260
290
  }
261
291
  // Groups (0.8.9: the buckets), newest first in each; anything with no
262
292
  // activity for 14+ days in Quiet.
@@ -277,15 +307,32 @@ function fchip({ on, count, label, attrs, cls = '' }) {
277
307
  }
278
308
 
279
309
  // The chip rows and the grouped list (repainted as you type in the search box).
280
- export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false } = {}) {
281
- const f = { repos: [], state: null, q: '', ...filter }
310
+ // 0.9.0: Merged and Closed fold to one row each ("Merged · 17 ›"), opened on a
311
+ // tap and remembered like Quiet; open when it's the bucket you picked or all there is.
312
+ export const DONE_GROUPS = ['merged', 'closed']
313
+ export const isOpenPr = (r) => !['merged', 'closed'].includes(String((r && r.pr && r.pr.state) || '').toLowerCase())
314
+ function doneGroupHtml(g, { open, rowOpts }) {
315
+ return '<details class="prgroup done b-' + g.key + '" data-group="' + g.key + '"' + (open ? ' open' : '') + '><summary><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span><span class="chev" aria-hidden="true">›</span></h2></summary>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
316
+ }
317
+ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false, doneOpen = null, projects = [] } = {}) {
318
+ const f = { repos: [], state: null, q: '', project: null, ...filter }
319
+ if (f.project && !(projects || []).some((p) => p.id === f.project)) f.project = null
282
320
  const all = prRows(d)
283
321
  const me = d.me || null
284
322
  let h = ''
285
323
  if (all.length) {
324
+ // 0.9.0: projects first (when you have any), then the repos in the picked one
325
+ if ((projects || []).length) {
326
+ const pCount = filterPrRows(all, f, { skip: 'project', me, projects }).filter(isOpenPr) // 0.9.0: counts are open PRs
327
+ const n = new Map()
328
+ for (const r of pCount) { const id = prProjectOf(r, projects); if (id) n.set(id, (n.get(id) || 0) + 1) }
329
+ h += chipRow('Filter by project', [fchip({ on: !f.project, count: pCount.length, label: 'All projects', cls: 'all', attrs: 'data-action="prs-filter" data-k="project" data-v=""' })]
330
+ .concat(projects.map((p) => fchip({ on: f.project === p.id, count: n.get(p.id) || 0, label: (p.emoji ? p.emoji + ' ' : '') + p.name, cls: 'pchip', attrs: 'data-action="prs-filter" data-k="project" data-v="' + esc(p.id) + '"' }))), 'prs-projects')
331
+ }
286
332
  // Repos: All, then by latest activity; a chosen one always shows, first and removable.
287
- const repoCount = filterPrRows(all, f, { skip: 'repos', me })
288
- let chips = prRepoChips(all, { countIn: repoCount })
333
+ const inProj = f.project ? all.filter((r) => inPrProject(r, f.project, projects)) : all
334
+ const repoCount = filterPrRows(all, f, { skip: 'repos', me, projects }).filter(isOpenPr) // 0.9.0: counts are open PRs
335
+ let chips = prRepoChips(inProj, { countIn: repoCount })
289
336
  for (const tok of f.repos) if (!chips.some((c) => chipSelected([tok], c))) chips.push({ key: tok, label: tok, count: 0, total: 0, last: 0 })
290
337
  chips = orderSelectedFirst(chips, f.repos)
291
338
  if (chips.length > 1 || f.repos.length) {
@@ -295,14 +342,14 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
295
342
  // 0.8.9: one row of buckets. The four open ones always show (dimmed at 0,
296
343
  // after the rest), Merged and Closed only when there are some; a chosen
297
344
  // one comes first.
298
- const stCount = filterPrRows(all, f, { skip: 'state', me })
345
+ const stCount = filterPrRows(all, f, { skip: 'state', me, projects })
299
346
  const bs = PR_BUCKETS.map(([k, l]) => ({ v: k, l, n: stCount.filter((r) => bucketOf(r, me) === k).length, on: f.state === k }))
300
347
  const shown = bs.filter((x) => !x.on && (x.n || ['review', 'fix', 'ready', 'waiting'].includes(x.v)) && !(x.v === 'review' && !x.n && !me))
301
348
  // the ones with PRs lead, so on a phone the empty ones are what scrolls off
302
349
  const ordered = [...bs.filter((x) => x.on), ...shown.filter((x) => x.n), ...shown.filter((x) => !x.n)]
303
350
  h += chipRow('Filter by what each PR is waiting on', ordered.map((x) => fchip({ on: x.on, count: x.n, label: x.l, cls: 'b-' + x.v, attrs: 'data-action="prs-filter" data-k="state" data-v="' + x.v + '"' })), 'prs-states prs-buckets')
304
351
  }
305
- const rows = filterPrRows(all, f, { me })
352
+ const rows = filterPrRows(all, f, { me, projects })
306
353
  const { groups, quiet } = groupPrRows(rows, { now, me })
307
354
  const showMachine = !!d.multiMachine
308
355
  const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
@@ -311,7 +358,7 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
311
358
  } else if (!rows.length) {
312
359
  h += '<div class="empty">No PRs match. <button type="button" class="linkish" data-action="prs-clear">Clear filters</button></div>'
313
360
  } else {
314
- for (const g of groups) h += '<section class="prgroup b-' + g.key + (g.key === 'fix' || g.key === 'review' ? ' attention' : '') + '" data-group="' + g.key + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</section>'
361
+ for (const g of groups) h += DONE_GROUPS.includes(g.key) ? doneGroupHtml(g, { open: !!(doneOpen && doneOpen[g.key]) || f.state === g.key || !groups.some((x) => !DONE_GROUPS.includes(x.key)), rowOpts }) : '<section class="prgroup b-' + g.key + (g.key === 'fix' || g.key === 'review' ? ' attention' : '') + '" data-group="' + g.key + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</section>'
315
362
  if (quiet.length) h += '<details class="prgroup quiet" data-group="quiet"' + (quietOpen || !groups.length ? ' open' : '') + '><summary><h2>Quiet <span class="count">' + quiet.length + '</span> <span class="faint qhint">no activity for ' + QUIET_DAYS + '+ days</span></h2></summary>' + quiet.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
316
363
  }
317
364
  if (view === 'team' && (d.failed || []).length) h += '<div class="hint">Couldn’t load ' + esc(d.failed.join(', ')) + ' right now.</div>'
package/ui/repos.js CHANGED
@@ -133,18 +133,38 @@ export function readFilter(params, storage) {
133
133
  try { const v = storage && storage.getItem(STORE_KEY); repos = v ? JSON.parse(v) : [] ; if (repos.length) from = 'storage' } catch { repos = [] }
134
134
  if (!Array.isArray(repos)) repos = []
135
135
  }
136
+ // 0.9.0: the project (its id) the same way: the hash first, else this browser's last pick
137
+ let project = null
138
+ if (p.has('project')) { project = p.get('project') || null; if (from === 'none') from = 'hash' }
139
+ else if (!p.has('repo')) { try { project = (storage && storage.getItem(PROJECT_KEY)) || null } catch {} ; if (project && from === 'none') from = 'storage' }
136
140
  const status = p.get('status')
137
- return { repos: [...new Set(repos.map(String))], status: status && STATUS_FILTERS[status] ? status : null, from }
141
+ return { repos: [...new Set(repos.map(String))], status: status && STATUS_FILTERS[status] ? status : null, project, from }
138
142
  }
139
143
 
144
+ export const PROJECT_KEY = 'cp.project'
145
+ export function saveProject(storage, id) {
146
+ try { if (!storage) return; if (id) storage.setItem(PROJECT_KEY, id); else storage.removeItem(PROJECT_KEY) } catch {}
147
+ }
148
+ // 0.9.0: a session in a project (its repo's, by id); no project picked = every session.
149
+ export function inProject(s, id) { return !id || !!(s && s.repoProject && s.repoProject.id === id) }
150
+ // The project chips: every project (they are yours, so a zero still shows),
151
+ // with the count of `sessions` in each. -> [{ id, name, emoji, count }]
152
+ export function projectChips(projects, sessions) {
153
+ const n = new Map()
154
+ for (const s of sessions || []) if (s && s.repoProject) n.set(s.repoProject.id, (n.get(s.repoProject.id) || 0) + 1)
155
+ return (projects || []).map((p) => ({ id: p.id, name: p.name, emoji: p.emoji || null, count: n.get(p.id) || 0 }))
156
+ }
157
+ export const projectLabel = (p) => (p ? (p.emoji ? p.emoji + ' ' : '') + p.name : '')
158
+
140
159
  export function saveRepos(storage, repos) {
141
160
  try { if (!storage) return; if (repos && repos.length) storage.setItem(STORE_KEY, JSON.stringify(repos)); else storage.removeItem(STORE_KEY) } catch {}
142
161
  }
143
162
 
144
163
  // The board's hash for a filter: "#/", "#/?repo=clutch_hunt",
145
164
  // "#/?repo=a,b&status=active". Commas and slashes stay readable.
146
- export function filterHash({ repos = [], status = null } = {}) {
165
+ export function filterHash({ repos = [], status = null, project = null } = {}) {
147
166
  const parts = []
167
+ if (project) parts.push('project=' + encodeURIComponent(project))
148
168
  if (repos.length) parts.push('repo=' + repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
149
169
  if (status) parts.push('status=' + encodeURIComponent(status))
150
170
  return '#/' + (parts.length ? '?' + parts.join('&') : '')
@@ -206,7 +226,7 @@ export function searchMatches(s, q, extra = []) {
206
226
  const words = String(q || '').toLowerCase().trim().split(/\s+/).filter(Boolean)
207
227
  if (!words.length) return true
208
228
  const cp = s.lastCheckpoint || null
209
- const hay = [s.title, s.repoLabel, s.repoName, s.repo && s.repo.branch, s.repo && s.repo.github, s.worktree && s.worktree.name, s.worktree && s.worktree.branch, s.cwd, s.lastPreview, s.firstPrompt, s.machine,
229
+ const hay = [s.title, s.repoLabel, s.repoName, s.repoProject && s.repoProject.name, s.repo && s.repo.branch, s.repo && s.repo.github, s.worktree && s.worktree.name, s.worktree && s.worktree.branch, s.cwd, s.lastPreview, s.firstPrompt, s.machine,
210
230
  cp && cp.doing, cp && cp.next, cp && cp.stoppedAt, ...extra].filter(Boolean).map((h) => String(h).toLowerCase())
211
231
  const id = String(s.id || '').toLowerCase()
212
232
  const short = String(s.shortId || '').toLowerCase()