feat(v0.6): auto-group Projects by namespace + Active-now band + cross-device prefs

The flat Projects grid was overwhelming at 50-100 repos. Explored the UX with
4 parallel agents (auto-grouping / manual folders / density-segmentation /
competitive research); all converged on namespace auto-grouping as the primary
fix — the dotted repo names (Billo.Platform.*, Billo.Infrastructure.*) already
encode the hierarchy, so it's a string-split, not a taxonomy the user builds.

Frontend:
- groupProjects(): depth-2 namespace grouping, >=2-member threshold (singletons
  fall to "Other"), degrade-to-flat when repos share no prefix, running projects
  duplicated into a pinned "Active now" section.
- displayLabel(): cards show the namespace tail (Payment, not
  Billo.Platform.Payment); full name in Active/Other.
- Collapsible sticky group headers with an "N active" badge so a collapsed
  section never hides running work; search force-expands matching groups.
- public/prefs.ts: server-first load with a localStorage offline mirror, migrates
  the legacy per-device proj-favs.

Backend (server-side prefs so favourites + collapse-state follow the user across
devices — localStorage was per-device, which broke the multi-device premise):
- src/http/prefs-store.ts: ~/.web-terminal-prefs.json store (mirrors push-store),
  every field bounded + sanitized.
- GET /prefs (read-only) + PUT /prefs (Origin-guarded); UiPrefs type +
  PREFS_STORE_PATH config.

Tests: +12 prefs-store, +21 groupProjects/displayLabel. Full suite 1227 passed.
Verified live: grouping renders, collapse persists to the server and survives a
full page reload (cross-device sync proven end-to-end).
This commit is contained in:
Yaojia Wang
2026-07-01 04:25:57 +02:00
parent 7b3ba8491a
commit 03612323c0
10 changed files with 581 additions and 1 deletions

View File

@@ -30,6 +30,10 @@ const {
normalizeProject,
makeProjectCard,
renderProjectDetail,
groupProjects,
displayLabel,
ACTIVE_GROUP_KEY,
OTHER_GROUP_KEY,
} = await import('../public/projects.js')
/* ── Helpers ───────────────────────────────────────────────────────────────── */
@@ -509,3 +513,121 @@ describe('renderProjectDetail', () => {
expect(h.onOpenProject).toHaveBeenCalledWith('/p/r', 'r', 'claude "/init"\r')
})
})
/* ── groupProjects / displayLabel (namespace grouping, v0.6) ─────────────────── */
const runningSession = { id: 's', status: 'working' as const, clientCount: 1, createdAt: 1, exited: false }
function proj(name: string, over: Partial<ProjectInfo> = {}): ProjectInfo {
return makeProject({ name, path: `/repos/${name}`, ...over })
}
describe('groupProjects', () => {
const noFavs = new Set<string>()
it('groups repos sharing a two-segment namespace (≥2 members)', () => {
const groups = groupProjects(
[proj('Billo.Platform.Payment'), proj('Billo.Platform.Document')],
noFavs,
)
expect(groups).toHaveLength(1)
expect(groups[0].kind).toBe('namespace')
expect(groups[0].label).toBe('Billo.Platform')
expect(groups[0].projects.map((p) => p.name)).toEqual([
'Billo.Platform.Payment',
'Billo.Platform.Document',
])
})
it('drops single-member namespaces and no-dot repos into "Other"', () => {
const groups = groupProjects(
[
proj('Billo.Platform.Payment'),
proj('Billo.Platform.Document'),
proj('Billo.Customer'), // lone "Billo.Customer" namespace
proj('web-terminal'), // no dot
],
noFavs,
)
const other = groups.find((g) => g.kind === 'other')
expect(other).toBeDefined()
expect(other!.projects.map((p) => p.name).sort()).toEqual(['Billo.Customer', 'web-terminal'])
})
it('falls back to a single flat group when no namespace earns a section', () => {
const groups = groupProjects([proj('web-terminal'), proj('graphrag'), proj('piano')], noFavs)
expect(groups).toHaveLength(1)
expect(groups[0].kind).toBe('flat')
expect(groups[0].projects).toHaveLength(3)
})
it('pins an "Active now" section first with projects that have a running session', () => {
const groups = groupProjects(
[
proj('Billo.Platform.Payment', { sessions: [runningSession] }),
proj('Billo.Platform.Document'),
],
noFavs,
)
expect(groups[0].kind).toBe('active')
expect(groups[0].label).toBe('Active now')
expect(groups[0].projects.map((p) => p.name)).toEqual(['Billo.Platform.Payment'])
// still also present inside its namespace group (deliberate duplication)
const ns = groups.find((g) => g.kind === 'namespace')!
expect(ns.projects.map((p) => p.name)).toContain('Billo.Platform.Payment')
expect(ns.activeCount).toBe(1)
})
it('orders namespace sections by their most-recently-active member', () => {
const groups = groupProjects(
[
proj('Old.Alpha.a', { lastActiveMs: 10 }),
proj('Old.Alpha.b', { lastActiveMs: 20 }),
proj('New.Beta.a', { lastActiveMs: 900 }),
proj('New.Beta.b', { lastActiveMs: 100 }),
],
noFavs,
)
const ns = groups.filter((g) => g.kind === 'namespace').map((g) => g.label)
expect(ns).toEqual(['New.Beta', 'Old.Alpha'])
})
it('keeps "Other" last', () => {
const groups = groupProjects(
[proj('Billo.Platform.Payment'), proj('Billo.Platform.Document'), proj('loose')],
noFavs,
)
expect(groups[groups.length - 1].kind).toBe('other')
})
it('merges namespaces case-insensitively', () => {
const groups = groupProjects([proj('Billo.Platform.a'), proj('billo.platform.b')], noFavs)
expect(groups).toHaveLength(1)
expect(groups[0].projects).toHaveLength(2)
})
it('does not mutate the input array', () => {
const input = [proj('A.b.c'), proj('A.b.d')]
const copy = [...input]
groupProjects(input, noFavs)
expect(input).toEqual(copy)
})
})
describe('displayLabel', () => {
it('strips the namespace prefix inside a namespace group', () => {
expect(displayLabel('Billo.Platform.Payment', 'Billo.Platform')).toBe('Payment')
expect(displayLabel('Billo.Platform.Shared.DomainEvents', 'Billo.Platform')).toBe(
'Shared.DomainEvents',
)
})
it('shows the full name in the active and other sections', () => {
expect(displayLabel('Billo.Platform.Payment', ACTIVE_GROUP_KEY)).toBe('Billo.Platform.Payment')
expect(displayLabel('web-terminal', OTHER_GROUP_KEY)).toBe('web-terminal')
})
it('is case-insensitive on the prefix but preserves the tail casing', () => {
expect(displayLabel('Billo.Platform.Payment', 'billo.platform')).toBe('Payment')
})
})