/** * public/projects.ts — Projects panel for the home screen (v0.6). * * Renders a grid of discovered git repositories fetched from GET /projects. * Each card shows: repo name, branch chip, dirty indicator, last-active time, * and running sessions (1:N). Supports live search and per-project favourites * persisted to localStorage. * * Pure helpers (filterProjects, sortProjects, getFavs, saveFavs, toggleFav) * are exported for unit testing without DOM. mountProjects is the thin wiring. * * Timer lifecycle mirrors launcher.ts: auto-refresh every 5 s while visible, * stopped + cleaned up on hide. */ import type { ProjectInfo, ProjectSessionRef, ClaudeStatus, ProjectDetail, WorktreeInfo, CreateWorktreeResult, FanoutLaunchOpts, PermissionMode, SyncState, WorktreeState, } from '../src/types.js' import { el, relTime, statusText } from './preview-grid.js' import { slugify, FANOUT_MAX_LANES } from './fanout.js' import { loadPrefs, savePrefs } from './prefs.js' import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js' import { mountDiffViewer, type DiffViewerHandle } from './diff.js' import { mountPrChip, type PrChipHandle } from './gh-chip.js' import { mountGitLog, type GitLogHandle } from './git-log.js' import { mountTimeline, type TimelineHandle } from './timeline.js' /* ── Constants ───────────────────────────────────────────────────────────── */ const FAV_KEY = 'web-terminal:proj-favs' const REFRESH_MS = 5000 /* ── Public contract (consumed by P6 — tabs.ts wiring) ──────────────────── */ export interface ProjectsHooks { /** 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 /** W5: fan ONE prompt across N branch/agent lanes of this repo (N worktrees, N * Claude sessions on the same prompt, watched in the split-grid board). */ onFanout: (repoPath: string, repoName: string, opts: FanoutLaunchOpts) => void } export interface ProjectsPanel { setVisible(v: boolean): void refresh(): void } /* ── Pure helpers (exported for unit tests) ──────────────────────────────── */ /** * Filter projects by name or path substring (case-insensitive). * Returns a new array; never mutates the input. */ export function filterProjects(projects: readonly ProjectInfo[], query: string): ProjectInfo[] { const trimmed = query.trim() if (!trimmed) return [...projects] const lower = trimmed.toLowerCase() return projects.filter( (p) => p.name.toLowerCase().includes(lower) || p.path.toLowerCase().includes(lower), ) } /** * Sort projects: favourites first, then by lastActiveMs descending. * Returns a new array; never mutates the input. */ export function sortProjects( projects: readonly ProjectInfo[], favs: ReadonlySet, ): ProjectInfo[] { return [...projects].sort((a, b) => { const aFav = favs.has(a.path) ? 0 : 1 const bFav = favs.has(b.path) ? 0 : 1 if (aFav !== bFav) return aFav - bFav return (b.lastActiveMs ?? 0) - (a.lastActiveMs ?? 0) }) } /* ── Namespace grouping (v0.6) — turn the flat grid into collapsible sections ── */ /** Sentinel group keys (can't collide with a real `First.Second` namespace). */ export const ACTIVE_GROUP_KEY = 'active' export const OTHER_GROUP_KEY = 'other' /** A namespace needs at least this many members to earn its own section. */ const MIN_GROUP_SIZE = 2 export type ProjectGroupKind = 'active' | 'namespace' | 'other' | 'flat' export interface ProjectGroup { key: string // stable id for collapse-state persistence label: string // header text kind: ProjectGroupKind projects: ProjectInfo[] // already sorted (favs-first, then recency) activeCount: number // projects with a running session (for the header badge) } /** First two dot-segments of a repo name (the namespace), or undefined if none. */ function namespaceKey(name: string): string | undefined { const segs = name.split('.') if (segs.length < 2) return undefined return segs.slice(0, 2).join('.') } /** The card label inside a group: the tail after the namespace prefix so the * repeated `Billo.Platform.` isn't shouted on every card. Full name elsewhere. */ export function displayLabel(name: string, groupKey: string): string { if (groupKey === ACTIVE_GROUP_KEY || groupKey === OTHER_GROUP_KEY) return name const prefix = `${groupKey}.` return name.toLowerCase().startsWith(prefix.toLowerCase()) ? name.slice(prefix.length) : name } function hasRunningSession(p: ProjectInfo): boolean { return p.sessions.some((s) => !s.exited) } function buildGroup( key: string, label: string, kind: ProjectGroupKind, items: readonly ProjectInfo[], favs: ReadonlySet, ): ProjectGroup { return { key, label, kind, projects: sortProjects(items, favs), activeCount: items.filter(hasRunningSession).length, } } function maxLastActive(items: readonly ProjectInfo[]): number { return items.reduce((m, p) => Math.max(m, p.lastActiveMs ?? 0), 0) } /** * Group projects into collapsible sections by dotted-namespace prefix. * - Projects with a running session are duplicated into a pinned "Active now". * - Namespaces with fewer than MIN_GROUP_SIZE members fall into "Other". * - No real namespace groups → a single 'flat' group (render with no chrome), * so a user whose repos share no prefix keeps today's plain grid. * Never mutates the input. */ export function groupProjects( projects: readonly ProjectInfo[], favs: ReadonlySet, ): ProjectGroup[] { const buckets = new Map() const noNamespace: ProjectInfo[] = [] for (const p of projects) { const ns = namespaceKey(p.name) if (ns === undefined) { noNamespace.push(p) continue } const lowerKey = ns.toLowerCase() const bucket = buckets.get(lowerKey) if (bucket) bucket.items.push(p) else buckets.set(lowerKey, { display: ns, items: [p] }) } const other: ProjectInfo[] = [...noNamespace] const namespaceGroups: ProjectGroup[] = [] for (const { display, items } of buckets.values()) { if (items.length < MIN_GROUP_SIZE) other.push(...items) else namespaceGroups.push(buildGroup(display, display, 'namespace', items, favs)) } // Grouping bought nothing — fall back to a single flat, header-less grid. if (namespaceGroups.length === 0) { return [buildGroup(OTHER_GROUP_KEY, 'All projects', 'flat', projects, favs)] } // Namespace sections ordered by their most-recently-active member. namespaceGroups.sort((a, b) => { const diff = maxLastActive(b.projects) - maxLastActive(a.projects) return diff !== 0 ? diff : a.label.localeCompare(b.label) }) const groups: ProjectGroup[] = [] const active = projects.filter(hasRunningSession) if (active.length > 0) { groups.push(buildGroup(ACTIVE_GROUP_KEY, 'Active now', 'active', active, favs)) } groups.push(...namespaceGroups) if (other.length > 0) { groups.push(buildGroup(OTHER_GROUP_KEY, 'Other', 'other', other, favs)) } return groups } /** Load persisted favourite project paths from localStorage. */ export function getFavs(): Set { try { const raw = localStorage.getItem(FAV_KEY) if (!raw) return new Set() const parsed: unknown = JSON.parse(raw) // Never trust persisted data: keep only string entries (corrupt/tampered // localStorage could otherwise poison the Set used for path matching). if (!Array.isArray(parsed)) return new Set() return new Set(parsed.filter((x): x is string => typeof x === 'string')) } catch { return new Set() } } /** Persist a set of favourite project paths to localStorage. */ export function saveFavs(favs: ReadonlySet): void { try { localStorage.setItem(FAV_KEY, JSON.stringify([...favs])) } catch { // localStorage unavailable — run without persistence } } /** * Immutable toggle: add if absent, remove if present. * Returns a new Set; never mutates the original. */ export function toggleFav(favs: ReadonlySet, path: string): Set { const next = new Set(favs) if (next.has(path)) { next.delete(path) } else { next.add(path) } return next } /* ── Fetch ───────────────────────────────────────────────────────────────── */ /** Coerce one untrusted /projects element into a safe ProjectInfo, or null. * Guarantees `sessions` is always an array so card rendering never throws on a * malformed response (never trust external data — API responses included). */ export function normalizeProject(p: unknown): ProjectInfo | null { if (p === null || typeof p !== 'object') return null const o = p as Record if (typeof o['name'] !== 'string' || typeof o['path'] !== 'string') return null const sessions = Array.isArray(o['sessions']) ? (o['sessions'] as ProjectSessionRef[]) : [] return { name: o['name'], path: o['path'], isGit: o['isGit'] === true, ...(typeof o['branch'] === 'string' ? { branch: o['branch'] } : {}), ...(typeof o['dirty'] === 'boolean' ? { dirty: o['dirty'] } : {}), ...(typeof o['lastActiveMs'] === 'number' ? { lastActiveMs: o['lastActiveMs'] } : {}), // W3(a) sync chip fields — drop non-numbers (never trust the API shape). ...(typeof o['ahead'] === 'number' ? { ahead: o['ahead'] } : {}), ...(typeof o['behind'] === 'number' ? { behind: o['behind'] } : {}), ...(typeof o['lastCommitMs'] === 'number' ? { lastCommitMs: o['lastCommitMs'] } : {}), sessions, } } async function fetchProjects(): Promise { try { const res = await fetch('/projects') const data: unknown = await res.json() if (!Array.isArray(data)) return [] return data.map(normalizeProject).filter((x): x is ProjectInfo => x !== null) } catch { return [] } } /** Fetch one project's detail (branch/worktrees + sessions). null on any error. */ async function fetchProjectDetail(projectPath: string): Promise { try { const res = await fetch(`/projects/detail?path=${encodeURIComponent(projectPath)}`) if (!res.ok) return null return (await res.json()) as ProjectDetail } catch { return null } } /** Kill a running session (manage-page DELETE; same-origin → Origin guard passes). */ async function killSession(id: string): Promise { try { await fetch(`/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }) } catch { // best-effort — the refresh that follows will reflect the real state } } /* ── W4 worktree remove / prune (same-origin → Origin guard passes) ─────────── */ interface WtActionResult { ok: boolean status?: number error?: string } /** POST /projects/worktree — create one worktree on a new branch. Never throws; * a network failure surfaces as { ok:false } so the caller can show an error. * Shared (DRY) by renderNewWorktreeForm and TabApp.launchFanout (W5). */ export async function createWorktreeReq( repoPath: string, branch: string, ): Promise { try { const res = await fetch('/projects/worktree', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: repoPath, branch }), }) let data: unknown try { data = await res.json() } catch { data = null } const d = (data ?? {}) as Record return { ok: res.ok && d['ok'] === true, status: res.status, ...(typeof d['path'] === 'string' ? { path: d['path'] } : {}), ...(typeof d['branch'] === 'string' ? { branch: d['branch'] } : {}), ...(typeof d['error'] === 'string' ? { error: d['error'] } : {}), } } catch { return { ok: false, error: 'Failed to create worktree' } } } /** DELETE /projects/worktree — remove one linked worktree. Never throws; a * network failure surfaces as { ok:false } so the caller can show an error. */ export async function removeWorktreeReq( repoPath: string, worktreePath: string, force: boolean, ): Promise { try { const res = await fetch('/projects/worktree', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: repoPath, worktreePath, force }), }) let data: unknown try { data = await res.json() } catch { data = null } const d = (data ?? {}) as Record return { ok: res.ok && d['ok'] === true, status: res.status, error: typeof d['error'] === 'string' ? d['error'] : undefined, } } catch { return { ok: false, error: 'Failed to remove worktree' } } } /** POST /projects/worktree/prune — reclaim stale worktrees. Never throws. */ export async function pruneWorktreesReq(repoPath: string): Promise { try { const res = await fetch('/projects/worktree/prune', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: repoPath }), }) let data: unknown try { data = await res.json() } catch { data = null } const d = (data ?? {}) as Record return { ok: res.ok && d['ok'] === true, status: res.status, error: typeof d['error'] === 'string' ? d['error'] : undefined, } } catch { return { ok: false, error: 'Failed to prune worktrees' } } } /** w6/G7: GET /projects/worktree/state — one worktree's branch/sync/dirty. * null on any error; a row that cannot be probed simply shows no chips, which * is the honest rendering of "unknown". */ export async function fetchWorktreeState(worktreePath: string): Promise { try { const res = await fetch(`/projects/worktree/state?path=${encodeURIComponent(worktreePath)}`) if (!res.ok) return null const raw: unknown = await res.json() if (raw === null || typeof raw !== 'object') return null const o = raw as Record if (typeof o['path'] !== 'string') return null return { path: o['path'], ...(typeof o['branch'] === 'string' ? { branch: o['branch'] } : {}), ...(typeof o['dirtyCount'] === 'number' ? { dirtyCount: o['dirtyCount'] } : {}), ...(o['sync'] !== null && typeof o['sync'] === 'object' ? { sync: o['sync'] as SyncState } : {}), } } catch { return null } } /** w6/G3: POST /projects/git/fetch — refresh remote-tracking refs only (never a * pull). The remote is chosen server-side; this body carries the repo and * nothing else on purpose. Never throws. */ export async function fetchRepoReq(repoPath: string): Promise { try { const res = await fetch('/projects/git/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: repoPath }), }) const data = (await res.json().catch(() => ({}))) as { error?: string } if (!res.ok) return { ok: false, error: data.error ?? `Fetch failed (${res.status})` } return { ok: true } } catch { return { ok: false, error: 'Fetch failed' } } } /** Remove-worktree confirm flow (exported so mountProjects and tests share the * real code). Confirms once; on a 409 dirty-tree refusal, confirms a SECOND * time before retrying with force — no single-click data loss. `onError` * receives a safe message (rendered via textContent by the caller, SEC-L3/H6). * Returns 'removed' (caller refreshes), 'cancelled', or 'error'. */ export async function confirmAndRemoveWorktree( repoPath: string, worktreePath: string, onError: (msg: string) => void, ): Promise<'removed' | 'cancelled' | 'error'> { if (!window.confirm(`Remove worktree at ${worktreePath}? This deletes the working tree.`)) { return 'cancelled' } const first = await removeWorktreeReq(repoPath, worktreePath, false) if (first.ok) return 'removed' if (first.status === 409) { if (!window.confirm('Uncommitted changes will be lost. Force-remove?')) return 'cancelled' const forced = await removeWorktreeReq(repoPath, worktreePath, true) if (forced.ok) return 'removed' onError(forced.error ?? 'Failed to remove worktree') return 'error' } onError(first.error ?? 'Failed to remove worktree') return 'error' } /** Prune confirm flow (exported; shared by mountProjects and tests). Returns * 'pruned' (caller refreshes), 'cancelled', or 'error'. */ export async function confirmAndPruneWorktrees( repoPath: string, onError: (msg: string) => void, ): Promise<'pruned' | 'cancelled' | 'error'> { if (!window.confirm('Prune worktrees whose folders are gone?')) return 'cancelled' const res = await pruneWorktreesReq(repoPath) if (res.ok) return 'pruned' onError(res.error ?? 'Failed to prune worktrees') return 'error' } /* ── Status helpers ──────────────────────────────────────────────────────── */ function sessionDotClass(status: ClaudeStatus): string { return `proj-session-dot proj-dot-${status}` } /* ── Card sub-elements ───────────────────────────────────────────────────── */ function makeSessionRow( sess: { id: string; title?: string; status: ClaudeStatus; clientCount: number }, onEnterSession: (id: string) => void, onKill?: (id: string) => void, ): HTMLElement { const row = el('div', 'proj-session-row') row.setAttribute('role', 'button') row.tabIndex = 0 const dot = el('span', sessionDotClass(sess.status)) const title = el('span', 'proj-session-title', sess.title ?? 'claude') const badge = el('span', 'proj-session-badge', `👁 ${sess.clientCount}`) row.append(dot, title, badge) if (onKill) { const kill = el('button', 'proj-session-kill', '✕') kill.title = 'Kill this session' kill.setAttribute('aria-label', 'Kill session') // Don't let the kill button trigger the row's enter handler. kill.addEventListener('pointerdown', (e) => e.stopPropagation()) kill.addEventListener('click', (e) => { e.stopPropagation() onKill(sess.id) }) row.append(kill) } row.addEventListener('click', () => onEnterSession(sess.id)) row.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onEnterSession(sess.id) } }) return row } /** Ask the host to open this project in the editor (POST /open-in-editor → `code `). */ async function openProjectInEditor(repoPath: string): Promise { 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 } /** W3(a) sync chip: `↑ahead ↓behind` vs upstream, with the last-commit time as a * tooltip. Returns null when the repo is in sync (ahead=behind=0/undefined) so * the chip only appears when there is drift worth showing. */ export function makeSyncChip(project: ProjectInfo): HTMLElement | null { const ahead = project.ahead ?? 0 const behind = project.behind ?? 0 if (ahead <= 0 && behind <= 0) return null const parts: string[] = [] if (ahead > 0) parts.push(`↑${ahead}`) if (behind > 0) parts.push(`↓${behind}`) const chip = el('span', 'proj-sync', parts.join(' ')) const tips: string[] = [] if (ahead > 0) tips.push(`${ahead} ahead of upstream`) if (behind > 0) tips.push(`${behind} behind upstream`) if (project.lastCommitMs !== undefined) tips.push(`last commit ${relTime(project.lastCommitMs)} ago`) chip.title = tips.join(' · ') return chip } /** w6/G3: how old FETCH_HEAD may be before `behind` stops being trustworthy. */ export const FETCH_STALE_MS = 60 * 60 * 1000 export interface SyncBandOpts { readonly nowMs?: number readonly onFetch?: () => void } /** * w6/G3 — the project detail header's git state, as a band of chips. * * The hard rule this function exists to enforce: **only one state may read as * "you're fine"** — even with upstream, and a fetch newer than FETCH_STALE_MS. * `behind` is derived from `@{u}`, a locally cached ref that only a fetch moves, * so a stale `behind: 0` is a guess, not a fact, and is marked as such. `ahead` * never depends on a fetch and is therefore never marked stale. * * Returns null for a non-git project (nothing truthful to say). */ export function makeSyncBand( sync: SyncState | undefined, opts: SyncBandOpts = {}, ): HTMLElement | null { if (sync === undefined) return null const band = el('div', 'proj-syncband') const nowMs = opts.nowMs ?? Date.now() const isStale = sync.lastFetchMs === undefined || nowMs - sync.lastFetchMs > FETCH_STALE_MS const canFetch = sync.detached !== true /** One labelled column: caption, the value row, and an optional footnote. * `absorbsShrink` marks the cell carrying the long sentence — it is the one * allowed to give up width so the band stays a single row down to 720px * instead of wrapping the Fetch button onto a ragged second line. */ const cell = ( caption: string, absorbsShrink = false, ): { root: HTMLElement; value: HTMLElement } => { const root = el('div', `proj-sync-cell${absorbsShrink ? ' proj-sync-cell-flex' : ''}`) root.append(el('span', 'proj-sync-k', caption)) const value = el('span', 'proj-sync-v') root.append(value) band.append(root) return { root, value } } const footnote = (root: HTMLElement, text: string, bad = false): void => { root.append(el('span', `proj-sync-sub${bad ? ' proj-sync-sub-bad' : ''}`, text)) } if (sync.detached === true) { // With no branch name the short sha IS the answer to "where am I" — the cell // used to hold only the warning chip, which said what was wrong and nothing // about where you were. const { value } = cell('Head', true) if (sync.head !== undefined && sync.head !== '') { value.append(el('span', 'proj-sync-upstream', sync.head)) } value.append(el('span', 'proj-sync-chip proj-sync-detached', 'detached HEAD')) } else if (sync.upstream === undefined) { // Nothing to compare against — which is NOT "nothing to push". Say so in // words as well as colour, because the absence of numbers is the part a // reader is most likely to misread as "fine". const { root, value } = cell('Upstream', true) value.append(el('span', 'proj-sync-chip proj-sync-noupstream', 'no upstream')) footnote(root, 'This branch tracks nothing — there is no ahead/behind to report') } else { const upstream = cell('Upstream') upstream.value.append(el('span', 'proj-sync-upstream', sync.upstream)) // `undefined` is NOT zero — it means the count could not be computed. Folding // the two together with `?? 0` is what let a repo whose counts failed to // resolve render a green "in sync", which is the one thing the design forbids. const aheadKnown = sync.ahead !== undefined const behindKnown = sync.behind !== undefined const inSync = sync.ahead === 0 && sync.behind === 0 && !isStale const push = cell('To push') push.value.append( el( 'span', `proj-sync-big ${(sync.ahead ?? 0) > 0 ? 'proj-sync-ahead' : 'proj-sync-zero'}`, aheadKnown ? `↑ ${sync.ahead}` : '↑ —', ), ) if (inSync) push.value.append(el('span', 'proj-sync-chip proj-sync-ok', '✓ in sync')) footnote( push.root, !aheadKnown ? 'Could not read the push backlog' : sync.ahead === 0 ? 'Nothing waiting to be pushed' : `${sync.ahead} commit${sync.ahead === 1 ? '' : 's'} only on this machine`, ) const pull = cell('To pull', true) // `ahead` needs only local refs and is always true; `behind` is read off a // cached remote ref, so it is exactly as old as the last fetch. The doubt is // carried by the "unverified" chip and the footnote — never by recolouring // the digit. Being behind is information, not an error, and painting the // largest glyph in the band warning-red made every un-fetched repo look broken. pull.value.append( el( 'span', `proj-sync-big ${(sync.behind ?? 0) > 0 ? 'proj-sync-behind' : 'proj-sync-zero'}`, behindKnown ? `↓ ${sync.behind}` : '↓ —', ), ) if (isStale || !behindKnown) { pull.value.append(el('span', 'proj-sync-chip proj-sync-stale', 'unverified')) footnote( pull.root, !behindKnown ? 'Could not read the remote-tracking ref — this number is unknown' : sync.lastFetchMs === undefined ? 'Never fetched — this number cannot be trusted' : `Last fetched ${relTime(sync.lastFetchMs)} ago — this number cannot be trusted`, true, ) } else { footnote(pull.root, sync.behind === 0 ? 'Up to date with the remote' : 'Waiting on the remote') } } const actions = el('div', 'proj-sync-cell proj-sync-actions') const fetchBtn = el('button', 'proj-sync-fetch', 'Fetch') as HTMLButtonElement fetchBtn.type = 'button' fetchBtn.disabled = !canFetch fetchBtn.title = canFetch ? 'Refresh remote-tracking refs (does not pull or merge)' : 'Not available on a detached HEAD' if (canFetch && opts.onFetch !== undefined) { fetchBtn.addEventListener('click', () => opts.onFetch?.()) } actions.append(fetchBtn) band.append(actions) return band } export function makeProjectCard( project: ProjectInfo, favs: ReadonlySet, hooks: ProjectsHooks, onToggleFav: (path: string) => void, onOpenDetail?: (path: string) => void, onKillSession?: (id: string) => void, displayName?: string, ): HTMLElement { const card = el('div', 'proj-card') // ── Header ──────────────────────────────────────────────────────────────── const header = el('div', 'proj-card-head') const isFav = favs.has(project.path) const favBtn = el('button', isFav ? 'proj-fav on' : 'proj-fav') favBtn.textContent = '★' favBtn.title = isFav ? 'Remove from favourites' : 'Add to favourites' favBtn.addEventListener('click', (e) => { e.stopPropagation() onToggleFav(project.path) }) // Visible label may be the namespace tail (e.g. "Payment"); launch/detail still // use the full project.name so tab titles and lookups stay unambiguous. const name = el('span', 'proj-name', displayName ?? project.name) name.title = project.name if (onOpenDetail) { name.classList.add('proj-name-link') name.setAttribute('role', 'button') name.tabIndex = 0 name.title = `View ${project.name} details` name.addEventListener('click', () => onOpenDetail(project.path)) name.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onOpenDetail(project.path) } }) } header.append(favBtn, name) if (project.branch) { const branch = el('span', 'proj-branch', project.branch) header.append(branch) } // W3(a): ahead/behind-vs-upstream sync chip (only when there's drift). const syncChip = makeSyncChip(project) if (syncChip !== null) header.append(syncChip) if (project.dirty === true) { const dirty = el('span', 'proj-dirty', '●') dirty.title = 'Uncommitted changes' header.append(dirty) } card.append(header) // ── Meta line ───────────────────────────────────────────────────────────── if (project.lastActiveMs !== undefined) { const meta = el('div', 'proj-meta', `active ${relTime(project.lastActiveMs)} ago`) card.append(meta) } // ── Sessions area (1:N) — list any running sessions above the launchers ───── const runningSessions = project.sessions.filter((s) => !s.exited) if (runningSessions.length > 0) { const sessionsList = el('div', 'proj-sessions-list') for (const sess of runningSessions) { sessionsList.append(makeSessionRow(sess, hooks.onEnterSession, onKillSession)) } card.append(sessionsList) } // ── Launchers (Claude · Codex · VS Code) — always available ───────────────── card.append(makeLauncherRow(project, hooks)) return card } /* ── Detail view (branch / worktrees + active sessions) ─────────────────────── */ /** Actions a worktree row can offer (W4). Present → non-main/non-locked rows get * a Remove (✕) button; a locked row keeps its tag but no button (unlock first). */ export interface WorktreeRowActions { onRemove: (wt: WorktreeInfo) => void /** Open this worktree as its own project. Optional so pre-existing callers * and tests keep compiling; absent ⇒ no button is rendered. */ onOpen?: (wt: WorktreeInfo) => void } /** One worktree row: branch (or detached@head) + main/current/locked tags + path, * plus an optional Remove button (W4) for removable (non-main, non-locked) rows. */ /** * w6/G7 — attribute each live session to the DEEPEST worktree containing its cwd. * * Plain prefix matching is wrong here and would be wrong in a way that reads as * a fact: `.claude/worktrees/` lives INSIDE the main checkout, so every * worktree session would also be counted against the main repo. Returns a map of * worktree path → count; sessions outside every worktree are dropped. */ export function countSessionsByWorktree( sessions: readonly ProjectSessionRef[], worktrees: readonly WorktreeInfo[], ): Map { const counts = new Map() for (const wt of worktrees) counts.set(wt.path, 0) for (const s of sessions) { if (s.exited || typeof s.cwd !== 'string') continue let deepest: string | undefined for (const wt of worktrees) { if (!isInside(s.cwd, wt.path)) continue if (deepest === undefined || wt.path.length > deepest.length) deepest = wt.path } if (deepest !== undefined) counts.set(deepest, (counts.get(deepest) ?? 0) + 1) } return counts } /** `child` is `parent` itself or sits under it — compared on path SEGMENTS so * `/a/repo-old` is never treated as living inside `/a/repo`. */ function isInside(child: string, parent: string): boolean { if (child === parent) return true return child.startsWith(parent.endsWith('/') ? parent : `${parent}/`) } export interface WorktreeRowState { /** Sync state for THIS worktree — the project's own checkout knows its state * for free, other rows get theirs from the G7 probe. Still optional: an * unprobed row must show nothing rather than a guess. */ readonly sync?: SyncState readonly dirtyCount?: number /** w6/G7: live sessions whose cwd sits inside this worktree. */ readonly sessionCount?: number } export function makeWorktreeRow( wt: WorktreeInfo, actions?: WorktreeRowActions, state?: WorktreeRowState, ): HTMLElement { const row = el('div', 'proj-wt-row') if (wt.isCurrent) row.classList.add('proj-wt-row-current') // Two lines: identity + state on top, path underneath. On one line the path // competed with the chips for width and whichever lost got truncated. const main = el('div', 'proj-wt-main') const top = el('div', 'proj-wt-top') row.append(main) main.append(top) const label = wt.branch ?? (wt.head ? `detached @ ${wt.head}` : 'detached') top.append(el('span', 'proj-wt-branch', label)) if (wt.isMain) top.append(el('span', 'proj-wt-tag', 'main')) if (wt.isCurrent) top.append(el('span', 'proj-wt-tag proj-wt-tag-current', 'current')) if (wt.locked) top.append(el('span', 'proj-wt-tag', 'locked')) if (wt.prunable) top.append(el('span', 'proj-wt-tag', 'prunable')) // w6/G5: the same vocabulary as the header band, so "no upstream" reads the // same everywhere. A brand-new worktree branch tracks nothing, which is the // common case here — it must say so rather than look settled. if (state?.sync !== undefined) { const sync = state.sync if (sync.detached === true) { top.append(el('span', 'proj-sync-chip proj-sync-detached', 'detached')) } else if (sync.upstream === undefined) { top.append(el('span', 'proj-sync-chip proj-sync-noupstream', 'no upstream')) } else if ((sync.ahead ?? 0) > 0) { top.append(el('span', 'proj-sync-chip proj-sync-ahead', `↑ ${sync.ahead}`)) } } // "Clean" is a probed fact and has to look different from "not probed yet" — // an empty row read as settled when nothing had actually been checked. if (state !== undefined && (state.dirtyCount ?? 0) > 0) { top.append(el('span', 'proj-sync-chip proj-sync-dirty', `● ${state.dirtyCount}`)) } else if (state !== undefined && state.dirtyCount === 0) { top.append(el('span', 'proj-sync-chip proj-sync-ok', '✓ clean')) } main.append(el('div', 'proj-wt-path', wt.path)) const right = el('div', 'proj-wt-right') row.append(right) if ((state?.sessionCount ?? 0) > 0) { const count = state?.sessionCount ?? 0 // The status dot is drawn by CSS (::before) so it is a real dot, not a glyph // that inherits the text colour and baseline. const chip = el('span', 'proj-wt-sessions', `${count} session${count === 1 ? '' : 's'}`) chip.title = `${count} running session(s) in this worktree` right.append(chip) } if (actions?.onOpen !== undefined) { const open = el('button', 'proj-wt-open', 'Open') open.title = 'Open this worktree as a project' open.addEventListener('click', () => actions.onOpen?.(wt)) right.append(open) } // W4: only linked, unlocked worktrees are removable. The main worktree (the // repo itself) and locked worktrees never get a button — matches the server's // 400/409 refusals so the UI can't invite an action git will reject. if (actions !== undefined && !wt.isMain && wt.locked !== true) { const remove = el('button', 'proj-wt-remove', '✕') remove.title = 'Remove this worktree' remove.setAttribute('aria-label', 'Remove worktree') remove.addEventListener('click', () => actions.onRemove(wt)) right.append(remove) } return row } /** One detailed session row: status + title + metadata + Open / Kill actions. */ function makeDetailSessionRow( s: ProjectSessionRef, onEnter: (id: string) => void, onKill: (id: string) => void, ): HTMLElement { const row = el('div', 'proj-dsession') const dot = el('span', sessionDotClass(s.status)) const main = el('div', 'proj-dsession-main') main.append(el('span', 'proj-dsession-title', s.title ?? 'session')) const parts: string[] = [] if (s.status !== 'unknown') parts.push(statusText(s.status)) parts.push(`👁 ${s.clientCount}`) parts.push(`started ${relTime(s.createdAt)} ago`) main.append(el('span', 'proj-dsession-meta', parts.join(' · '))) const open = el('button', 'proj-dsession-open', 'Open') open.addEventListener('click', () => onEnter(s.id)) const kill = el('button', 'proj-dsession-kill', '✕') kill.title = 'Kill this session' kill.setAttribute('aria-label', 'Kill session') kill.addEventListener('click', () => onKill(s.id)) row.append(dot, main, open, kill) return row } /** Returns null if `branch` is a valid git branch name, or an error message (B3). Never throws. */ export function validateBranchNameClient(branch: string): string | null { if (!branch) return 'Branch name cannot be empty' if (branch.length > 250) return 'Branch name is too long (max 250 characters)' if (/[\x00-\x20\x7f]/.test(branch)) return 'Branch name cannot contain spaces or control characters' if (branch.startsWith('-')) return 'Branch name cannot start with a hyphen' if (branch.includes('..')) return 'Branch name cannot contain ".."' if (branch.endsWith('.lock')) return 'Branch name cannot end with ".lock"' if (/[~^:?*\[\\]/.test(branch)) return 'Branch name contains invalid characters' if (branch.includes('@{')) return 'Branch name cannot contain "@{"' if (branch.startsWith('/') || branch.endsWith('/') || branch.includes('//')) { return 'Branch name has invalid slash usage' } return null } /** Render a "New Worktree" form (B3). Errors via textContent only (SEC-L3/H6). */ export function renderNewWorktreeForm(detail: ProjectDetail, hooks: ProjectsHooks): HTMLElement { const form = el('div', 'proj-wt-form') const input = document.createElement('input') input.type = 'text' input.className = 'proj-wt-branch-input' input.placeholder = 'New branch name' input.setAttribute('aria-label', 'New branch name') input.maxLength = 250 const errorEl = el('div', 'proj-wt-error') errorEl.style.display = 'none' const submitBtn = el('button', 'proj-wt-submit', 'Create Worktree') form.append(input, errorEl, submitBtn) function showError(msg: string): void { errorEl.textContent = msg // SEC-L3/H6: textContent, never innerHTML errorEl.style.display = '' submitBtn.disabled = false } function hideError(): void { errorEl.textContent = ''; errorEl.style.display = 'none' } let busy = false async function doCreate(): Promise { if (busy) return const branch = input.value.trim() const err = validateBranchNameClient(branch) if (err !== null) { showError(err); return } hideError() busy = true submitBtn.disabled = true try { const result = await createWorktreeReq(detail.path, branch) if (!result.ok) { showError(result.error ?? 'Failed to create worktree') return } hooks.onOpenProject(result.path ?? detail.path, result.branch ?? branch, 'claude\r') } finally { busy = false submitBtn.disabled = false } } submitBtn.addEventListener('click', () => { void doCreate() }) input.addEventListener('keydown', (e) => { if (e.key === 'Enter') void doCreate() }) input.addEventListener('input', () => { if (errorEl.style.display !== 'none') hideError() }) return form } /** Permission modes offered in the fan-out form (the high-risk 'auto' is left out; * it is gated server-side and TabApp.launchFanout downgrades it anyway). */ const FANOUT_FORM_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan'] /** * Render the "Fan out" form (W5) — a sibling of renderNewWorktreeForm. Fans ONE * prompt across N lanes: prompt textarea, lane-count stepper (2..maxLanes), * branch-base input (auto-filled from the prompt slug until edited), a permission- * mode select, and a submit that calls hooks.onFanout. Errors render via * textContent only (SEC-L3/H6). `maxLanes` mirrors the server MAX_FANOUT_LANES. */ export function renderFanoutForm( detail: ProjectDetail, hooks: ProjectsHooks, maxLanes: number = FANOUT_MAX_LANES, ): HTMLElement { const cap = Math.max(2, Math.floor(maxLanes)) const form = el('div', 'proj-fanout-form') const prompt = document.createElement('textarea') prompt.className = 'proj-fanout-prompt' prompt.placeholder = 'One task, fanned across N lanes (e.g. "add dark mode toggle")' prompt.setAttribute('aria-label', 'Fan-out task prompt') prompt.rows = 2 prompt.maxLength = 4000 const row = el('div', 'proj-fanout-row') const lanes = document.createElement('input') lanes.type = 'number' lanes.className = 'proj-fanout-lanes' lanes.min = '2' lanes.max = String(cap) lanes.value = String(Math.max(2, Math.min(3, cap))) lanes.setAttribute('aria-label', 'Number of lanes') const branch = document.createElement('input') branch.type = 'text' branch.className = 'proj-fanout-branch' branch.placeholder = 'branch base' branch.setAttribute('aria-label', 'Branch base name') branch.maxLength = 200 const mode = document.createElement('select') mode.className = 'proj-fanout-mode' mode.setAttribute('aria-label', 'Permission mode') for (const m of FANOUT_FORM_MODES) { const opt = document.createElement('option') opt.value = m opt.textContent = m mode.append(opt) } row.append(lanes, branch, mode) const errorEl = el('div', 'proj-fanout-error') errorEl.style.display = 'none' const submitBtn = el('button', 'proj-fanout-submit') as HTMLButtonElement form.append(prompt, row, errorEl, submitBtn) let branchTouched = false function laneCount(): number { const n = Math.floor(Number(lanes.value)) if (!Number.isFinite(n)) return 2 return Math.max(2, Math.min(cap, n)) } function refreshSubmit(): void { const n = laneCount() submitBtn.textContent = `⑃ Fan out ${n} lanes` submitBtn.disabled = prompt.value.trim() === '' } function showError(msg: string): void { errorEl.textContent = msg // SEC-L3/H6: textContent, never innerHTML errorEl.style.display = '' } function hideError(): void { errorEl.textContent = '' errorEl.style.display = 'none' } prompt.addEventListener('input', () => { if (!branchTouched) branch.value = slugify(prompt.value) if (errorEl.style.display !== 'none') hideError() refreshSubmit() }) branch.addEventListener('input', () => { branchTouched = true }) lanes.addEventListener('input', refreshSubmit) submitBtn.addEventListener('click', () => { const text = prompt.value.trim() if (text === '') { showError('Enter a task prompt to fan out.') return } const branchBase = (branch.value.trim() || slugify(text)).trim() const branchErr = validateBranchNameClient(branchBase) if (branchErr !== null) { showError(branchErr) return } hideError() hooks.onFanout(detail.path, detail.name, { prompt: text, lanes: laneCount(), branchBase, mode: mode.value as PermissionMode, }) }) refreshSubmit() return form } /** Build the "View Diff" toggle + inline panel (B1). `diffRef.h` tracks the open * handle. `bases` (branch names) feed the FR-B1.9 "compare against base" picker. */ function buildDiffSection( repoPath: string, diffRef: { h: DiffViewerHandle | null }, bases: string[], ): HTMLElement { const section = el('div', 'proj-diff-section') const toggle = el('button', 'proj-diff-toggle', 'View Diff') const panel = el('div', 'proj-diff-panel') panel.style.display = 'none' toggle.addEventListener('click', () => { if (panel.style.display === 'none') { panel.style.display = '' toggle.textContent = 'Hide Diff' diffRef.h = mountDiffViewer(panel, repoPath, { bases, onClose: () => { panel.style.display = 'none' toggle.textContent = 'View Diff' diffRef.h?.destroy() diffRef.h = null }, }) } else { diffRef.h?.destroy() diffRef.h = null panel.style.display = 'none' toggle.textContent = 'View Diff' } }) section.append(toggle, panel) return section } /** Build the "Activity" section: one timeline panel per running session (A4). */ function buildActivitySection( running: ProjectSessionRef[], localTimelines: TimelineHandle[], collector: TimelineHandle[] | undefined, ): HTMLElement | null { if (running.length === 0) return null const section = el('div', 'proj-activity-section') for (const sess of running) { const block = el('div', 'proj-activity-session') block.append(el('div', 'proj-activity-label', sess.title ?? 'session')) const tlContainer = el('div', 'proj-tl-container') block.append(tlContainer) const handle = mountTimeline(tlContainer, sess.id) localTimelines.push(handle) if (collector !== undefined) collector.push(handle) section.append(block) } return section } interface DetailCallbacks { onBack: () => void onKill: (id: string) => void // W4 (optional so pre-W4 callers/tests keep compiling): when provided, // removable worktree rows gain a ✕ button and, if any worktree is prunable, // the Worktrees header gains a "Prune stale worktrees" button. onRemoveWorktree?: (worktreePath: string) => void onPruneWorktrees?: () => void // w6/G3: refresh remote-tracking refs so the header's `behind` stops being a // stale guess. Optional for the same reason as the W4 pair above. onFetch?: () => void // w6/G7: per-worktree state already probed by mountProjects, keyed by worktree // path. Absent entries render no chips — unknown is shown as nothing, not zero. worktreeStates?: ReadonlyMap /** Open a worktree as its own project detail. */ onOpenWorktree?: (worktreePath: string) => void } /** Render the project detail view (exported for unit tests). `timelineCollector` receives A4 handles for re-render disposal. */ export function renderProjectDetail( detail: ProjectDetail | null, hooks: ProjectsHooks, cb: DetailCallbacks, timelineCollector?: TimelineHandle[], ): HTMLElement { const root = el('div', 'proj-detail-inner') const localTimelines: TimelineHandle[] = [] const diffRef: { h: DiffViewerHandle | null } = { h: null } const prRef: { h: PrChipHandle | null } = { h: null } const logRef: { h: GitLogHandle | null } = { h: null } const back = el('button', 'proj-back', '← All projects') back.addEventListener('click', () => { diffRef.h?.destroy() diffRef.h = null prRef.h?.destroy() prRef.h = null logRef.h?.destroy() logRef.h = null for (const handle of localTimelines) handle.dispose() cb.onBack() }) root.append(back) if (detail === null) { root.append(el('div', 'proj-empty', 'Project not found.')) return root } // Header: name + branch + dirty + path const head = el('div', 'proj-detail-head') head.append(el('h2', 'proj-detail-name', detail.name)) if (detail.branch) head.append(el('span', 'proj-branch', detail.branch)) // w6/G3: the sync band carries the dirty count, so the bare dot is only a // fallback for the pre-G3 shape (no sync ⇒ non-git, or an older server). const syncBand = makeSyncBand(detail.sync, { ...(cb.onFetch !== undefined ? { onFetch: cb.onFetch } : {}), }) // The count belongs on the title line beside the branch, where the eye already // is — not inside the sync band, which is about the remote, not the tree. if (detail.dirtyCount !== undefined && detail.dirtyCount > 0) { const d = el('span', 'proj-sync-chip proj-sync-dirty', `● ${detail.dirtyCount} uncommitted`) d.title = `${detail.dirtyCount} uncommitted change(s)` head.append(d) } else if (detail.dirtyCount === undefined && detail.dirty === true) { // Older server, or dirty-check on without a count — keep the bare dot. const d = el('span', 'proj-dirty', '●') d.title = 'Uncommitted changes' head.append(d) } // W3: PR + CI status chip (git repos only). Read-only side-channel via `gh`; // self-degrades (gh missing / unauthed / no PR) so it never blocks the header. if (detail.isGit) { const prHost = el('span', 'proj-pr-host') head.append(prHost) prRef.h = mountPrChip(prHost, detail.path) } root.append(head) root.append(el('div', 'proj-detail-path', detail.path)) if (syncBand !== null) root.append(syncBand) // B1: View Diff (git repos only) — SEC-H4 enforced inside mountDiffViewer. // FR-B1.9: offer this repo's worktree branches + current branch as diff bases. if (detail.isGit) { const bases = Array.from( new Set( [...detail.worktrees.map((w) => w.branch), detail.branch].filter( (b): b is string => typeof b === 'string' && b !== '', ), ), ) // Every other block on this page is introduced by a heading; without one the // toggle hung off the bottom edge of the sync band and read as part of it. root.append(el('div', 'proj-section-title', 'Changes')) root.append(buildDiffSection(detail.path, diffRef, bases)) } // W3(d): Recent commits (git repos only) — read-only, inert-text rows. if (detail.isGit) { const logLabel = el('div', 'proj-section-title', 'Recent commits') root.append(logLabel) const logHost = el('div', 'proj-commitlog') root.append(logHost) logRef.h = mountGitLog(logHost, detail.path, logLabel) } // Branch / worktrees — header carries a "Prune stale worktrees" button (W4) // when any worktree is prunable and a prune callback is wired. const wtHead = el('div', 'proj-section-row') // w6/G5: always "Worktrees". With one worktree per session the count is the // point, and a heading that renames itself at n=2 hides that this is a list. const wtTitle = el('div', 'proj-section-title', 'Worktrees') if (detail.worktrees.length > 0) { // A separate element, not "(2)" glued into the heading string: the count is // data and takes the lighter, un-tracked treatment the design gives it. wtTitle.append(el('span', 'proj-section-count', String(detail.worktrees.length))) } wtHead.append(wtTitle) if (cb.onPruneWorktrees !== undefined && detail.worktrees.some((w) => w.prunable === true)) { const pruneBtn = el('button', 'proj-wt-prune', 'Prune stale worktrees') pruneBtn.title = 'Remove worktrees whose folders are gone' pruneBtn.addEventListener('click', () => cb.onPruneWorktrees?.()) wtHead.append(pruneBtn) } root.append(wtHead) // W4: a shared, initially-hidden error line for remove/prune failures. Written // via textContent only by mountProjects (SEC-L3/H6); persists until the next // refresh rebuilds this detail view. const wtError = el('div', 'proj-wt-actions-error') wtError.style.display = 'none' root.append(wtError) if (!detail.isGit) { root.append(el('div', 'proj-empty', 'Not a git repository.')) } else if (detail.worktrees.length === 0) { root.append( el('div', 'proj-empty', detail.branch ? `On branch ${detail.branch}` : 'No worktree info.'), ) } else { const wtActions: WorktreeRowActions | undefined = cb.onRemoveWorktree !== undefined ? { onRemove: (wt) => cb.onRemoveWorktree?.(wt.path), // A linked worktree IS a project directory, so "open" is just the // detail view pointed at its path — no new concept, no new route. ...(cb.onOpenWorktree !== undefined ? { onOpen: (wt: WorktreeInfo) => cb.onOpenWorktree?.(wt.path) } : {}), } : undefined const list = el('div', 'proj-wt-list') const sessionCounts = countSessionsByWorktree(detail.sessions, detail.worktrees) for (const wt of detail.worktrees) { // The current worktree's state came free with the detail; every other row // waits for its G7 probe and shows nothing until it lands. const probed = cb.worktreeStates?.get(wt.path) const own = wt.isCurrent === true ? { ...(detail.sync !== undefined ? { sync: detail.sync } : {}), ...(detail.dirtyCount !== undefined ? { dirtyCount: detail.dirtyCount } : {}), } : {} const state: WorktreeRowState = { ...(probed?.sync !== undefined ? { sync: probed.sync } : {}), ...(probed?.dirtyCount !== undefined ? { dirtyCount: probed.dirtyCount } : {}), ...own, sessionCount: sessionCounts.get(wt.path) ?? 0, } list.append(makeWorktreeRow(wt, wtActions, state)) } root.append(list) } // B3: New Worktree form (git repos only) if (detail.isGit) { root.append(el('div', 'proj-section-title', 'New Worktree')) root.append(renderNewWorktreeForm(detail, hooks)) } // W5: Fan-out form (git repos only) — one prompt across N branch/agent lanes. if (detail.isGit) { root.append(el('div', 'proj-section-title', 'Fan out')) root.append(renderFanoutForm(detail, hooks)) } // Active sessions const running = detail.sessions.filter((s) => !s.exited) const sessTitle = el('div', 'proj-section-title', 'Active sessions') sessTitle.append(el('span', 'proj-section-count', String(running.length))) root.append(sessTitle) if (running.length === 0) { root.append(el('div', 'proj-empty', 'No running sessions — start one below.')) } else { const list = el('div', 'proj-detail-sessions') for (const s of running) list.append(makeDetailSessionRow(s, hooks.onEnterSession, cb.onKill)) root.append(list) } // A4: Activity section — one timeline per running session, disposed on back/re-render const activityEl = buildActivitySection(running, localTimelines, timelineCollector) if (activityEl !== null) { root.append(el('div', 'proj-section-title', 'Activity')) root.append(activityEl) } // CLAUDE.md const cmHead = el('div', 'proj-section-row') cmHead.append(el('div', 'proj-section-title', 'CLAUDE.md')) const cmBtn = el('button', 'proj-claudemd-btn', detail.hasClaudeMd ? '↻ Update' : '✨ Generate') cmBtn.title = detail.hasClaudeMd ? 'Open a Claude session running /init to refresh CLAUDE.md' : 'Open a Claude session running /init to create CLAUDE.md' cmBtn.addEventListener('click', () => hooks.onOpenProject(detail.path, detail.name, 'claude "/init"\r')) cmHead.append(cmBtn) root.append(cmHead) if (detail.hasClaudeMd && detail.claudeMd !== undefined) { const pre = el('pre', 'proj-claudemd') pre.textContent = detail.claudeMd root.append(pre) } else { root.append( el('div', 'proj-empty', 'No CLAUDE.md yet — generate one to give Claude project-specific instructions.'), ) } // Launchers root.append(el('div', 'proj-section-title', 'Start')) root.append(makeLauncherRow(detail, hooks)) return root } /* ── Group header (collapsible namespace section) ───────────────────────────── */ /** One section header: caret + label + count + optional "N active" badge. * "Active now" is not collapsible (always shown — it's the point). */ function makeGroupHeader( group: ProjectGroup, isCollapsed: boolean, collapsible: boolean, onToggle: () => void, ): HTMLElement { const head = el('div', `proj-group-head proj-group-${group.kind}`) const caret = el('span', 'proj-group-caret') caret.textContent = collapsible ? (isCollapsed ? '▶' : '▼') : '●' head.append(caret) head.append(el('span', 'proj-group-label', group.label)) head.append(el('span', 'proj-group-count', String(group.projects.length))) // On namespace/other headers, surface hidden running work so a collapsed // section never silently buries an active session. if (group.kind !== 'active' && group.activeCount > 0) { head.append(el('span', 'proj-group-active', `● ${group.activeCount} active`)) } if (collapsible) { head.setAttribute('role', 'button') head.tabIndex = 0 head.setAttribute('aria-expanded', String(!isCollapsed)) head.title = isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}` head.addEventListener('click', onToggle) head.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onToggle() } }) } return head } /* ── Mount ───────────────────────────────────────────────────────────────── */ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): ProjectsPanel { const root = el('div', 'proj-panel') root.style.display = 'none' host.appendChild(root) // Search box const searchWrap = el('div', 'proj-search-wrap') const searchInput = document.createElement('input') searchInput.className = 'proj-search' searchInput.type = 'search' searchInput.placeholder = 'Filter projects…' searchInput.setAttribute('aria-label', 'Filter projects') searchWrap.append(searchInput) root.append(searchWrap) const grid = el('div', 'proj-grid') root.append(grid) const detailEl = el('div', 'proj-detail') detailEl.style.display = 'none' root.append(detailEl) let allProjects: ProjectInfo[] = [] let favs: Set = new Set() // Namespace group collapse-state (true = collapsed); loaded from server prefs. let collapsed: Record = {} let prefsLoaded = false let timer: ReturnType | null = null let view: 'grid' | 'detail' = 'grid' let detailPath: string | null = null // A4: tracks timeline handles from the current detail render for re-render disposal let detailTimelineHandles: TimelineHandle[] = [] /** Persist favourites + collapse-state to the server (best-effort, cross-device). */ function persist(): void { void savePrefs({ favourites: [...favs], collapsed }) } function onToggleFav(path: string): void { favs = toggleFav(favs, path) persist() renderGrid() } function toggleCollapsed(key: string): void { if (collapsed[key] === true) { const next = { ...collapsed } delete next[key] // store only the "collapsed" fact; expanded is the default collapsed = next } else { collapsed = { ...collapsed, [key]: true } } persist() renderGrid() } function applyView(): void { const isGrid = view === 'grid' searchWrap.style.display = isGrid ? '' : 'none' grid.style.display = isGrid ? '' : 'none' detailEl.style.display = isGrid ? 'none' : 'block' } /** w6/G6: the payload behind the last detail render, so an unchanged 5 s tick * can be dropped. Re-rendering rebuilds the whole subtree, which re-mounts the * commit log and the PR chip — i.e. it re-fetches `/projects/log` (two more * `git` spawns) every 5 s to redraw pixel-identical rows. Skipping also keeps * the diff viewer's scroll position and avoids the flicker. */ let lastDetailJson: string | null = null function openDetail(p: string): void { view = 'detail' detailPath = p // w6/G6: a new project must always paint, even if its payload happens to // serialise identically to the one we rendered last. lastDetailJson = null worktreeStates = new Map() worktreeProbeFor = null applyView() void refresh() } function closeDetail(): void { view = 'grid' detailPath = null lastDetailJson = null applyView() void refresh() } function killAndRefresh(id: string): void { void killSession(id).then(() => void refresh()) } /** Render a remove/prune failure into the detail view's error line (textContent * only, SEC-L3/H6). No-op if the element isn't present (e.g. mid-transition). */ function showWtActionError(msg: string): void { const errEl = detailEl.querySelector('.proj-wt-actions-error') if (errEl instanceof HTMLElement) { errEl.textContent = msg errEl.style.display = '' } } /** W4: confirm → (force?) → refresh flow for removing one worktree. */ function removeWorktreeAndRefresh(worktreePath: string): void { if (detailPath === null) return void confirmAndRemoveWorktree(detailPath, worktreePath, showWtActionError).then((outcome) => { if (outcome === 'removed' && view === 'detail') void refresh() }) } /** W4: confirm → prune → refresh flow for stale worktrees. */ function pruneWorktreesAndRefresh(): void { if (detailPath === null) return void confirmAndPruneWorktrees(detailPath, showWtActionError).then((outcome) => { if (outcome === 'pruned' && view === 'detail') void refresh() }) } /** w6/G3: fetch → refresh. In flight, `fetching` blocks a second click so a * slow network can't queue a pile of fetches behind one button. On failure * the detail is still re-rendered: lastFetchMs is unchanged, so the band * correctly keeps showing "stale" rather than pretending it refreshed. */ let fetching = false function fetchAndRefresh(): void { if (detailPath === null || fetching) return fetching = true void fetchRepoReq(detailPath) .then((res) => { if (!res.ok) showWtActionError(res.error ?? 'Fetch failed') if (view === 'detail') void refresh() }) .finally(() => { fetching = false }) } /** The card grid for one group's projects (tail labels inside namespace groups). */ function renderCards(group: ProjectGroup): HTMLElement { const cards = el('div', 'proj-grid-cards') for (const p of group.projects) { cards.append( makeProjectCard( p, favs, hooks, onToggleFav, openDetail, killAndRefresh, displayLabel(p.name, group.key), ), ) } return cards } function renderGrid(): void { const query = searchInput.value const filtered = filterProjects(allProjects, query) const searching = query.trim() !== '' // Preserve scroll across the 5 s auto-refresh rebuild. const prevScroll = root.scrollTop grid.replaceChildren() if (filtered.length === 0) { const msg = allProjects.length === 0 ? 'No projects found. Check PROJECT_ROOTS configuration.' : 'No projects match your search.' grid.append(el('div', 'proj-empty', msg)) return } for (const group of groupProjects(filtered, favs)) { if (group.kind === 'flat') { grid.append(renderCards(group)) // no header — plain grid fallback continue } const collapsible = group.kind === 'namespace' || group.kind === 'other' // While searching, force every matching section open so results are never // hidden behind a collapsed caret. const isCollapsed = collapsible && !searching && collapsed[group.key] === true const section = el('div', `proj-group${isCollapsed ? ' collapsed' : ''}`) section.append( makeGroupHeader(group, isCollapsed, collapsible, () => toggleCollapsed(group.key)), ) if (!isCollapsed) section.append(renderCards(group)) grid.append(section) } root.scrollTop = prevScroll } function renderDetail(detail: ProjectDetail | null): void { // Dispose timelines from the previous render before replacing DOM (A4 re-render) for (const h of detailTimelineHandles) h.dispose() detailTimelineHandles = [] detailEl.replaceChildren( renderProjectDetail( detail, hooks, { onBack: closeDetail, onKill: (id) => { void killSession(id).then(() => { if (view === 'detail') void refresh() }) }, onRemoveWorktree: removeWorktreeAndRefresh, onPruneWorktrees: pruneWorktreesAndRefresh, onFetch: fetchAndRefresh, onOpenWorktree: openDetail, worktreeStates, }, detailTimelineHandles, ), ) } /** w6/G7: probed worktree state, keyed by worktree path. Filled ONCE per opened * project rather than on every 5 s tick — one probe costs two `git` spawns per * worktree, and a row's branch/ahead only moves when the user does something, * which lands here anyway via the explicit refresh after fetch/create/remove. */ let worktreeStates = new Map() let worktreeProbeFor: string | null = null async function probeWorktrees(detail: ProjectDetail): Promise { // The current worktree already reported itself inside `detail` — never spend // a probe on it. const targets = detail.worktrees.filter((w) => w.isCurrent !== true).map((w) => w.path) if (targets.length === 0) return const probed = await Promise.all(targets.map((wt) => fetchWorktreeState(wt))) // A different project may have been opened while these were in flight. if (detailPath !== detail.path) return const next = new Map(worktreeStates) for (const state of probed) { if (state !== null) next.set(state.path, state) } worktreeStates = next lastDetailJson = null // force one re-render so the new chips appear if (view === 'detail') void refresh() } async function refresh(): Promise { if (view === 'detail' && detailPath !== null) { const detail = await fetchProjectDetail(detailPath) const json = JSON.stringify(detail) if (json === lastDetailJson) return lastDetailJson = json renderDetail(detail) if (detail !== null && worktreeProbeFor !== detail.path) { worktreeProbeFor = detail.path void probeWorktrees(detail) } return } allProjects = await fetchProjects() renderGrid() } /** Load cross-device prefs once, then refresh. Prefs stay in memory afterward * (the 5 s tick only re-fetches projects, so it never clobbers local edits). */ async function init(): Promise { if (!prefsLoaded) { const prefs = await loadPrefs() favs = new Set(prefs.favourites) collapsed = { ...prefs.collapsed } prefsLoaded = true } await refresh() } searchInput.addEventListener('input', () => renderGrid()) return { setVisible(v: boolean): void { root.style.display = v ? 'block' : 'none' if (v) { // Re-entering the chooser always lands on the grid (never a stale detail). view = 'grid' detailPath = null applyView() void init() if (timer === null) { timer = setInterval(() => { // w6/G6: a backgrounded tab (or a phone with the screen off) kept // spawning `git` on the host every 5 s for a view nobody could see. if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return if (root.style.display !== 'none') void refresh() }, REFRESH_MS) } } else { if (timer !== null) { clearInterval(timer) timer = null } } }, refresh(): void { void refresh() }, } }