feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
This commit is contained in:
@@ -33,6 +33,7 @@ const {
|
||||
toggleFav,
|
||||
normalizeProject,
|
||||
makeProjectCard,
|
||||
makeSyncChip,
|
||||
renderProjectDetail,
|
||||
groupProjects,
|
||||
displayLabel,
|
||||
@@ -285,6 +286,65 @@ describe('normalizeProject', () => {
|
||||
expect(p?.lastActiveMs).toBeUndefined()
|
||||
expect(p?.isGit).toBe(false)
|
||||
})
|
||||
|
||||
it('passes through numeric sync fields (W3 a)', () => {
|
||||
const p = normalizeProject({ name: 'web', path: '/p', ahead: 2, behind: 1, lastCommitMs: 123456 })
|
||||
expect(p?.ahead).toBe(2)
|
||||
expect(p?.behind).toBe(1)
|
||||
expect(p?.lastCommitMs).toBe(123456)
|
||||
})
|
||||
|
||||
it('drops non-numeric sync fields (W3 a)', () => {
|
||||
const p = normalizeProject({ name: 'web', path: '/p', ahead: '2', behind: null, lastCommitMs: 'x' })
|
||||
expect(p?.ahead).toBeUndefined()
|
||||
expect(p?.behind).toBeUndefined()
|
||||
expect(p?.lastCommitMs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
/* ── makeSyncChip / sync chip on the card (W3 a) ─────────────────────────────── */
|
||||
|
||||
describe('makeSyncChip', () => {
|
||||
it('renders ↑ahead ↓behind when there is drift', () => {
|
||||
const chip = makeSyncChip(makeProject({ ahead: 2, behind: 1 }))
|
||||
expect(chip).not.toBeNull()
|
||||
expect(chip?.className).toContain('proj-sync')
|
||||
expect(chip?.textContent).toBe('↑2 ↓1')
|
||||
})
|
||||
|
||||
it('shows only the ahead arrow when behind is 0', () => {
|
||||
const chip = makeSyncChip(makeProject({ ahead: 3, behind: 0 }))
|
||||
expect(chip?.textContent).toBe('↑3')
|
||||
})
|
||||
|
||||
it('returns null when in sync (ahead=behind=0)', () => {
|
||||
expect(makeSyncChip(makeProject({ ahead: 0, behind: 0 }))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when ahead/behind are undefined', () => {
|
||||
expect(makeSyncChip(makeProject())).toBeNull()
|
||||
})
|
||||
|
||||
it('includes the last-commit time in the tooltip when present', () => {
|
||||
const chip = makeSyncChip(makeProject({ ahead: 1, lastCommitMs: Date.now() - 3600_000 }))
|
||||
expect(chip?.title).toContain('last commit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeProjectCard — sync chip', () => {
|
||||
const noopHooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
|
||||
|
||||
it('renders the sync chip when the project has drift', () => {
|
||||
const card = makeProjectCard(makeProject({ ahead: 2, behind: 1 }), new Set(), noopHooks(), () => {})
|
||||
expect(card.querySelector('.proj-sync')?.textContent).toBe('↑2 ↓1')
|
||||
})
|
||||
|
||||
it('omits the sync chip when in sync / undefined', () => {
|
||||
const inSync = makeProjectCard(makeProject({ ahead: 0, behind: 0 }), new Set(), noopHooks(), () => {})
|
||||
expect(inSync.querySelector('.proj-sync')).toBeNull()
|
||||
const noData = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
|
||||
expect(noData.querySelector('.proj-sync')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/* ── makeProjectCard launcher row (Claude · Codex · VS Code) ────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user