margin-left:auto was correct for the old single-line row, where it pushed the
path to the far right of a flex row. The two-line card stacks branch over path
in a column, so the same declaration shoved the path to the opposite edge from
the branch it labels. Removed, with a note saying why it must not come back.
Same shape as the .proj-wt-row duplicate earlier in this series: an element
reused in a new layout kept a positioning declaration that only made sense in
the old one. Caught by screenshot; the DOM assertions could not see it.
Two things, both invisible in code review and obvious on screen.
1. Eight controls in the project detail had class names but no CSS rule at
all, so they rendered as raw user-agent widgets — white input boxes and grey
buttons sitting inside a dark panel: View Diff, the new-branch input, Create
Worktree, and the five fan-out fields. Gave them the skin the panel already
established, with two button roles: filled accent for the control that
commits a form (matching Fetch), outline for one that only reveals or
toggles (matching Open). Layout stays in each element's existing rule; this
block sets only the shared skin, so no property is declared twice.
2. The w6 CSS referenced --fg, --fg-dim, --fg-faint, --bg-sunk and --mono,
none of which exist in :root. Every one of those 19 references was silently
falling through to its hardcoded fallback, which happened to match the
default amber theme — so it looked correct while being wired to nothing.
Under any other theme the sync band would have kept the default colours
while the rest of the app changed. Rewired to the real tokens: --text,
--text-dim, --text-faint, --surface-1, --on-accent, and the mono stack the
file already uses.
The fallback values are what hid this: a var() fallback turns a typo into a
silent success. Worth remembering when reaching for one.
Verified: tsc clean, npm test green (78 files / 2161, e2e 27), bundle rebuilt.
Two layout defects the screenshots caught that the DOM tests could not.
1. `.proj-wt-row` was declared twice — my two-line-card rule earlier in the
file, the original flex rule later. Same specificity, so the later copy won
and the Open button sat against the text instead of at the card's right
edge. Folded the grid into the ORIGINAL rule and left a note where the
duplicate was. One rule per selector; a second declaration of a layout
property is a silent override waiting to happen.
2. The unpushed count rendered as its own line under "Recent commits". The
design has it ON the heading, because it qualifies that heading rather than
making a separate statement. renderGitLog now takes the heading element and
writes the badge there, clearing any previous badge first so a re-render
cannot stack duplicates.
Both were invisible to the existing assertions: they checked that the elements
exist and carry the right text, which was true in each case. Added a test for
the no-duplicate-badge path; the right-edge alignment is a pure CSS outcome and
is verified by screenshot, not by the suite.
The shipped panel was functionally right — every number matched git — but it
was not the approved design. I implemented from the plan's semantics (which
state is green, when to flag stale) and let the mock's layout drift, and
nothing caught it: the tests asserted behaviour, and no assertion covered
structure. Three deviations, now closed against docs/mockups/project-detail-git.html.
1. The sync band was compressed into one row of chips. Restored to the four
labelled cells: Upstream / To push / To pull / Fetch, with display-size
numerals and a footnote under each count. The footnotes are load-bearing,
not decoration — "Last fetched 19d ago — this number cannot be trusted" is
what tells a reader WHY the zero is suspect. Compressed to
"fetched 19d ago", the causal link was left for the reader to infer, and
not having to infer it is the entire point of the panel. The unverified
state is a chip on the count again, so the doubt attaches to the digit.
2. The working-tree count sat inside the band. Moved to the title line beside
the branch, where the design puts it: the band is about the remote, and the
dirty count is not. Reads "● 3 uncommitted" instead of a bare dot; the dot
remains as the fallback when a server sends no count.
3. Worktree rows were the old single line with the path right-aligned against
the chips. Rebuilt as the two-line card the design calls for — identity and
state on top, path underneath — plus the Open button, which was specified
and simply missing, so a worktree row could be read but not entered. Open
reuses the detail view pointed at the worktree path: a linked worktree is a
project directory, so this needs no new concept and no new route.
Also added the unpushed count beside the commit-list heading, so the number is
readable before the eye walks the rows hunting for rails.
Tests now assert structure, not just behaviour: cell captions exist, the stale
footnote says why, counts render at display size, the path is outside the chip
row, and Open fires with its worktree. That is the gap that let the drift
through, so it is the gap that is now covered.
Verified: tsc and build clean; npm test green (unit 78 files / 2159, e2e 27).
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.
One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:
- `ahead` needs only local refs and is never flagged.
- `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
- Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
"I checked, ignore this"; getting it wrong is lying to the user.
- No upstream (the normal state of a fresh worktree branch) leaves ahead and
behind undefined — it renders an explicit `no upstream`, never the green
path. That fall-through is the easiest bug to ship here.
What landed:
G1 SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
and .dirtyCount. All additive and optional — the Android and iOS clients
decode these shapes. The ahead/behind helper already existed for the list
view; buildProjectDetail had simply never called it.
Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
directly, so it returned nothing inside a LINKED worktree, where .git is a
file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
the shared common dir (FETCH_HEAD).
G2 POST /projects/git/fetch. Same discipline as push: the remote is derived
server-side and no remote or refspec is ever read from the body, so a
client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
working tree, no index, no merge; it is not a pull. Own rate-limit bucket
so refreshes cannot eat the budget a real push needs. On failure
lastFetchMs is left alone, so the UI keeps saying "stale" instead of
pretending it refreshed.
G3 makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
dirty count, Fetch button (disabled on a detached HEAD).
G4 The commit list marks unpushed commits and draws the upstream boundary
once, after the last of them. Marking is server-side from `rev-list`,
deliberately NOT "the first N rows": `git log` is date-ordered, so merging
an older branch interleaves unpushed commits BELOW pushed ones, and that
shortcut fails in the dangerous direction — calling an unpushed commit
pushed. A regression test builds exactly that backdated-merge shape.
G5 The worktree section is always "Worktrees (n)" (it used to rename itself
to "Branch" at n=1) and the current row carries its own state chips.
G6 Cost control. The plan called for a .git-mtime cache; that was dropped
during implementation because a fingerprint over HEAD/index/reflog does
NOT move when push updates a remote-tracking ref — the cached `ahead`
would still claim "9 to push" right after a successful push, which is the
exact lie the feature exists to prevent. Replaced with three measures that
cannot go stale: in-flight coalescing (N devices watching one repo cost
one probe, entry dropped as it settles, nothing cached across time),
skipping the re-render when the payload is byte-identical (this also stops
the 5 s re-mount of the commit log, two more git spawns per tick), and
pausing the timer while the document is hidden.
G7 Per-worktree state via GET /projects/worktree/state, kept narrower than
/projects/detail so N rows do not pay for worktree listing and CLAUDE.md
reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
carried no cwd, so sessions could not be attributed to a worktree. Added
it, plus countSessionsByWorktree, which matches DEEPEST-first because
.claude/worktrees/<name> lives INSIDE the main checkout and prefix
matching would count every worktree session against the parent repo too.
Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.
Verified: tsc and build clean; 46 new tests.
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.
The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).
- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
throws). Path containment: every files[] entry realpath-contained under the repo,
argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
single -m argv. Push: current branch only — has-upstream → plain `git push`;
no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
+ isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
(all text via textContent; re-loads the diff on success).
Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
Per-project chip: PR state · N checks passing · mergeable — glance from the phone,
re-engage only when it's red, instead of dropping into a terminal for `gh pr checks`.
- src/http/gh.ts (new): single `gh pr view --json number,state,title,url,isDraft,
mergeable,headRefName,baseRefName,statusCheckRollup` (execFile, no shell, cwd =
isValidGitDir repo, timeout + maxBuffer). summarizeChecks rolls the mixed
CheckRun/StatusContext rollup into {total,passing,failing,pending}. Never throws —
degrades to not-installed (ENOENT) / unauthenticated / no-pr / error / disabled.
Cache keyed by repoPath+branch (reuses projectScanTtlMs) with in-flight dedupe.
- src/types.ts: additive PrStatus/PrAvailability/PrCheckSummary; config GH_ENABLED
(default on) + GH_TIMEOUT_MS (8s).
- GET /projects/pr?path= (read-only, isValidGitDir); public/gh-chip.ts render-only
chip mounted in the project detail header (git repos only).
Read-only, host's own authed gh (same trust as the shell). No untrusted argv (only
the validated cwd); gh stdout/token never logged; the attacker-controllable PR title
is rendered inert via textContent (SEC-H4). Verified: typecheck + build:web clean,
1816 pass (gh tests 118). The 1 red is the known real-PTY ring-buffer timeout flake.
Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants
to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now
renders above the approval bar, on every attached device, riding the same broadcast
+ late-joiner rails as the existing `gate` field.
- src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview —
Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null.
Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines /
200 chars/line / 4KB (security limits, UTF-8-safe byte clamp).
- src/types.ts: additive optional `preview?: ApprovalPreview` on the status
ServerMessage + handleHookEvent (older clients ignore it).
- src/server.ts /hook/permission: derive preview from tool_input, store on the
PendingApproval entry, re-send to late joiners exactly like `gate`.
- manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre>
textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools /
plan gates fall back to today's name-only bar.
Attacker-influenced tool input → rendered via textContent/diff-renderer only,
never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.
The focused grid cell's last terminal row was clipped by the cell border/focus
ring at large/maximized window sizes. Root cause (measured in headless Chromium):
.term-pane is box-sizing:border-box, so getComputedStyle(pane).height — which
xterm's FitAddon reads to compute rows — returns the padding-box height and
double-counts the pane's own padding, packing one extra row that overruns the
border (overflow +6px). Fix: box-sizing:content-box on the grid pane so the height
reports the content box; FitAddon drops the phantom row and the 6px bottom padding
becomes real clearance. Verified overflow +6 → −10px (16px clear) at grid-4/grid-6,
1920×1200 (DPR 1 & 2), unchanged at 1200×800. Single mode untouched.
Resizable splitters: draggable gutters between grid tracks adjust per-layout
column/row fractions (fr units), applied as an inline grid-template and persisted
(web-terminal:grid-splits). The fraction math (adjustSplit — trades delta between
adjacent tracks, clamps to a minimum, conserves the total) is a pure, unit-tested
helper in grid-layout.ts; the drag translates pixel motion into an fr delta.
Saved presets: public/grid-presets.ts — a toolbar dropdown to save the current
layout + its split under a name, re-apply it in one click, or delete it
(web-terminal:grid-presets). Desktop-only, like the layout toggle.
- grid-layout.ts: layoutTracks, defaultSplit, adjustSplit, tracksToTemplate,
load/saveGridSplits, splitForLayout (validates stored shape).
- tabs.ts: renderGutters/beginGutterDrag/setSplit; applyLayout sets the inline
template; gridArrangement()/applyGridPreset() hooks.
- main.ts: mount the presets dropdown. style.css: gutters + presets menu.
- tests: splitter math + persistence + a stubbed drag repro (1.2fr/0.8fr); presets
persistence + dropdown (save/apply/delete/close). typecheck+build clean, 1612 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A quadrant can be flipped to "monitor" mode via a 👁 header toggle: instead of
the live interactive terminal, it renders the session's screen from periodic
GET /live-sessions/:id/preview snapshots into a read-only xterm. A monitored
quadrant never attaches a WS and never sends a resize, so — unlike a live
quadrant (latest-writer-wins) — it does not drive the shared PTY size. That lets
you watch a session in a small quadrant without shrinking it for another device
using it full-screen (the cross-device shrink the split-grid design flagged).
- public/cell-monitor.ts (new): mountCellMonitor(host, id) — polling read-only
preview, scaled to fit, best-effort, disposes cleanly (no write after dispose).
- tabs.ts: per-entry monitor state; applyLayout keeps the live pane hidden and
starts/stops the monitor; 👁 toggle in the cell header; teardown on close /
leaving grid mode.
- style.css: monitor button + .cell-monitor container.
- tests: cell-monitor.test.ts (poll→write, dispose, late-resolve guard) + monitor
wiring in tabs.test.ts. typecheck + build clean, 1587 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the v1 watch board:
- Two more layouts: row-3 (1×3) and grid-6 (2×3). A single `grid` marker class on
#term now carries the shared cell chrome so it no longer enumerates each lay-*.
- Ctrl+` cycles the focused quadrant (Ctrl+Shift+` reverses); matchFocusCycleKey
is an exported pure helper so it's unit-tested. No-op / passthrough in single mode.
- Per-quadrant ⛶ maximize: the focused cell expands to fill the grid as an absolute
overlay while siblings stay live behind it; follows focus, resets on layout
change / tab close.
- Drag a tab from the tab bar onto a quadrant to assign it there (reuses the
existing tab-drag dragIndex); grid-only.
Adversarial review (3 lenses → per-finding verify, incl. a headless-Chrome repro)
caught a HIGH: maximizing via `grid-column/row: 1/-1` shoved siblings into implicit
rows — a strip instead of fullscreen AND a spurious resize to backgrounded live
PTYs. Fixed with an absolute-overlay (`position:absolute; inset:0`), then verified
in real Chromium (Playwright): maximized cell fills #term, siblings 0px size delta.
Also: silence a covered pane's pending pulse under maximize; coarse-pointer target
for .cell-max. Verified: typecheck + build:web clean, 1579 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When several sessions are open on a large screen (≥1024px), the terminal
area can split into 1×2 or 2×2 so multiple LIVE, interactive terminals show
at once — a monitoring convenience for vibe-coding several Claude sessions.
Design keeps activeIndex as the single focused pane, so keybar/voice/approval
routing is unchanged; a new gridLayout + derived visible-set lets several
.term-cell wrappers show together inside a CSS-grid #term. The server and WS
protocol are untouched.
- public/grid-layout.ts (new): layout types/capacity, visibleIndices,
matchMedia desktop gate (GRID_MIN_WIDTH=1024), persistence, toolbar toggle.
- tabs.ts: pane→.term-cell wrapper (header + terminal + inline-approve footer);
applyLayout() owns show/hide + grid class + cell order + placeholders;
board-aware activate() (never focuses a hidden pane); setFocused/setGridLayout
delegate to it; renderCell/renderInlineApprove; refitVisible; notification
suppression for on-screen panes (factoring in the home overlay).
- terminal-session.ts: show({focus}) so non-focused quadrants don't steal
keyboard focus; onFocus callback (capture-phase pointerdown).
- main.ts: mount the toggle; window-focus refit → refitVisible.
- style.css: cell/grid model (.term-pane → relative flex child), focus ring,
pending pulse, inline approve, placeholder, toggle + coarse-pointer targets.
- tests: grid-layout.test.ts + split-grid block in tabs.test.ts (+33).
Adversarial review (4 lenses → per-finding verify) caught and fixed a HIGH:
activate() was not board-aware, so opening a tab on a full grid focused a
hidden pane (typing into an invisible session). Verified: typecheck + build:web
clean, 1566 tests pass, grid-layout 95% / tabs 94% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
viewport-fit=cover spans the physical screen edges; add --safe-t/--safe-b from
env(safe-area-inset-*) threaded through #tabbar/#term/#keybar/#approvalbar/#searchbox/
#settingspanel. Falls back to 0px (zero regression on non-safe-area platforms).
The flat Projects grid was overwhelming at 50-100 repos. Explored the UX with
4 parallel agents (auto-grouping / manual folders / density-segmentation /
competitive research); all converged on namespace auto-grouping as the primary
fix — the dotted repo names (Billo.Platform.*, Billo.Infrastructure.*) already
encode the hierarchy, so it's a string-split, not a taxonomy the user builds.
Frontend:
- groupProjects(): depth-2 namespace grouping, >=2-member threshold (singletons
fall to "Other"), degrade-to-flat when repos share no prefix, running projects
duplicated into a pinned "Active now" section.
- displayLabel(): cards show the namespace tail (Payment, not
Billo.Platform.Payment); full name in Active/Other.
- Collapsible sticky group headers with an "N active" badge so a collapsed
section never hides running work; search force-expands matching groups.
- public/prefs.ts: server-first load with a localStorage offline mirror, migrates
the legacy per-device proj-favs.
Backend (server-side prefs so favourites + collapse-state follow the user across
devices — localStorage was per-device, which broke the multi-device premise):
- src/http/prefs-store.ts: ~/.web-terminal-prefs.json store (mirrors push-store),
every field bounded + sanitized.
- GET /prefs (read-only) + PUT /prefs (Origin-guarded); UiPrefs type +
PREFS_STORE_PATH config.
Tests: +12 prefs-store, +21 groupProjects/displayLabel. Full suite 1227 passed.
Verified live: grouping renders, collapse persists to the server and survives a
full page reload (cross-device sync proven end-to-end).
Give the two tab-bar line-icon buttons the same styled data-tip hover tooltip as
the toolbar icons, so their meaning is clear on hover (native title was slow/ugly):
- timeline → "Activity timeline"
- push toggle → "Notifications off — tap to turn on" / "…on — tap to turn off"
Left-anchored (they sit on the left of the tab bar). 158 push+tabs tests green.
The icons were swapped to lucide line icons, but the buttons kept a boxed/light
background that clashed with the minimal toolbar icons. CSS-only fix: render
.tab-timeline + .push-toggle-btn as transparent, monochrome 18px line-icon
buttons (text-dim, accent on hover/active) — identical to .toolbtn — overriding
the earlier boxed default. Markup unchanged (tests stay green: 158 push+tabs).
The detail page now reads <repo>/CLAUDE.md and shows it in a scrollable
monospace box, with one smart button: "↻ Update" when it exists / "✨ Generate"
when it doesn't. The button opens an interactive Claude session in the repo
running /init (claude "/init") so you review what it writes — reuses the project
launcher, no new backend orchestration, stays a byte-shuttle.
- ProjectDetail += hasClaudeMd / claudeMd; buildProjectDetail reads CLAUDE.md
(truncated 64KB, best-effort, in the existing Promise.all)
- renderProjectDetail: CLAUDE.md section (content box or empty state) + button
- style.css: .proj-section-row / .proj-claudemd-btn / .proj-claudemd
Tests +5 (458): backend read present/absent; frontend content+Update,
empty+Generate, button opens `claude "/init"`. Verified: tsc clean, full suite
green, build OK, curl returns the content, in-browser shows the box + button.
The glossy emoji (🔍⚙▦🕘⌨🔗📱) clashed with the Amber dark theme. Swap them
for clean lucide line icons (MIT, stroke=currentColor) so they take the theme
colour — muted by default, Amber on hover — and add a styled CSS hover tooltip
(data-tip, right-anchored so it never clips the edge) showing what each does.
- public/icons.ts: 7 line icons (search/settings/dashboard/history/keyboard/
share/device), script-generated from lucide
- 7 toolbar modules: innerHTML = ICON_* + data-tip + aria-label
- style.css .toolbtn: 18px svg, --text-dim → --accent on hover, [data-tip] tooltip
Frontend-only. web typecheck + build clean; bundle has 0 toolbar emoji; verified
in-browser: line icons in theme colour, amber + tooltip on hover.
The big solid-amber "+ New session" button clashed with the refined segmented
control. Replace it with a dashed "+" tile that leads the session grid (same
size as the session cards, re-prepended each refresh), so it reads as a natural
"create" cell. Header is now just the title + Sessions/Projects toggle; the
redundant empty-state message is gone (the tile is the CTA).
Frontend-only (launcher.ts + style.css). Verified: web typecheck + build clean,
preview-grid tests green, in-browser the grid leads with the New-session tile
followed by the session cards (Open/Kill).
The detail view could kill sessions, but the grid card's session rows were
enter-only. Add a ✕ kill button to each card session row (stopPropagation so it
doesn't also enter the session); makeSessionRow gains an optional onKill,
makeProjectCard an optional onKillSession, and mountProjects wires it to
DELETE /live-sessions/:id followed by a refresh.
Frontend-only. projects-panel tests +1 (51): ✕ present only with the callback,
click kills the right id without entering. Verified in-browser: a card with 2
sessions dropped to 1 after clicking ✕.
Clicking a project's name opens an in-app detail view (back button returns to
the grid) with:
- header: name, path, branch, dirty
- Branch / Worktrees: each `git worktree list` entry (branch or detached@head,
main/current/locked/prunable tags, path) — a single-worktree repo shows its
branch; a multi-worktree repo shows them all
- Active sessions (N): detailed rows (status, devices watching, started-ago)
with Open + Kill
- the Claude/Codex/VS Code launcher row
Backend: src/http/worktrees.ts (parseWorktrees pure + listWorktrees via
execFile, no shell, best-effort); buildProjectDetail(cfg, path, liveSessions)
in projects.ts (validates absolute existing dir, reuses branch/dirty/session
helpers, uncached — detail is on-demand and wants fresh state); read-only
GET /projects/detail?path= (no Origin guard, like /projects; 400 missing /
404 invalid). Types: WorktreeInfo, ProjectDetail.
Frontend: detail view + navigation in projects.ts (grid<->detail), project
name becomes a link, Kill via DELETE /live-sessions/:id.
Tests +21 (452 total): worktree parser, buildProjectDetail (validation,
non-git, session merge, real git-init worktree), renderProjectDetail.
worktrees.ts 100% / projects.ts 93.8% cov. Verified in-browser: opened a
Claude session, then the detail showed its branch+worktree and the live
session with Open/Kill.
Replace each card's "+ start claude here" with a row of three brand-logo
launcher buttons (logo + caption, touch-friendly, tooltips):
- Claude (coral burst) → new terminal tab running `claude`
- Codex (OpenAI mark) → new terminal tab running `codex`
- VS Code (blue ribbon) → opens the repo in VS Code ON THE HOST
VS Code: new guarded POST /open-in-editor runs `<EDITOR_CMD> <path>` (default
`code`) on the host via execFile (no shell); path validated as an existing
absolute dir; Origin guard (CSRF) like the DELETE routes. EDITOR_CMD config added.
Active-session highlight (user request): when a project has a running Claude
session (Claude-hook status != unknown — plain shells/codex stay unknown), the
Claude logo gets a coral ring + tint, with a count badge when more than one.
New: public/icons.ts (simple-icons SVGs, fill=currentColor), src/http/editor.ts.
onOpenProject gains an optional cmd; makeProjectCard exported for tests.
Tests +16 (431 total): editor validation/launch, EDITOR_CMD config, launcher
row (3 buttons, claude/codex commands), and the active-Claude highlight + badge.
Verified: coverage >=80x4, build OK, both typechecks clean; in-browser the 3
logos render with brand colours, and POST /open-in-editor actually launched
VS Code on the host (403 no-Origin / 400 bad-path / 204 valid-dir all correct).
The toggle sat on its own dedicated row, leaving a large empty band above the
project grid. On desktop (>=769px) float it top-right on the SAME row as the
filter box / launcher header: panels fill from the top (inset 0), launcher-head
reserves right padding for the pill. Mobile keeps the centred stacked toggle to
avoid overlapping the full-width filter on narrow screens. Also reclaim the
hidden key-bar's space on the home view (body.home-open #term { bottom: 0 }).
CSS-only (style.css is served directly). Verified in-browser: toggle and filter
share a row on Projects, no overlap with New/Manage on Sessions.
- Segmented control: replace the two free-standing bordered boxes with a
single rounded track holding two pill buttons (iOS-style), active segment
filled Amber; centred via fit-content + margin auto. Panel top-offset 44→68px.
- Hide the bottom key-bar on the home chooser (it only targets an active
terminal): updateHomeView toggles body.home-open; CSS hides #keybar.
Frontend-only. 415 tests green; both typechecks clean; verified in-browser
(toggle pill renders, keybar hidden on home, reappears when a tab opens).
Once a project/session tab was open, the home chooser (Sessions/Projects
segmented control) was hidden and only reachable by closing every tab — no
way back to the Projects view to open another project.
Add a ⌂ Home button to the tab bar that overlays the chooser on top of the
open tabs (homeForced flag; updateHomeView shows home when no tab OR forced,
hiding the terminal panes). Clicking ⌂ again, clicking a tab (activate resets
homeForced), or opening another session/project dismisses the overlay.
Closing the last tab resets the flag. The button highlights while active.
Tests: +3 (tabs.test.ts). Full suite 415 green; both typechecks clean;
verified in-browser (open tab → ⌂ → Projects panel reachable → ⌂ → terminal).
Prior-session prep for the v0.6 Project Manager, checkpointed on the
v0.6-projects branch as a clean base for the parallel builders:
- P1 (src/types.ts): ProjectInfo + ProjectSessionRef contracts; Config
gains projectRoots/projectScanDepth/projectScanTtlMs/projectDirtyCheck.
- P3 (src/config.ts): parseBool + parseProjectRoots helpers; parse the
4 PROJECT_* env vars with defaults ([homeDir]/4/10000/true).
- UI: public/style.css indigo -> Amber (#e3a64a) accent theme.
- Docs: FEATURE_PROJECT_MANAGER.md (1:N session design), THEME.md,
docs/mockups/ (final-amber.png is the locked visual).
Typecheck (backend + web) green. No behavior change to runtime code yet;
P2/P4/P5/P6 implement the feature on top of this.
Opening the app no longer auto-creates a blank tab or auto-restores/discovers
sessions as tabs. Instead it lands on a launcher (session chooser): a grid of the
host's running sessions as live preview thumbnails — pick one to open (replays its
full scrollback), or '+ New session'. Closing the last tab returns to the chooser.
- public/launcher.ts: mountLauncher() renders the chooser into the terminal area
(live xterm thumbnails via /live-sessions + /preview, 5s refresh), New session +
Manage link
- tabs.ts: drop restore()/syncLiveSessions() auto-tab behavior; constructor lands
on the launcher; rebuild() toggles launcher when tab count is 0; closeTab no
longer spawns a blank tab
- style.css: .launcher chrome (reuses the .mg-* card/grid/thumbnail styles)
Verified: fresh load → 0 tabs, chooser with thumbnails; Open → tab + chooser
hides; close last tab → chooser returns. 228 tests green, tsc + build clean.
The flat session list didn't tell you what each session was doing. Rebuild
/manage.html as a full-page grid where each card renders a LIVE preview
thumbnail — a read-only xterm showing the session's actual current screen,
scaled down like a screenshot — so you can recognize each at a glance.
- RingBuffer.tail(maxBytes): newest scrollback on chunk boundaries (no ANSI/UTF-8
split); GET /live-sessions/:id/preview returns {cols,rows,data} (no attach, so
it doesn't inflate watcher counts or keep sessions alive)
- manage.ts: card grid; per card a scaled read-only Terminal renders CLEAR+tail,
auto-refresh every 4s, click thumbnail/Open to join, Kill / bulk kill
- bundles xterm into manage.js (+manage.css linked in manage.html)
- ring-buffer tail tests
Verified: 3 sessions rendered as recognizable thumbnails (top / git log / ls),
colored, with status/age; click opens. 228 tests green, tsc clean.
The mirror looked broken because a backgrounded tab on one device still pinned
the shared PTY to its (default 80x24) size — so the device actively viewing got
a cramped terminal.
Fix: only an ACTIVELY-VIEWING client votes on PTY size.
- attachWs no longer seeds a default size vote (a join may be a hidden mirror)
- new 'blur' client message + clearClientDims(): a tab going hidden withdraws its
size vote (still mirrors output); show() re-casts it
- PTY size = min cols/rows across clients that have actually reported dims
- session/protocol tests cover hidden-mirror-doesn't-clamp + blur
Session manager (separate page, for the 'too many sessions' problem):
- GET stays; add DELETE /live-sessions/:id and DELETE /live-sessions[?detached=1]
- manager.killById(); LiveSessionInfo gains cols/rows
- public/manage.html + manage.ts: list/open/kill sessions, kill-all / kill-detached,
auto-refresh; 🗂 toolbar button; bundled as a 2nd esbuild entry
Verified: two concurrent clients mirror output + shared input; manage page
lists/kills (3→2→0). 225 tests green, tsc clean.
- tabs.ts: on open, syncLiveSessions() fetches /live-sessions and adds a tab for
each host session not already open → any device sees & mirrors everything
running on the host. Empty storage + no live sessions → one fresh tab.
activeSessionId() + openSession(id) helpers.
- share.ts: 🔗 toolbar button → QR + <origin>/?join=<id> for the active session.
- main.ts: ?join=<id> on load opens/focuses that shared session, then strips the
param. Mount share button (toolbar: 🔍 ⚙ ▦ 🕘 ⌨ 🔗📱).
- style.css: #sharemodal reuses the QR card/backdrop.
Verified in-browser: fresh device auto-discovered the host session and replayed
its scrollback; two concurrent clients (browser + ws) both received live output
(clientCount=2); share modal shows the join URL/QR.
keybar:
- add Ctrl+R (搜历史/\x12), Ctrl+L (重绘/\x0c), Ctrl+D (退出/\x04)
- each key now shows a short function caption under the glyph (self-documenting
on touch where tooltips don't appear); 17 keys total
- test/keybar.test.ts updated to 17 keys
cheat-sheet (new public/shortcuts.ts):
- ⌨ toolbar button opens a grouped reference of common Claude Code shortcuts
(general / editing / multiline / macOS / prefixes), each labeled with what it
does; keys also on the bottom bar are tagged 键栏
- Esc / click-outside to close; styled card with sticky header + accent tags
216 tests green; tsc + build clean.
- src/http/history.ts: listSessions() reads ~/.claude/projects/*/*.jsonl
(mtime-sorted, top 50), parseSessionMeta() extracts cwd + first user prompt
(pure, 4 unit tests); GET /sessions
- terminal-session: opts.initialInput (typed ~700ms after the shell is ready)
- tabs.newTabForResume(cwd,id): new tab in the project dir running
'claude --resume <id>'
- public/history.ts: 🕘 panel listing sessions (project · time · preview) with
Resume buttons
- Verified: /sessions returns 44 real sessions; panel lists project/time/preview.
216 tests green.
- M3: settings.ts (⚙ panel: dark/light/solarized theme + font size A-/A+),
persisted to localStorage; terminal-session.applyTheme; TabApp.applySettings
applies to all + new tabs
- M7: dashboard.ts (▦ overlay listing every tab with connection dot + folder +
Claude status; click a row to focus; live re-render while open);
TabApp.snapshot()/focusTab()
- Browser-verified: light theme renders + persists; dashboard lists tabs and
focuses on click. 208 tests green, typechecks + build ok.
- types/protocol: ClientMessage approve/reject; ServerMessage status.pending
- server: POST /hook/permission HELD until the client decides; approve/reject
ws messages resolve it with {hookSpecificOutput.decision.behavior allow|deny};
5-min timeout + release-on-disconnect fall back to Claude's own prompt
- manager.handleHookEvent threads pending flag
- setup-hooks: PermissionRequest uses the held curl (writes decision to stdout);
status events stay fire-and-forget
- FE: terminal-session approve()/reject() + pending state; tabs approval banner
(Approve/Reject) for the active tab's held request
- Tests: protocol approve/reject; integration ⑧ held→approve→allow. 207 green.
- Browser-verified: banner 'Claude wants to use Bash' → Approve → resolves allow.
- M5: 📱 toolbar button → modal with a client-side QR of location.origin
(shareable when opened via the LAN IP) + localhost tip
- M4: manifest.webmanifest + icon.svg + sw.js (network-first, never intercepts
/term or /hook) + registration in main.ts; apple-touch + theme-color meta
- Browser-verified: QR canvas renders, SW 'controlled', manifest linked,
no console errors. 199 tests green, build ok.
- terminal-session.ts: track SessionStatus (connecting/connected/reconnecting/
exited) + onStatus callback; FIX: wire onTitleChange (onTitle was in opts but
never bound — auto-title was dead)
- tabs.ts: per-tab status dot (color = connection state), unread flag set on
background output + cleared on activate, HTML5 drag-to-reorder (preserves
active tab, persisted)
- style.css: .tab-dot colors, .unread ring, .dragging/.drag-over feedback
- Browser-verified: green dot connected, unread ring after bg output, reorder
AAA|BBB→BBB|AAA persists across reload. 191 tests green, typechecks + build ok.