Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.
- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
"grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
+ caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
+ extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
One-click merge deferred (winner session stays open for a manual merge).
Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
1407 lines
50 KiB
TypeScript
1407 lines
50 KiB
TypeScript
/**
|
||
* 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,
|
||
} 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 = ' |