feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
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.
This commit is contained in:
74
public/fanout.ts
Normal file
74
public/fanout.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* public/fanout.ts (W5 fan-out board) — PURE launch-command + branch-slug helpers.
|
||||
*
|
||||
* No DOM, no fetch, no side effects — every export is unit-testable in isolation.
|
||||
* The load-bearing invariant is the prompt shell-quoting (shellSingleQuote): the
|
||||
* prompt is typed as raw bytes into a lane's shell BEFORE `claude` parses it, so
|
||||
* it must be wrapped as a single literal argv element — no `$(...)`, backtick,
|
||||
* `;`, `&&`, or redirection may execute. NEVER String-concat the prompt into the
|
||||
* command unquoted (see the plan's Security section).
|
||||
*/
|
||||
|
||||
import type { PermissionMode } from '../src/types.js'
|
||||
|
||||
/** Max prompt length typed into a lane (bounded DoS + keeps the command sane). */
|
||||
export const FANOUT_PROMPT_MAX = 4000
|
||||
|
||||
/** Default/max fan-out lanes (mirrors the server MAX_FANOUT_LANES default and the
|
||||
* grid-6 board capacity). The FE stepper max and the launch clamp both use it. */
|
||||
export const FANOUT_MAX_LANES = 6
|
||||
|
||||
/**
|
||||
* POSIX single-quote a string so the shell passes it verbatim as ONE argv element.
|
||||
* Wrap in single quotes and replace every embedded `'` with the classic
|
||||
* `'\''` sequence (close-quote, escaped-quote, re-open-quote). Pure.
|
||||
*/
|
||||
export function shellSingleQuote(s: string): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a prompt for typing into a PTY: collapse every line break (`\r`, `\n`,
|
||||
* `\r\n`) to a single space — a bare newline keystroke would submit a partial line
|
||||
* before `claude` starts — then trim and cap at FANOUT_PROMPT_MAX. Pure.
|
||||
*/
|
||||
export function sanitizePrompt(s: string): string {
|
||||
return s.replace(/[\r\n]+/g, ' ').trim().slice(0, FANOUT_PROMPT_MAX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `claude` launch command for one lane. Single-quotes the sanitized
|
||||
* prompt (injection-safe), prefixes `--permission-mode <m>` for non-default modes,
|
||||
* and ends with `\r` so the shell auto-executes it. 'auto' is downgraded to
|
||||
* 'default' when the server forbids it (SEC-M5 parity with resolveMode). Pure.
|
||||
*/
|
||||
export function buildFanoutCmd(
|
||||
prompt: string,
|
||||
mode: PermissionMode,
|
||||
allowAutoMode: boolean,
|
||||
): string {
|
||||
const effective: PermissionMode = mode === 'auto' && !allowAutoMode ? 'default' : mode
|
||||
const flag = effective === 'default' ? '' : `--permission-mode ${effective} `
|
||||
return `claude ${flag}${shellSingleQuote(sanitizePrompt(prompt))}\r`
|
||||
}
|
||||
|
||||
/** Lane branch name: `${base}-lane-${i}` (i is 1-based). Pure. */
|
||||
export function laneBranch(base: string, i: number): string {
|
||||
return `${base}-lane-${i}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a safe git-branch-base slug from a free-text prompt: lowercase, non-
|
||||
* alphanumeric runs → single `-`, strip leading/trailing dashes, cap at 40 chars.
|
||||
* Falls back to 'fanout' when nothing survives. The result is a valid branch base
|
||||
* (passes validateBranchNameClient). Pure.
|
||||
*/
|
||||
export function slugify(prompt: string): string {
|
||||
const slug = prompt
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40)
|
||||
.replace(/-+$/g, '')
|
||||
return slug || 'fanout'
|
||||
}
|
||||
Binary file not shown.
@@ -456,6 +456,20 @@ body {
|
||||
color: var(--text);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.cell-keep {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.cell-keep:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.cell-monitor-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -1944,6 +1958,60 @@ body {
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
/* W5 fan-out form — sibling of the New Worktree form. */
|
||||
.proj-fanout-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.proj-fanout-prompt {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
min-height: 44px;
|
||||
font: inherit;
|
||||
}
|
||||
.proj-fanout-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.proj-fanout-lanes {
|
||||
width: 72px;
|
||||
}
|
||||
.proj-fanout-branch {
|
||||
flex: 1 1 160px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.proj-fanout-error {
|
||||
color: var(--danger, #e5534b);
|
||||
font-size: 12px;
|
||||
}
|
||||
.proj-fanout-submit {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.proj-fanout-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* W5 fan-out status/error banner — fixed, dismissible, textContent only. */
|
||||
.fanout-banner {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 16px;
|
||||
transform: translateX(-50%);
|
||||
max-width: min(680px, 92vw);
|
||||
z-index: 60;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-3, #2a2c33);
|
||||
color: var(--text, #e7e8ec);
|
||||
border: 1px solid var(--border, #3a3d46);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Section header with an inline action (CLAUDE.md generate/update). */
|
||||
.proj-section-row {
|
||||
display: flex;
|
||||
|
||||
231
public/tabs.ts
231
public/tabs.ts
@@ -25,8 +25,21 @@ import { TerminalSession } from './terminal-session.js'
|
||||
import { ICON_TIMELINE } from './icons.js'
|
||||
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
||||
import { mountLauncher, type Launcher } from './launcher.js'
|
||||
import { mountProjects, type ProjectsPanel } from './projects.js'
|
||||
import type { ApprovalPreview, ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js'
|
||||
import {
|
||||
mountProjects,
|
||||
createWorktreeReq,
|
||||
removeWorktreeReq,
|
||||
validateBranchNameClient,
|
||||
type ProjectsPanel,
|
||||
} from './projects.js'
|
||||
import { buildFanoutCmd, laneBranch, FANOUT_MAX_LANES } from './fanout.js'
|
||||
import type {
|
||||
ApprovalPreview,
|
||||
ClaudeStatus,
|
||||
FanoutLaunchOpts,
|
||||
PermissionMode,
|
||||
UiConfig,
|
||||
} from '../src/types.js'
|
||||
import { renderDiffFile } from './diff.js'
|
||||
import { renderTelemetryGauge } from './preview-grid.js'
|
||||
import { mountPushToggle } from './push.js'
|
||||
@@ -72,12 +85,35 @@ const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits
|
||||
|
||||
type HomeView = 'sessions' | 'projects'
|
||||
|
||||
/** W5: one lane of a fan-out group — its worktree + branch (the session that runs
|
||||
* in it is tracked via the tagged TabEntry, matched by TabEntry.fanoutGroupId). */
|
||||
interface FanoutLane {
|
||||
branch: string
|
||||
worktreePath: string
|
||||
}
|
||||
|
||||
/** W5: an in-memory fan-out group — one prompt fanned across N lanes of one repo.
|
||||
* Lives only for the session (v1): reload does not reconstruct the board (matches
|
||||
* v0.5 "no auto-restore"); keepFanoutWinner operates on the currently-open lanes. */
|
||||
interface FanoutGroup {
|
||||
id: string
|
||||
repoPath: string
|
||||
repoName: string
|
||||
prompt: string
|
||||
lanes: FanoutLane[]
|
||||
}
|
||||
|
||||
interface TabEntry {
|
||||
session: TerminalSession
|
||||
customTitle: string | null // user-set; null = use auto/fallback (persisted)
|
||||
autoTitle: string | null // current folder from the terminal title
|
||||
hasActivity: boolean // inactive tab got output since last viewed
|
||||
el: HTMLDivElement | null // the .tab element (updated in place)
|
||||
// W5: when set, this tab is a lane of a fan-out group (id) with its own worktree
|
||||
// + branch; the cell gets a 🏆 Keep button that resolves the race for this lane.
|
||||
fanoutGroupId?: string
|
||||
worktreePath?: string
|
||||
branch?: string
|
||||
// Split-grid: the .term-cell wrapper around session.el (header + terminal +
|
||||
// optional inline-approve footer). One per tab; the grid lays these out.
|
||||
cell: HTMLDivElement | null
|
||||
@@ -144,6 +180,12 @@ export class TabApp {
|
||||
// W3(b): the server COST_BUDGET_USD (from /config/ui); 0 = disabled. The per-tab
|
||||
// gauge warn-styles the cost chip once costUsd >= this budget.
|
||||
private costBudgetUsd = 0
|
||||
// W5 fan-out board: the server MAX_FANOUT_LANES (from /config/ui) — the hard cap
|
||||
// launchFanout clamps to (alongside grid-6 capacity + the server DoS cap).
|
||||
private maxFanoutLanes = FANOUT_MAX_LANES
|
||||
// W5: in-memory fan-out group model (session-scoped; not persisted in v1).
|
||||
private readonly fanoutGroups = new Map<string, FanoutGroup>()
|
||||
private fanoutBanner: HTMLElement | null = null
|
||||
private pushHost!: HTMLElement // A1: 🔔 host, mounted once, re-parented per rebuild
|
||||
private timelinePanel!: HTMLElement // A4: shared timeline panel (one mounted at a time)
|
||||
private timelineOpen = false
|
||||
@@ -180,6 +222,7 @@ export class TabApp {
|
||||
this.projects = mountProjects(this.paneHost, {
|
||||
onOpenProject: (repoPath, repoName, cmd) => this.openProject(repoPath, repoName, cmd),
|
||||
onEnterSession: (id) => this.openSession(id),
|
||||
onFanout: (repoPath, repoName, opts) => void this.launchFanout(repoPath, repoName, opts),
|
||||
})
|
||||
|
||||
this.segControl = this.buildSegControl()
|
||||
@@ -195,6 +238,7 @@ export class TabApp {
|
||||
this.setupQuickReply() // A3 chips above the key bar
|
||||
this.setupTimelinePanel() // A4 activity timeline
|
||||
this.setupVoiceOverlay() // A2 interim-transcript overlay
|
||||
this.setupFanoutBanner() // W5 fan-out status/error banner
|
||||
void this.loadUiConfig() // B4 allowAutoMode gate (best-effort)
|
||||
|
||||
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
|
||||
@@ -264,6 +308,35 @@ export class TabApp {
|
||||
this.voiceConfirmOverlay = confirmOverlay
|
||||
}
|
||||
|
||||
/** W5: a dismissible banner for fan-out status/errors (partial-launch, keep-
|
||||
* winner failures). textContent only (SEC-L3/H6); tap or auto-hide to clear. */
|
||||
private setupFanoutBanner(): void {
|
||||
const banner = document.createElement('div')
|
||||
banner.id = 'fanout-banner'
|
||||
banner.className = 'fanout-banner'
|
||||
banner.style.display = 'none'
|
||||
banner.addEventListener('click', () => {
|
||||
banner.style.display = 'none'
|
||||
})
|
||||
document.body.appendChild(banner)
|
||||
this.fanoutBanner = banner
|
||||
}
|
||||
|
||||
private fanoutBannerTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** Show a fan-out banner message (textContent only). Auto-hides after 8s. */
|
||||
private showFanoutBanner(msg: string): void {
|
||||
const banner = this.fanoutBanner
|
||||
if (!banner) return
|
||||
banner.textContent = msg // SEC-L3/H6: textContent, never innerHTML
|
||||
banner.style.display = 'block'
|
||||
if (this.fanoutBannerTimer !== null) clearTimeout(this.fanoutBannerTimer)
|
||||
this.fanoutBannerTimer = setTimeout(() => {
|
||||
banner.style.display = 'none'
|
||||
this.fanoutBannerTimer = null
|
||||
}, 8000)
|
||||
}
|
||||
|
||||
/** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */
|
||||
private async loadUiConfig(): Promise<void> {
|
||||
try {
|
||||
@@ -283,6 +356,11 @@ export class TabApp {
|
||||
if (typeof budget === 'number' && Number.isFinite(budget) && budget > 0) {
|
||||
this.costBudgetUsd = budget
|
||||
}
|
||||
// W5: read the fan-out lane cap (optional; older servers omit → keep default).
|
||||
const lanes = (data as Record<string, unknown>)?.['maxFanoutLanes']
|
||||
if (typeof lanes === 'number' && Number.isFinite(lanes) && lanes >= 2) {
|
||||
this.maxFanoutLanes = Math.floor(lanes)
|
||||
}
|
||||
} catch {
|
||||
// best-effort — leave allowAutoMode false (auto hidden) on any failure
|
||||
}
|
||||
@@ -798,7 +876,23 @@ export class TabApp {
|
||||
e.stopPropagation()
|
||||
this.toggleMonitor(this.tabs.indexOf(entry))
|
||||
})
|
||||
head.append(monBtn, maxBtn)
|
||||
// W5: 🏆 keep-winner — only shown (via renderCell) when this tab is a fan-out
|
||||
// lane. Resolves the race: keep this lane, discard the others' worktrees.
|
||||
const keepBtn = document.createElement('button')
|
||||
keepBtn.type = 'button'
|
||||
keepBtn.className = 'cell-keep'
|
||||
keepBtn.textContent = '🏆'
|
||||
keepBtn.title = 'Keep this lane, discard the others'
|
||||
keepBtn.setAttribute('aria-label', 'Keep this fan-out lane as the winner')
|
||||
keepBtn.style.display = 'none'
|
||||
keepBtn.addEventListener('pointerdown', (e) => e.stopPropagation())
|
||||
keepBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
if (entry.fanoutGroupId !== undefined) {
|
||||
void this.keepFanoutWinner(entry.fanoutGroupId, entry.session.id ?? '')
|
||||
}
|
||||
})
|
||||
head.append(monBtn, keepBtn, maxBtn)
|
||||
// v2: drag a tab from the tab bar onto this quadrant to assign it here.
|
||||
this.wireCellDropTarget(cell, () => this.tabs.indexOf(entry))
|
||||
cell.append(head, session.el)
|
||||
@@ -852,6 +946,134 @@ export class TabApp {
|
||||
).length
|
||||
}
|
||||
|
||||
/* ── W5 fan-out board ────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Fan ONE prompt across N lanes of `repoPath`: create N worktrees SEQUENTIALLY
|
||||
* (git's `worktree add` takes a repo lock — parallel adds race), then open one
|
||||
* Claude session per created lane pre-injected with the same shell-quoted prompt,
|
||||
* and lay them out on the split-grid board. Composition of shipped parts —
|
||||
* createWorktreeReq + addEntry (openProject shape) + setGridLayout. Partial
|
||||
* success is surfaced in the banner (no auto-rollback in v1); never throws.
|
||||
*/
|
||||
async launchFanout(repoPath: string, repoName: string, opts: FanoutLaunchOpts): Promise<void> {
|
||||
const base = opts.branchBase.trim()
|
||||
const branchErr = validateBranchNameClient(base)
|
||||
if (branchErr !== null) {
|
||||
this.showFanoutBanner(`Cannot fan out — ${branchErr}.`)
|
||||
return
|
||||
}
|
||||
// Effective N: min(requested, server cap, grid-6 capacity). The remaining
|
||||
// maxSessions − liveCount bound is enforced server-side (assertUnderSessionCap
|
||||
// throws → that lane shows exit(-1); reported as "started K of N" below).
|
||||
const n = Math.min(
|
||||
Math.max(2, Math.floor(opts.lanes)),
|
||||
this.maxFanoutLanes,
|
||||
FANOUT_MAX_LANES,
|
||||
)
|
||||
// Build the launch command ONCE — every lane runs the identical prompt.
|
||||
const cmd = buildFanoutCmd(opts.prompt, this.resolveMode(opts.mode ?? 'default'), this.allowAutoMode)
|
||||
|
||||
const groupId = `fanout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const lanes: FanoutLane[] = []
|
||||
const failures: string[] = []
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const branch = laneBranch(base, i)
|
||||
const r = await createWorktreeReq(repoPath, branch) // sequential (repo lock)
|
||||
if (r.ok && typeof r.path === 'string') {
|
||||
lanes.push({ branch, worktreePath: r.path })
|
||||
} else {
|
||||
failures.push(`${branch}: ${r.error ?? 'failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (lanes.length === 0) {
|
||||
this.showFanoutBanner(`Fan-out failed — no lanes created. ${failures.join('; ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
const group: FanoutGroup = { id: groupId, repoPath, repoName, prompt: opts.prompt, lanes }
|
||||
this.fanoutGroups.set(groupId, group)
|
||||
|
||||
const created: TabEntry[] = []
|
||||
for (const lane of lanes) {
|
||||
const entry = this.addEntry(null, `${repoName}·${lane.branch}`, lane.worktreePath || undefined, cmd)
|
||||
entry.fanoutGroupId = groupId
|
||||
entry.worktreePath = lane.worktreePath
|
||||
entry.branch = lane.branch
|
||||
created.push(entry)
|
||||
}
|
||||
this.persist()
|
||||
this.rebuild()
|
||||
// Watch board: 2×2 up to 4 lanes, else 2×3 (grid-6 holds up to 6).
|
||||
this.setGridLayout(lanes.length <= 4 ? 'grid-4' : 'grid-6')
|
||||
const first = created[0]
|
||||
if (first) this.activate(this.tabs.indexOf(first))
|
||||
|
||||
if (failures.length > 0) {
|
||||
this.showFanoutBanner(`Started ${lanes.length} of ${n} lanes. Failed: ${failures.join('; ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the winning lane and discard the others: a single batch confirm, then
|
||||
* each losing lane's tab is closed (detach — its PTY is reclaimed by IDLE_TTL)
|
||||
* and its worktree removed (auto-forcing on a 409 dirty tree, inside the one
|
||||
* confirm — no per-lane prompt). The winner's tab + worktree stay so the user
|
||||
* runs the merge inside it (one-click merge is out of v1 scope). Never throws.
|
||||
*/
|
||||
async keepFanoutWinner(groupId: string, winnerSessionId: string): Promise<void> {
|
||||
const group = this.fanoutGroups.get(groupId)
|
||||
if (!group) return
|
||||
const laneTabs = this.tabs.filter((t) => t.fanoutGroupId === groupId)
|
||||
const winner = laneTabs.find((t) => t.session.id === winnerSessionId)
|
||||
if (!winner) {
|
||||
// No lane matches — refuse rather than treat every lane (incl. the intended
|
||||
// winner) as a loser and delete them all. Happens if the winner hasn't
|
||||
// attached yet (session.id still null).
|
||||
this.showFanoutBanner('Cannot keep this lane yet — it has not connected.')
|
||||
return
|
||||
}
|
||||
const losers = laneTabs.filter((t) => t !== winner)
|
||||
const winnerBranch = winner.branch ?? winnerSessionId
|
||||
if (
|
||||
!window.confirm(
|
||||
`Keep ${winnerBranch} and discard the other ${losers.length} lane(s) (delete their worktrees)?`,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const failures: string[] = []
|
||||
for (const loser of losers) {
|
||||
const idx = this.tabs.indexOf(loser)
|
||||
if (idx >= 0) this.closeTab(idx) // detach; PTY reaped by IDLE_TTL
|
||||
const wt = loser.worktreePath
|
||||
if (wt !== undefined && wt !== '') {
|
||||
let r = await removeWorktreeReq(group.repoPath, wt, false)
|
||||
if (!r.ok && r.status === 409) {
|
||||
r = await removeWorktreeReq(group.repoPath, wt, true) // dirty → force (batch-confirmed)
|
||||
}
|
||||
if (!r.ok) failures.push(`${loser.branch ?? wt}: ${r.error ?? 'failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.fanoutGroups.delete(groupId)
|
||||
winner.fanoutGroupId = undefined // drop the 🏆 button — the race is resolved
|
||||
|
||||
if (losers.length > 0) {
|
||||
this.setGridLayout('single')
|
||||
const wi = this.tabs.indexOf(winner)
|
||||
if (wi >= 0) this.activate(wi)
|
||||
}
|
||||
this.persist()
|
||||
this.rebuild()
|
||||
|
||||
if (failures.length > 0) {
|
||||
this.showFanoutBanner(`Kept ${winnerBranch}. Some worktrees could not be removed: ${failures.join('; ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
activate(i: number): void {
|
||||
if (i < 0 || i >= this.tabs.length) return
|
||||
// Board-aware focus: in a split grid the focused pane must be ON the board,
|
||||
@@ -1344,6 +1566,9 @@ export class TabApp {
|
||||
if (maxBtn) maxBtn.textContent = maximized ? '⤡' : '⛶'
|
||||
const monBtn = cell.querySelector<HTMLElement>('.cell-monitor-btn')
|
||||
if (monBtn) monBtn.classList.toggle('active', grid && entry.monitor)
|
||||
// W5: the 🏆 keep button appears only on a fan-out lane's cell (any layout).
|
||||
const keepBtn = cell.querySelector<HTMLElement>('.cell-keep')
|
||||
if (keepBtn) keepBtn.style.display = entry.fanoutGroupId !== undefined ? '' : 'none'
|
||||
|
||||
const nameEl = cell.querySelector('.cell-name')
|
||||
if (nameEl) nameEl.textContent = this.displayTitle(entry, idx)
|
||||
|
||||
Reference in New Issue
Block a user