Files
web-terminal/public/projects.ts
Yaojia Wang dba83559d3 fix(projects): close the last four git-panel design gaps
The low-severity leftovers from the panel audit, plus the tooling trap that made
reviewing this work harder than it needed to be.

.gitattributes (new) — `public/projects.ts diff`
  That file embeds literal NUL bytes on purpose: the namespace sentinels
  '\0active' / '\0other' are prefixed so they cannot collide with a real
  `First.Second` key. git therefore classified it as binary and printed
  "Binary files differ" for every diff, and `git diff --stat` reported
  "Bin 66815 -> 68701 bytes" instead of line counts — the file was effectively
  unreviewable. The `diff` attribute makes git diff it as text without setting
  `text`, so no line endings are normalised and the bytes on disk are untouched.
  (Same trap in the shell: grep prints only "Binary file ... matches", which
  reads as a no-match. The comment in the file says to use grep -a.)

Detached HEAD now shows its short sha
  The Head cell held the "detached HEAD" chip and nothing else: it said what was
  wrong and nothing about where you were. SyncState gains an optional `head`,
  populated from the .git/HEAD text readSyncState ALREADY reads for the detached
  test — so this costs no extra I/O and no extra git call. Guarded by a hex regex
  and rendered as textContent.

One geometry for the ghost button role
  The design defines two button roles. The outline one had drifted to three
  different sizes (.proj-wt-open at 11.5px/4px 12px/6px, .proj-diff-toggle at
  12.5px/7px 15px/7px) and both used full-strength --text instead of the muted
  label. All four outline buttons are now declared once, together, at the
  design's geometry; the duplicate .proj-diff-toggle block further down the file
  is gone, since a second block at equal specificity is exactly how the role
  drifted in the first place.

Header branch chip joins the chip language
  .proj-branch was the only chip with no border and no mono face, and 1px tighter
  all round, so the detail header's branch did not match the branch chips in the
  worktree list right below it.

"Changes" heading over the View Diff toggle
  Every other block on the page is introduced by a section heading. Without one
  the toggle hung 4px off the bottom of the sync band and read as part of it.

Tests: 2167 unit (4 new — detached sha present/absent on both the server and the
panel) and 27 integration, twice.
2026-07-30 07:57:00 +02:00

1815 lines
68 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string>,
): 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<string>,
): 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<string>,
): ProjectGroup[] {
const buckets = new Map<string, { display: string; items: ProjectInfo[] }>()
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<string> {
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<string> 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<string>): 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<string>, path: string): Set<string> {
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<string, unknown>
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<ProjectInfo[]> {
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<ProjectDetail | null> {
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<void> {
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<CreateWorktreeResult> {
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<string, unknown>
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<WtActionResult> {
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<string, unknown>
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<WtActionResult> {
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<string, unknown>
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<WorktreeState | null> {
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<string, unknown>
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<WtActionResult> {
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 <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
}
/** 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<string>,
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/<name>` 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<string, number> {
const counts = new Map<string, number>()
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<void> {
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<string, WorktreeState>
/** 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<string> = new Set()
// Namespace group collapse-state (true = collapsed); loaded from server prefs.
let collapsed: Record<string, boolean> = {}
let prefsLoaded = false
let timer: ReturnType<typeof setInterval> | 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<string, WorktreeState>()
let worktreeProbeFor: string | null = null
async function probeWorktrees(detail: ProjectDetail): Promise<void> {
// 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<void> {
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<void> {
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()
},
}
}