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

@@ -1077,35 +1077,83 @@ body {
}
/* " New" / " start claude here" action button */
.proj-new {
background: var(--surface-3);
/* Launcher row — Claude · Codex · VS Code, each a brand-logo icon button. */
.proj-launchers {
display: flex;
gap: 8px;
margin-top: 2px;
}
.proj-launch {
position: relative; /* for the active count badge */
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 9px 4px;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 8px;
padding: 7px 12px;
border-radius: 9px;
cursor: pointer;
font: inherit;
font-size: 13px;
text-align: center;
transition: background 0.1s ease, color 0.1s ease, border-color 0.1s ease;
color: var(--text-dim); /* drives the logo colour (currentColor) by default */
transition: background 0.12s ease, border-color 0.12s ease, transform 0.08s ease;
}
.proj-new:hover {
background: var(--accent-soft);
color: var(--accent);
border-color: rgba(227, 166, 74, 0.3);
.proj-launch svg {
display: block;
}
.proj-launch-cap {
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
}
.proj-launch:hover {
background: var(--surface-3);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.proj-launch:active {
transform: translateY(0);
}
.proj-launch:hover .proj-launch-cap {
color: var(--text);
}
/* Brand colours on the logos (currentColor) */
.proj-launch-claude {
color: #d97757; /* Claude coral */
}
.proj-launch-codex {
color: #e7e8ec; /* OpenAI mark — near-white on dark */
}
.proj-launch-vscode {
color: #3aa0e3; /* VS Code blue */
}
/* Primary action — no sessions yet; full Amber accent button */
.proj-new.proj-new-primary {
background: var(--accent);
color: var(--on-accent);
border-color: var(--accent);
font-weight: 600;
/* Active state — a matching session is running (Claude logo when a Claude
* session is live). Coral ring + faint tint draw the eye without shouting. */
.proj-launch-active {
background: rgba(217, 119, 87, 0.14);
border-color: #d97757;
box-shadow: 0 0 0 1px rgba(217, 119, 87, 0.45) inset;
}
.proj-new.proj-new-primary:hover {
background: var(--accent-2);
border-color: var(--accent-2);
color: var(--on-accent);
.proj-launch-active .proj-launch-cap {
color: #e9b9a6;
}
.proj-launch-badge {
position: absolute;
top: 4px;
right: 6px;
min-width: 15px;
height: 15px;
padding: 0 3px;
box-sizing: border-box;
border-radius: 8px;
background: #d97757;
color: #1a1408;
font-size: 9px;
font-weight: 700;
line-height: 15px;
text-align: center;
}
/* Empty state */