feat(v0.6): project card launchers — Claude/Codex/VS Code logos + active highlight

Replace each card's "+ start claude here" with a row of three brand-logo
launcher buttons (logo + caption, touch-friendly, tooltips):

- Claude (coral burst)  → new terminal tab running `claude`
- Codex  (OpenAI mark)  → new terminal tab running `codex`
- VS Code (blue ribbon) → opens the repo in VS Code ON THE HOST

VS Code: new guarded POST /open-in-editor runs `<EDITOR_CMD> <path>` (default
`code`) on the host via execFile (no shell); path validated as an existing
absolute dir; Origin guard (CSRF) like the DELETE routes. EDITOR_CMD config added.

Active-session highlight (user request): when a project has a running Claude
session (Claude-hook status != unknown — plain shells/codex stay unknown), the
Claude logo gets a coral ring + tint, with a count badge when more than one.

New: public/icons.ts (simple-icons SVGs, fill=currentColor), src/http/editor.ts.
onOpenProject gains an optional cmd; makeProjectCard exported for tests.

Tests +16 (431 total): editor validation/launch, EDITOR_CMD config, launcher
row (3 buttons, claude/codex commands), and the active-Claude highlight + badge.
Verified: coverage >=80x4, build OK, both typechecks clean; in-browser the 3
logos render with brand colours, and POST /open-in-editor actually launched
VS Code on the host (403 no-Origin / 400 bad-path / 204 valid-dir all correct).
This commit is contained in:
Yaojia Wang
2026-06-30 12:46:12 +02:00
parent 3323bc81c0
commit a390130205
12 changed files with 447 additions and 42 deletions

View File

@@ -21,9 +21,8 @@ class FakeTerminal {
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
// Dynamic import AFTER mock declaration so the factory is in place.
const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject } = await import(
'../public/projects.js'
)
const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject, makeProjectCard } =
await import('../public/projects.js')
/* ── Helpers ───────────────────────────────────────────────────────────────── */
@@ -271,3 +270,86 @@ describe('normalizeProject', () => {
expect(p?.isGit).toBe(false)
})
})
/* ── makeProjectCard launcher row (Claude · Codex · VS Code) ────────────────── */
describe('makeProjectCard — launcher row', () => {
const noopHooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
it('renders exactly three brand launcher buttons', () => {
const card = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
expect(card.querySelectorAll('.proj-launch')).toHaveLength(3)
expect(card.querySelector('.proj-launch-claude')).not.toBeNull()
expect(card.querySelector('.proj-launch-codex')).not.toBeNull()
expect(card.querySelector('.proj-launch-vscode')).not.toBeNull()
// The old single-button action is gone.
expect(card.querySelector('.proj-new')).toBeNull()
})
it('each launcher embeds an inline SVG logo + caption', () => {
const card = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
const claude = card.querySelector('.proj-launch-claude')!
expect(claude.querySelector('svg')).not.toBeNull()
expect(claude.querySelector('.proj-launch-cap')?.textContent).toBe('Claude')
})
it('Claude launcher opens the project with the claude command (with CR)', () => {
const hooks = noopHooks()
const card = makeProjectCard(makeProject({ name: 'repo', path: '/p/repo' }), new Set(), hooks, () => {})
;(card.querySelector('.proj-launch-claude') as HTMLButtonElement).click()
expect(hooks.onOpenProject).toHaveBeenCalledWith('/p/repo', 'repo', 'claude\r')
})
it('Codex launcher opens the project with the codex command (with CR)', () => {
const hooks = noopHooks()
const card = makeProjectCard(makeProject({ name: 'repo', path: '/p/repo' }), new Set(), hooks, () => {})
;(card.querySelector('.proj-launch-codex') as HTMLButtonElement).click()
expect(hooks.onOpenProject).toHaveBeenCalledWith('/p/repo', 'repo', 'codex\r')
})
it('still lists running sessions above the launcher row', () => {
const p = makeProject({
sessions: [{ id: 's1', status: 'idle', clientCount: 1, createdAt: 1, exited: false }],
})
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-sessions-list')).not.toBeNull()
expect(card.querySelectorAll('.proj-launch')).toHaveLength(3)
})
it('highlights the Claude launcher when a Claude session (known status) is live', () => {
const p = makeProject({
sessions: [{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false }],
})
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(true)
// Codex / VS Code are not highlighted by a Claude session.
expect(card.querySelector('.proj-launch-codex')!.classList.contains('proj-launch-active')).toBe(false)
})
it('does NOT highlight Claude for an unknown-status session (plain shell / codex)', () => {
const p = makeProject({
sessions: [{ id: 's1', status: 'unknown', clientCount: 0, createdAt: 1, exited: false }],
})
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(false)
})
it('does NOT highlight Claude for an exited session', () => {
const p = makeProject({
sessions: [{ id: 's1', status: 'idle', clientCount: 0, createdAt: 1, exited: true }],
})
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(false)
})
it('shows a count badge when more than one Claude session is live', () => {
const p = makeProject({
sessions: [
{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false },
{ id: 's2', status: 'idle', clientCount: 0, createdAt: 2, exited: false },
],
})
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
expect(card.querySelector('.proj-launch-claude .proj-launch-badge')?.textContent).toBe('2')
})
})