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

27
public/icons.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* public/icons.ts — brand logos for the project launcher buttons (v0.6).
*
* Single-path SVGs (viewBox 0 0 24 24, from simple-icons), rendered with
* fill="currentColor" so each launcher button sets its own brand colour via CSS.
* Strings are static + trusted (no user data) — safe to inject via innerHTML.
*/
function logoSvg(path: string): string {
return (
'<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true">' +
'<path d="' + path + '"/></svg>'
)
}
export const CLAUDE_LOGO = logoSvg(
'm4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z',
)
// OpenAI mark — used for the Codex launcher.
export const CODEX_LOGO = logoSvg(
'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
)
export const VSCODE_LOGO = logoSvg(
'M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z',
)

View File

@@ -15,6 +15,7 @@
import type { ProjectInfo, ProjectSessionRef, ClaudeStatus } from '../src/types.js'
import { el, relTime } from './preview-grid.js'
import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js'
/* ── Constants ───────────────────────────────────────────────────────────── */
@@ -24,8 +25,8 @@ const REFRESH_MS = 5000
/* ── Public contract (consumed by P6 — tabs.ts wiring) ──────────────────── */
export interface ProjectsHooks {
/** Spawn a new claude session in the given repo. */
onOpenProject: (repoPath: string, repoName: string) => void
/** Spawn a new terminal session in the repo running `cmd` (default: claude). */
onOpenProject: (repoPath: string, repoName: string, cmd?: string) => void
/** Enter an already-running session by id. */
onEnterSession: (id: string) => void
}
@@ -169,7 +170,88 @@ function makeSessionRow(
return row
}
function makeProjectCard(
/** Ask the host to open this project in the editor (POST /open-in-editor → `code <path>`). */
async function openProjectInEditor(repoPath: string): Promise<void> {
try {
const res = await fetch('/open-in-editor', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: repoPath }),
})
if (!res.ok) console.error('[projects] open in editor failed:', res.status)
} catch (err) {
console.error('[projects] open in editor error:', err)
}
}
interface LauncherOpts {
/** Highlight the button (a matching session is running). */
active?: boolean
/** Optional count badge (only shown when > 1). */
count?: number
}
/** One launcher button: brand logo (currentColor) + caption; colour set via CSS. */
function makeLauncher(
logo: string,
label: string,
cls: string,
title: string,
onClick: () => void,
opts?: LauncherOpts,
): HTMLElement {
const active = opts?.active === true
const btn = el('button', `proj-launch ${cls}${active ? ' proj-launch-active' : ''}`)
btn.title = title
btn.setAttribute('aria-label', title)
btn.innerHTML = logo // static, trusted brand SVG — no user data
btn.append(el('span', 'proj-launch-cap', label))
if (active && opts?.count !== undefined && opts.count > 1) {
btn.append(el('span', 'proj-launch-badge', String(opts.count)))
}
btn.addEventListener('click', (e) => {
e.stopPropagation()
onClick()
})
return btn
}
/** Count running Claude sessions in a project. A session counts as "Claude" once
* Claude Code hooks report a status (working/waiting/idle); plain shells and
* codex sessions stay 'unknown'. Used to highlight the Claude launcher. */
function activeClaudeCount(project: ProjectInfo): number {
return project.sessions.filter((s) => !s.exited && s.status !== 'unknown').length
}
/** Launcher row for a card: Claude · Codex (new terminal sessions) · VS Code (host editor). */
function makeLauncherRow(project: ProjectInfo, hooks: ProjectsHooks): HTMLElement {
const claude = activeClaudeCount(project)
const claudeTitle =
claude > 0
? `${claude} active Claude session${claude > 1 ? 's' : ''} in ${project.name} — start another`
: `Start Claude in ${project.name}`
const row = el('div', 'proj-launchers')
row.append(
makeLauncher(
CLAUDE_LOGO,
'Claude',
'proj-launch-claude',
claudeTitle,
() => hooks.onOpenProject(project.path, project.name, 'claude\r'),
{ active: claude > 0, count: claude },
),
makeLauncher(CODEX_LOGO, 'Codex', 'proj-launch-codex', `Start Codex in ${project.name}`, () =>
hooks.onOpenProject(project.path, project.name, 'codex\r'),
),
makeLauncher(VSCODE_LOGO, 'Code', 'proj-launch-vscode', `Open ${project.name} in VS Code`, () =>
void openProjectInEditor(project.path),
),
)
return row
}
export function makeProjectCard(
project: ProjectInfo,
favs: ReadonlySet<string>,
hooks: ProjectsHooks,
@@ -212,27 +294,19 @@ function makeProjectCard(
card.append(meta)
}
// ── Sessions area (1:N) ───────────────────────────────────────────────────
// ── Sessions area (1:N) — list any running sessions above the launchers ─────
const runningSessions = project.sessions.filter((s) => !s.exited)
if (runningSessions.length === 0) {
// 0 running sessions: single primary "start claude here" action
const newBtn = el('button', 'proj-new proj-new-primary', ' start claude here')
newBtn.addEventListener('click', () => hooks.onOpenProject(project.path, project.name))
card.append(newBtn)
} else {
// 1+ running sessions: list session rows + always-present " New" button
if (runningSessions.length > 0) {
const sessionsList = el('div', 'proj-sessions-list')
for (const sess of runningSessions) {
sessionsList.append(makeSessionRow(sess, hooks.onEnterSession))
}
card.append(sessionsList)
const newBtn = el('button', 'proj-new', ' New')
newBtn.addEventListener('click', () => hooks.onOpenProject(project.path, project.name))
card.append(newBtn)
}
// ── Launchers (Claude · Codex · VS Code) — always available ─────────────────
card.append(makeLauncherRow(project, hooks))
return card
}

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 */

View File

@@ -87,7 +87,7 @@ export class TabApp {
}
this.projects = mountProjects(this.paneHost, {
onOpenProject: (repoPath, repoName) => this.openProject(repoPath, repoName),
onOpenProject: (repoPath, repoName, cmd) => this.openProject(repoPath, repoName, cmd),
onEnterSession: (id) => this.openSession(id),
})