feat(v0.6): Project Manager — repo discovery + Projects panel + tab wiring

Home screen gains a Sessions↔Projects segmented control. The Projects view
discovers the host's git repos and, on click, opens a tab named after the
repo, spawned in the repo dir, auto-running `claude` (1:N sessions per project).

Backend (P2/P4):
- src/http/projects.ts — parseGitHead + buildProjects(cfg, liveSessions):
  depth-capped BFS for .git (skips node_modules/dotdirs/symlinks, stops
  descending at a repo), .git/HEAD branch parse, rate-limited `git status`
  dirty check (execFile, no shell, 2s timeout, concurrency 8), merge of
  ~/.claude/projects cwds (reuses history.listSessions), cwd-prefix session
  merge (fresh each call), TTL discovery cache with in-flight dedup.
- src/server.ts — read-only GET /projects (no Origin guard, []-fallback).

Frontend (P5/P6):
- public/projects.ts — mountProjects: 1:N cards (branch chip, dirty dot,
  session rows, "+ start claude here"), live search, localStorage favourites;
  pure helpers extracted for unit tests.
- public/tabs.ts — openProject (#n suffix for dup names, sends 'claude\r'),
  countOpenWithTitlePrefix, Sessions↔Projects home toggle (updateHomeView).
- public/style.css — Projects panel + .home-seg control styles.

Config (P3, hardened): PROJECT_ROOTS/SCAN_DEPTH/SCAN_TTL/DIRTY_CHECK already
added; this commit adds fail-fast rejection of relative PROJECT_ROOTS.

Review hardening (4 confirmed HIGH + boundary validation):
- carriage-return fix so claude auto-executes (A3); seg-control z-order;
  parallelised history merge; concurrent-cache-miss dedup.
- normalizeProject guards the /projects response; getFavs filters non-strings.

Tests: +57 (398 total). Backend src/http/projects.ts 93.4%, config.ts 98%.
Verified: both typechecks clean, full suite green, build:web OK, coverage
≥80×4, real-browser smoke (83 repos, branch/dirty correct, click→claude tab).
This commit is contained in:
Yaojia Wang
2026-06-30 11:04:49 +02:00
parent dc5d073374
commit 7b4adf5072
12 changed files with 2060 additions and 35 deletions

View File

@@ -14,14 +14,23 @@
*
* Closing a tab only disconnects its WS (a *detach*); the server PTY keeps
* running and is reclaimed by IDLE_TTL.
*
* v0.6 (P6): the home screen has a segmented control ("Sessions" / "Projects")
* that switches between the session launcher and the projects panel. State is
* persisted to localStorage. openProject() spawns a new claude session in a
* repo directory, labelled with the repo name (deduped with #N suffixes).
*/
import { TerminalSession } from './terminal-session.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
import { mountLauncher, type Launcher } from './launcher.js'
import { mountProjects, type ProjectsPanel } from './projects.js'
const TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active'
const HOME_VIEW_KEY = 'web-terminal:home-view'
type HomeView = 'sessions' | 'projects'
interface TabEntry {
session: TerminalSession
@@ -47,7 +56,9 @@ export class TabApp {
private readonly tabBar: HTMLElement
private readonly approvalBar: HTMLDivElement
private readonly launcher: Launcher
private launcherVisible = false
private readonly projects: ProjectsPanel
private homeView: HomeView = 'sessions'
private readonly segControl: HTMLElement
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
this.paneHost = paneHost
@@ -56,25 +67,100 @@ export class TabApp {
this.approvalBar.id = 'approvalbar'
this.approvalBar.style.display = 'none'
document.body.appendChild(this.approvalBar)
this.launcher = mountLauncher(this.paneHost, {
onOpen: (id) => this.openSession(id),
onNew: () => this.newTab(),
})
// v0.5: do NOT auto-create or auto-restore tabs. Land on the launcher (the
// session chooser); the user picks which session to open. Sessions persist
// server-side, so opening one replays its full scrollback.
// Load persisted home view choice.
try {
const stored = localStorage.getItem(HOME_VIEW_KEY)
if (stored === 'sessions' || stored === 'projects') this.homeView = stored
} catch {
// localStorage unavailable — use default
}
this.projects = mountProjects(this.paneHost, {
onOpenProject: (repoPath, repoName) => this.openProject(repoPath, repoName),
onEnterSession: (id) => this.openSession(id),
})
this.segControl = this.buildSegControl()
this.paneHost.appendChild(this.segControl)
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
// the user picks which session to open. Sessions persist server-side, so
// opening one replays its full scrollback.
this.rebuild()
this.updateLauncher()
this.updateHomeView()
}
/** Show the launcher (session chooser) iff no tab is open. */
private updateLauncher(): void {
/* ── Home-view visibility ────────────────────────────────────────── */
/**
* Controls visibility of the segmented control and both home sub-panels.
* When tabs are open: hide everything (the terminal panes own the screen).
* When no tab is open: show the seg control and the selected sub-panel.
*/
private updateHomeView(): void {
const empty = this.tabs.length === 0
if (empty === this.launcherVisible) return
this.launcherVisible = empty
this.launcher.setVisible(empty)
// Sync active-class on segmented control buttons.
const btns = this.segControl.querySelectorAll<HTMLButtonElement>('.home-seg-btn')
btns.forEach((btn, i) => {
const view: HomeView = i === 0 ? 'sessions' : 'projects'
btn.classList.toggle('active', view === this.homeView)
})
if (empty) {
this.segControl.style.display = 'flex'
this.launcher.setVisible(this.homeView === 'sessions')
this.projects.setVisible(this.homeView === 'projects')
} else {
this.segControl.style.display = 'none'
this.launcher.setVisible(false)
this.projects.setVisible(false)
}
}
private saveHomeView(): void {
try {
localStorage.setItem(HOME_VIEW_KEY, this.homeView)
} catch {
// localStorage unavailable
}
}
private buildSegControl(): HTMLElement {
const seg = document.createElement('div')
seg.className = 'home-seg'
seg.style.display = 'none' // updateHomeView() will show it when needed
const sessBtn = document.createElement('button')
sessBtn.className = 'home-seg-btn'
sessBtn.textContent = 'Sessions'
sessBtn.addEventListener('click', () => {
this.homeView = 'sessions'
this.saveHomeView()
this.updateHomeView()
})
const projBtn = document.createElement('button')
projBtn.className = 'home-seg-btn'
projBtn.textContent = 'Projects'
projBtn.addEventListener('click', () => {
this.homeView = 'projects'
this.saveHomeView()
this.updateHomeView()
})
seg.append(sessBtn, projBtn)
return seg
}
/* ── Approval bar ───────────────────────────────────────────────── */
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
private updateApprovalBar(): void {
const session = this.tabs[this.activeIndex]?.session
@@ -108,7 +194,7 @@ export class TabApp {
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
}
/* ── persistence ─────────────────────────────────────────────── */
/* ── persistence ─────────────────────────────────────────────────── */
private persist(): void {
try {
@@ -138,7 +224,7 @@ export class TabApp {
this.activate(this.tabs.length - 1)
}
/* ── tab lifecycle ───────────────────────────────────────────── */
/* ── tab lifecycle ───────────────────────────────────────────────── */
private addEntry(
sessionId: string | null,
@@ -193,12 +279,34 @@ export class TabApp {
/** O2: open a new tab in `cwd` and run `claude --resume <id>`. */
newTabForResume(cwd: string, sessionId: string): void {
this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}`)
this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}\r`)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
/**
* v0.6 (P6): spawn a new claude session in `repoPath`, labelled `repoName`.
* If tabs with the same base name already exist, appends a #N suffix to
* distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in
* structure: addEntry → persist → rebuild → activate.
*/
openProject(repoPath: string, repoName: string, cmd = 'claude\r'): void {
const n = this.countOpenWithTitlePrefix(repoName)
const label = n === 0 ? repoName : `${repoName} #${n + 1}`
this.addEntry(null, label, repoPath || undefined, cmd)
this.persist()
this.rebuild()
this.activate(this.tabs.length - 1)
}
/** Count tabs whose customTitle is exactly `name` or starts with `name + ' #'`. */
private countOpenWithTitlePrefix(name: string): number {
return this.tabs.filter(
(t) => t.customTitle === name || t.customTitle?.startsWith(`${name} #`) === true,
).length
}
activate(i: number): void {
if (i < 0 || i >= this.tabs.length) return
this.maybeAskNotify() // first switch is a user gesture — request notif permission
@@ -217,10 +325,10 @@ export class TabApp {
entry?.session.dispose()
if (this.editingIndex === i) this.editingIndex = -1
if (this.tabs.length === 0) {
// v0.5: closing the last tab returns to the launcher — no auto-blank tab.
// v0.5: closing the last tab returns to the home screen — no auto-blank tab.
this.activeIndex = -1
this.persist()
this.rebuild() // updateLauncher() (in rebuild) shows the chooser
this.rebuild() // updateHomeView() (in rebuild) shows the chooser
this.updateApprovalBar()
return
}
@@ -323,7 +431,7 @@ export class TabApp {
new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' })
}
/* ── rendering ───────────────────────────────────────────────── */
/* ── rendering ───────────────────────────────────────────────────── */
/** In-place update of one tab's classes/label/dot — never destroys the DOM. */
private refreshTab(entry: TabEntry): void {
@@ -453,7 +561,7 @@ export class TabApp {
add.addEventListener('click', () => this.newTab())
this.tabBar.appendChild(add)
// Show/hide the launcher based on whether any tab is open.
this.updateLauncher()
// Show/hide the home screen based on whether any tab is open.
this.updateHomeView()
}
}