/**
 * public/tabs.ts — multi-tab manager + tab bar UI.
 *
 * Each tab owns an independent TerminalSession (own WS + server session).
 *
 * Titles: auto from the terminal (current folder, via folderFromTitle) →
 * double-click to rename inline → falls back to "Term N". Manual wins.
 * Interaction: pointerdown to switch (robust with draggable), drag to reorder,
 * middle-click / × to close, status dot (connection color), unread dot.
 *
 * DOM is built once per tab and updated in place. A full rebuild happens ONLY
 * on structural changes (add / close / reorder / rename) — never on a plain
 * activate or on background output — so clicks are never eaten by a re-render.
 *
 * 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 { ICON_TIMELINE } from './icons.js'
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
import { mountLauncher, type Launcher } from './launcher.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'
import { mountQuickReply } from './quick-reply.js'
import { enqueueFollowup } from './queue.js'
import { mountTimeline, type TimelineHandle } from './timeline.js'
import { createVoiceInput, type VoiceInput } from './voice.js'
import { matchCommand, type VoiceMatchContext } from './voice-commands.js'
import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js'
import {
  type GridLayout,
  type GridToggle,
  type GridSplits,
  type TrackSplit,
  isVisibleIndex,
  layoutCapacity,
  layoutTracks,
  loadGridLayout,
  saveGridLayout,
  adjustSplit,
  tracksToTemplate,
  loadGridSplits,
  saveGridSplits,
  splitForLayout,
} from './grid-layout.js'
import { mountCellMonitor, type CellMonitor } from './cell-monitor.js'

const TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active'
const HOME_VIEW_KEY = 'web-terminal:home-view'
const PERMISSION_MODE_KEY = 'web-terminal:permission-mode'

/** Mirrors the server STATUSLINE_TTL_MS default — the per-tab gauge greys out
 *  (class `tg-stale`) once telemetry is older than this (B2). */
const STATUSLINE_TTL_MS = 30_000

/** VC decision C: how long the "未连接" not-connected flash stays visible. */
const VOICE_NOT_CONNECTED_FLASH_MS = 1500

/** All `--permission-mode` values (B4). 'auto' is high-risk and only offered
 *  when the server's ALLOW_AUTO_MODE allows it (SEC-M5, gated via /config/ui). */
const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']

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
  // v3: when true, this quadrant shows a read-only polled preview (monitor mode)
  // instead of the live pane, so it never drives the shared PTY size.
  monitor: boolean
  monitorHandle: CellMonitor | null
  monitorEl: HTMLElement | null
  // A4: live timeline handle while this tab's timeline is open (disposed on
  // switch/close). Telemetry is NOT cached here — refreshTab reads the single
  // source of truth session.telemetry (review #15).
  timelineHandle: TimelineHandle | null
}

interface StoredTab {
  id: string | null
  title: string | null
}

/** Compact glyph for a tab's Claude activity, incl. 'stuck' ⚠ (A5). */
function claudeIcon(cs: ClaudeStatus): string {
  if (cs === 'working') return '⚙'
  if (cs === 'waiting') return '⏳'
  if (cs === 'idle') return '✓'
  if (cs === 'stuck') return '⚠'
  return ''
}

export class TabApp {
  private readonly tabs: TabEntry[] = []
  private activeIndex = -1
  private editingIndex = -1
  private dragIndex = -1
  private notifyAsked = false
  private settings: Settings = DEFAULT_SETTINGS
  private readonly paneHost: HTMLElement
  private readonly tabBar: HTMLElement
  private readonly approvalBar: HTMLDivElement
  private readonly launcher: Launcher
  private readonly projects: ProjectsPanel
  private homeView: HomeView = 'sessions'
  private readonly segControl: HTMLElement
  // Split-grid ("watch board") state. Layout persists in localStorage; the
  // toolbar toggle (mounted in main.ts) drives setGridLayout and is refreshed
  // back via gridToggle. 'single' preserves the original one-pane behavior.
  private gridLayout: GridLayout = 'single'
  private gridToggle: GridToggle | null = null
  // v2: when true, the focused quadrant is expanded to fill the grid (others stay
  // live behind it). Follows the focused pane; reset on layout change / tab close.
  private maximized = false
  // v3: persisted per-layout column/row track fractions (draggable splitters).
  private gridSplits: GridSplits = {}
  // v3: true while a gutter is being dragged, so a concurrent applyLayout does
  // not destroy-and-recreate the handle (which would drop the drag listeners).
  private draggingGutter = false
  // When true, the home chooser is overlaid on top of the open tabs (so you can
  // jump back to Sessions/Projects without closing anything). Reset whenever a
  // tab is activated. Irrelevant while no tab is open (home shows regardless).
  private homeForced = false
  private homeBtn: HTMLButtonElement | null = null
  // B4: mirrors the server ALLOW_AUTO_MODE gate (from /config/ui); when false the
  // high-risk 'auto' permission mode is hidden/refused (SEC-M5).
  private allowAutoMode = false
  // 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
  private timelineBtn: HTMLButtonElement | null = null
  private voice: VoiceInput | null = null // A2: push-to-talk recognizer
  private voiceOverlay: HTMLElement | null = null // A2: interim-transcript overlay
  // VC: context-gated confirm/reject command mapping (PLAN_VOICE_COMMANDS.md).
  private readonly approveConfirm: ApproveConfirm = createApproveConfirm() // decision B
  private voiceConfirmOverlay: HTMLElement | null = null // "听到：批准" / "未连接" (B, C)
  private voicePttSession: TerminalSession | null = null // session captured at PTT-start (safeguard 5)
  private voicePttEpoch = 0 // session.pendingEpoch captured at PTT-start (safeguard 5)

  constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
    this.paneHost = paneHost
    this.tabBar = tabBar
    this.approvalBar = document.createElement('div')
    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(),
    })

    // 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, 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()
    this.paneHost.appendChild(this.segControl)

    // Split-grid: restore the persisted layout (forced to 'single' on screens
    // too narrow for a multi-pane board — see loadGridLayout) + custom splits.
    this.gridLayout = loadGridLayout()
    this.gridSplits = loadGridSplits()

    // v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds):
    this.setupPushToggle() // A1 🔔
    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;
    // the user picks which session to open. Sessions persist server-side, so
    // opening one replays its full scrollback.
    this.rebuild()
    this.updateHomeView()
  }

  /* ── v0.7 panel setup (A1/A2/A3/A4/B4) ──────────────────────────── */

  /** A1: mount the 🔔 push toggle once into a host re-parented on every rebuild. */
  private setupPushToggle(): void {
    this.pushHost = document.createElement('div')
    this.pushHost.className = 'push-host'
    mountPushToggle(this.pushHost)
  }

  /** A3: quick-reply chips row, inserted directly above the #keybar. */
  private setupQuickReply(): void {
    const host = document.createElement('div')
    host.id = 'quickreply'
    const keybar = document.getElementById('keybar')
    if (keybar?.parentElement) keybar.parentElement.insertBefore(host, keybar)
    else document.body.appendChild(host)
    mountQuickReply(host, {
      onSend: (data) => this.sendToActive(data),
      // W2: "Queue" in the snippet editor enqueues a follow-up for the active
      // session instead of sending it now (fires when Claude next goes idle).
      onQueue: (text, appendEnter) => this.enqueueToActive(text, appendEnter),
    })
  }

  /** W2: enqueue a follow-up prompt for the active session (best-effort — the
   *  POST is Origin-guarded server-side; failures are surfaced only via the badge
   *  not updating). No-op when no tab has attached yet. */
  enqueueToActive(text: string, appendEnter: boolean): void {
    const id = this.activeSessionId()
    if (id === null) return
    void enqueueFollowup(id, text, appendEnter)
  }

  /** A4: hidden activity-timeline panel; toggled per active session. */
  private setupTimelinePanel(): void {
    this.timelinePanel = document.createElement('div')
    this.timelinePanel.id = 'timeline-panel'
    this.timelinePanel.style.display = 'none'
    document.body.appendChild(this.timelinePanel)
  }

  /** A2: overlay that shows the live interim transcript while dictating. VC: a
   *  SEPARATE overlay carries "听到：批准" / "未连接" so hideInterim() (which
   *  fires on PTT release) never clobbers it mid-confirm-window (§6). */
  private setupVoiceOverlay(): void {
    const overlay = document.createElement('div')
    overlay.id = 'voice-interim'
    overlay.style.display = 'none'
    document.body.appendChild(overlay)
    this.voiceOverlay = overlay

    const confirmOverlay = document.createElement('div')
    confirmOverlay.id = 'voice-confirm'
    confirmOverlay.style.display = 'none'
    // Tap-to-cancel an armed approve confirm window (§4 cancel trigger).
    confirmOverlay.addEventListener('click', () => this.cancelArmedApprove())
    document.body.appendChild(confirmOverlay)
    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 {
      if (typeof fetch === 'undefined') return
      const res = await fetch('/config/ui', { credentials: 'same-origin' })
      if (!res.ok) return
      const data: unknown = await res.json()
      if (
        data !== null &&
        typeof data === 'object' &&
        typeof (data as Record<string, unknown>)['allowAutoMode'] === 'boolean'
      ) {
        this.allowAutoMode = (data as UiConfig).allowAutoMode
      }
      // W3(b): read the cost budget (optional, present only when > 0 server-side).
      const budget = (data as Record<string, unknown>)?.['costBudgetUsd']
      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
    }
  }

  /* ── 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 {
    // Home shows when there is no tab OR the user explicitly forced it (the ⌂
    // button), letting you reach Sessions/Projects without closing a session.
    const showHome = this.tabs.length === 0 || this.homeForced

    // 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)
    })
    // The ⌂ button is "pressed" only while overlaying home on top of open tabs.
    this.homeBtn?.classList.toggle('active', this.homeForced && this.tabs.length > 0)

    // Hide the bottom key-bar on the home chooser — it only sends keys to an
    // active terminal, so it's noise on the Sessions/Projects views.
    document.body.classList.toggle('home-open', showHome)

    if (showHome) {
      // Overlay home: the chooser is on top; applyLayout hides every pane below.
      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)
    }

    // Pane/cell visibility, the grid layout class, focus ring and placeholders
    // are all owned by applyLayout — home just decides whether panes show at all.
    this.applyLayout(showHome)
  }

  /** Toggle the home chooser overlay over the open tabs (the ⌂ button). No-op
   *  when no tab is open (home is already showing). */
  private toggleHome(): void {
    if (this.tabs.length === 0) return
    this.homeForced = !this.homeForced
    if (this.homeForced) {
      this.updateHomeView()
    } else {
      // Return to the active terminal (activate re-shows its pane + hides home).
      this.activate(this.activeIndex)
    }
  }

  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 (H3). B4: a 'plan' gate renders THREE
   *  choices; an ordinary 'tool' gate keeps the two buttons (no regression). */
  private updateApprovalBar(): void {
    const session = this.tabs[this.activeIndex]?.session
    if (!session || !session.pendingApproval) {
      this.approvalBar.style.display = 'none'
      return
    }
    this.approvalBar.replaceChildren()
    const label = document.createElement('span')
    label.className = 'approval-label'
    // W1: show WHAT will run (command / diff) between the label and the buttons,
    // so a one-tap remote approval is no longer blind. Absent for plan gates and
    // unknown tools (no reviewable command/diff) → today's name-only bar.
    const preview = session.pendingPreview
    const previewNode = preview ? this.renderApprovalPreview(preview) : null
    const middle = previewNode ? [previewNode] : []
    if (session.pendingGate === 'plan') {
      label.textContent = 'Claude finished planning — how should it proceed?'
      this.approvalBar.append(label, ...middle, ...this.planGateButtons(session))
    } else {
      label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
      this.approvalBar.append(label, ...middle, ...this.toolGateButtons(session))
    }
    this.approvalBar.style.display = 'flex'
  }

  /** W1: build the command/diff preview node for the approval bar. Untrusted,
   *  server-sanitized content is rendered via textContent / renderDiffFile ONLY
   *  (never innerHTML) — <script>, ANSI, & etc. appear as literal characters. */
  private renderApprovalPreview(p: ApprovalPreview): HTMLElement {
    const container = document.createElement('div')
    container.className = 'approval-preview'
    if (p.kind === 'command') {
      const pre = document.createElement('pre')
      pre.className = 'approval-cmd'
      pre.textContent = p.text // textContent — attacker-influenced command bytes
      container.append(pre)
    } else {
      container.append(renderDiffFile(p.file)) // diff.ts is innerHTML-free (SEC-H4)
    }
    if (p.truncated) {
      const note = document.createElement('div')
      note.className = 'approval-truncated'
      note.textContent = '… truncated'
      container.append(note)
    }
    return container
  }

  /** Ordinary tool gate: Approve / Reject (two buttons, unchanged). */
  private toolGateButtons(session: TerminalSession): HTMLButtonElement[] {
    return [
      this.approvalButton('approval-yes', '✓ Approve', () => session.approve()),
      this.approvalButton('approval-no', '✗ Reject', () => session.reject()),
    ]
  }

  /** B4 plan gate: Approve+Auto (acceptEdits) / Approve+Review (default) / Keep
   *  Planning (reject — stays in plan mode). Three buttons. */
  private planGateButtons(session: TerminalSession): HTMLButtonElement[] {
    return [
      this.approvalButton('approval-yes', '✓ Approve + Auto', () => session.approve('acceptEdits')),
      this.approvalButton('approval-review', '✓ Approve + Review', () => session.approve('default')),
      this.approvalButton('approval-no', '✎ Keep Planning', () => session.reject()),
    ]
  }

  /** Build one approval-bar button that runs `onClick` then re-renders the bar. */
  private approvalButton(cls: string, text: string, onClick: () => void): HTMLButtonElement {
    const btn = document.createElement('button')
    btn.className = cls
    btn.textContent = text
    btn.addEventListener('click', () => {
      onClick()
      this.updateApprovalBar()
    })
    return btn
  }

  /* ── A2 voice · A4 timeline · B4 permission mode ─────────────────── */

  /** Push-to-talk handler for the key-bar 🎤 (A2). Wire into mountKeybar via
   *  `{ onVoiceTrigger: (a) => app.handleVoiceTrigger(a) }`. */
  handleVoiceTrigger(action: 'start' | 'stop'): void {
    if (action === 'start') this.startVoice()
    else this.stopVoice()
  }

  private startVoice(): void {
    if (!this.voice) {
      this.voice = createVoiceInput(
        (text, confidence) => this.handleVoiceTranscript(text, confidence),
        { onInterim: (t) => this.showInterim(t) },
      )
    }
    if (!this.voice) return // SpeechRecognition unsupported — no-op (AC-A2.3)
    // VC safeguard 5: bind this utterance to the CURRENTLY active session/epoch
    // so a slow transcript can't approve a newer gate or a different tab.
    const active = this.tabs[this.activeIndex]?.session ?? null
    this.voicePttSession = active
    this.voicePttEpoch = active?.pendingEpoch ?? 0
    this.showInterim('')
    this.voice.start()
  }

  private stopVoice(): void {
    this.voice?.stop()
    this.hideInterim()
  }

  private showInterim(text: string): void {
    if (!this.voiceOverlay) return
    this.voiceOverlay.textContent = text // textContent — transcript is untrusted
    this.voiceOverlay.style.display = 'block'
  }

  private hideInterim(): void {
    if (this.voiceOverlay) this.voiceOverlay.style.display = 'none'
  }

  /**
   * VC — dispatch a final voice transcript (PLAN_VOICE_COMMANDS.md §6):
   * context-gated confirm/reject resolve the held permission via the existing
   * approve()/reject() WS channel; everything else (including a stale gate or
   * a tab switch mid-utterance) falls through to ordinary dictation, byte-for-
   * byte unchanged. Raw bytes are NEVER synthesized for approve/reject.
   */
  private handleVoiceTranscript(text: string, confidence?: number): void {
    // A NEW utterance supersedes any pending approve confirm window: the user
    // has spoken again, so never let a stale timer commit behind a fresh
    // command (this is the "say another word" cancel trigger, §4). Combined
    // with tap-to-cancel and the commit-time re-check below, it closes the
    // TOCTOU race where a delayed approve could resolve a different gate.
    this.cancelArmedApprove()

    const session = this.tabs[this.activeIndex]?.session
    if (!session) {
      this.sendToActive(text)
      return
    }
    const ctx: VoiceMatchContext = {
      pendingApproval: session.pendingApproval,
      gate: session.pendingGate,
      confidence,
    }
    const action = matchCommand(text, ctx)
    // Stale-gate guard: a command can only resolve the SAME session+gate it
    // was spoken against; anything else (tab switch, a new pending flip) is
    // downgraded to plain dictation rather than silently dropped.
    const stale = session !== this.voicePttSession || session.pendingEpoch !== this.voicePttEpoch
    if (action !== 'text' && stale) {
      this.sendToActive(text)
      return
    }
    if (action === 'reject') {
      if (!this.isVoiceConnected(session)) return this.flashVoiceStatus('未连接', VOICE_NOT_CONNECTED_FLASH_MS)
      session.reject()
      this.updateApprovalBar()
      return
    }
    if (action === 'approve') {
      if (!this.isVoiceConnected(session)) return this.flashVoiceStatus('未连接', VOICE_NOT_CONNECTED_FLASH_MS)
      // Decision B: arm a cancellable window instead of committing immediately.
      // The commit RE-VALIDATES the gate identity (TOCTOU fix): if the held
      // permission resolved and a NEW gate was raised during the ~1.5s window
      // (pendingEpoch bumps), or the tab/WS changed, drop the approve — never
      // silently approve a different, unseen/unheard gate.
      const armedSession = session
      const armedEpoch = session.pendingEpoch
      this.approveConfirm.arm(
        () => this.flashVoiceStatus('听到：批准（点此取消）'),
        () => {
          this.hideVoiceStatus()
          const fresh =
            armedSession.pendingApproval &&
            armedSession.pendingGate === 'tool' &&
            armedSession.pendingEpoch === armedEpoch &&
            this.isVoiceConnected(armedSession)
          if (!fresh) return // gate changed / WS dropped during the window → drop
          armedSession.approve()
          this.updateApprovalBar()
        },
      )
      return
    }
    this.sendToActive(text) // UNCHANGED dictation path
  }

  /** Cancel a still-armed approve confirm window and clear its overlay. Invoked
   *  on a new utterance, a reject, and a tap on the overlay (§4 cancel triggers). */
  private cancelArmedApprove(): void {
    if (!this.approveConfirm.isArmed()) return
    this.approveConfirm.cancel()
    this.hideVoiceStatus()
  }

  /** Decision C: approve()/reject() are silently dropped by sendMsg when the WS
   *  isn't open — flash feedback instead of a silent no-op. */
  private isVoiceConnected(session: TerminalSession): boolean {
    return session.status === 'connected'
  }

  /** Transient voice-status text ("听到：批准" / "未连接") on a DIFFERENT overlay
   *  than the interim transcript, so hideInterim() (PTT release) never clears
   *  it mid-confirm-window. XSS-safe: textContent only. */
  private flashVoiceStatus(text: string, autoHideMs?: number): void {
    if (!this.voiceConfirmOverlay) return
    this.voiceConfirmOverlay.textContent = text
    this.voiceConfirmOverlay.style.display = 'block'
    if (autoHideMs !== undefined) setTimeout(() => this.hideVoiceStatus(), autoHideMs)
  }

  private hideVoiceStatus(): void {
    if (this.voiceConfirmOverlay) this.voiceConfirmOverlay.style.display = 'none'
  }

  /** Toggle the activity-timeline panel for the active session (A4). */
  private toggleTimeline(): void {
    this.timelineOpen = !this.timelineOpen
    if (this.timelineOpen) this.openTimelineForActive()
    else this.closeTimeline()
    this.timelineBtn?.classList.toggle('active', this.timelineOpen)
  }

  /** Mount the timeline for the active session (one panel at a time, A4). */
  private openTimelineForActive(): void {
    for (const t of this.tabs) {
      t.timelineHandle?.dispose()
      t.timelineHandle = null
    }
    this.timelinePanel.style.display = 'block'
    this.timelinePanel.replaceChildren()
    const entry = this.tabs[this.activeIndex]
    const id = entry?.session.id
    if (entry && id) entry.timelineHandle = mountTimeline(this.timelinePanel, id)
  }

  private closeTimeline(): void {
    this.timelinePanel.style.display = 'none'
    for (const t of this.tabs) {
      t.timelineHandle?.dispose()
      t.timelineHandle = null
    }
  }

  /** Permission modes the user may pick (B4). 'auto' is filtered out unless the
   *  server allows it (SEC-M5). */
  availablePermissionModes(): PermissionMode[] {
    return ALL_PERMISSION_MODES.filter((m) => m !== 'auto' || this.allowAutoMode)
  }

  /** B4: persisted default permission mode; 'auto' downgrades to 'default' when
   *  the server forbids it (SEC-M5). Invalid stored values fall back to 'default'. */
  loadDefaultMode(): PermissionMode {
    let mode: PermissionMode = 'default'
    try {
      const stored = localStorage.getItem(PERMISSION_MODE_KEY)
      if (stored !== null && (ALL_PERMISSION_MODES as readonly string[]).includes(stored)) {
        mode = stored as PermissionMode
      }
    } catch {
      // localStorage unavailable — use 'default'
    }
    return this.resolveMode(mode)
  }

  /** Persist the chosen default permission mode (B4-FR7). */
  saveDefaultMode(mode: PermissionMode): void {
    try {
      localStorage.setItem(PERMISSION_MODE_KEY, mode)
    } catch {
      // localStorage unavailable — run without persistence
    }
  }

  /** SEC-M5: never honor 'auto' when the server forbids it. */
  private resolveMode(mode: PermissionMode): PermissionMode {
    return mode === 'auto' && !this.allowAutoMode ? 'default' : mode
  }

  /** B4: `claude` launch command for a permission mode (default → plain claude). */
  private buildClaudeCmd(mode: PermissionMode): string {
    return mode === 'default' ? 'claude\r' : `claude --permission-mode ${mode}\r`
  }

  private displayTitle(entry: TabEntry, idx: number): string {
    return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
  }

  /* ── persistence ─────────────────────────────────────────────────── */

  private persist(): void {
    try {
      const stored: StoredTab[] = this.tabs.map((t) => ({ id: t.session.id, title: t.customTitle }))
      localStorage.setItem(TABS_KEY, JSON.stringify(stored))
      localStorage.setItem(ACTIVE_KEY, String(this.activeIndex))
    } catch {
      // localStorage unavailable — run without persistence
    }
  }

  /** The active tab's server session id (null until it has attached). */
  activeSessionId(): string | null {
    return this.tabs[this.activeIndex]?.session.id ?? null
  }

  /** Focus the tab for `sessionId`, or open one that joins it (share-link target). */
  openSession(sessionId: string): void {
    const existing = this.tabs.findIndex((t) => t.session.id === sessionId)
    if (existing >= 0) {
      this.activate(existing)
      return
    }
    this.addEntry(sessionId, null)
    this.persist()
    this.rebuild()
    this.activate(this.tabs.length - 1)
  }

  /* ── tab lifecycle ───────────────────────────────────────────────── */

  private addEntry(
    sessionId: string | null,
    customTitle: string | null,
    cwd?: string,
    initialInput?: string,
  ): TabEntry {
    // Build the session FIRST so `entry` is never typed with a null session.
    // The callbacks below capture `entry` by reference; they only fire after
    // construction (async), by which point `entry` is assigned.
    let entry: TabEntry
    const session = new TerminalSession({
      sessionId,
      ...(cwd !== undefined ? { cwd } : {}),
      ...(initialInput !== undefined ? { initialInput } : {}),
      onSessionId: () => {
        this.persist()
        // v3: a monitor toggled before attach completed (session.id was null, so
        // applyLayout's monitor branch fell through to the live pane) engages now
        // that the id is available — reconcile instead of waiting for the next
        // unrelated structural re-render.
        if (entry.monitor && this.isVisible(this.tabs.indexOf(entry))) this.updateHomeView()
      },
      // onActivity only fires for hidden (inactive) panes (see TerminalSession).
      onActivity: () => {
        entry.hasActivity = true
        this.refreshTab(entry)
      },
      onTitle: (title) => {
        entry.autoTitle = title.trim() || null
        this.refreshTab(entry)
      },
      onStatus: () => this.refreshTab(entry),
      onClaudeStatus: (status) => {
        this.refreshTab(entry)
        this.updateApprovalBar()
        // Notify only when a tab that needs approval is NOT on screen. In a split
        // grid an on-board quadrant is visible and glows amber in place, so an OS
        // notification would be redundant — but a pane hidden behind the ⌂ home
        // overlay is NOT on screen even if isVisible() (which only knows the
        // layout) would say so, so factor in homeForced too.
        const onScreen = !this.homeForced && this.isVisible(this.tabs.indexOf(entry))
        if (status === 'waiting' && !onScreen) {
          this.notify(entry)
        }
      },
      // B2: telemetry is the single source of truth on the session; just re-render.
      onTelemetry: () => this.refreshTab(entry),
      // W2: pending inject-queue depth changed — re-render the "N queued" badge.
      onQueue: () => this.refreshTab(entry),
      // Split-grid: clicking anywhere in this pane makes it the focused quadrant.
      onFocus: () => this.setFocused(this.tabs.indexOf(entry)),
    })
    entry = {
      session,
      customTitle,
      autoTitle: null,
      hasActivity: false,
      el: null,
      cell: null,
      monitor: false,
      monitorHandle: null,
      monitorEl: null,
      timelineHandle: null,
    }
    // Wrap the pane in a grid cell (header + terminal + optional inline-approve).
    // The header is hidden by CSS in single mode, so the classic one-pane look is
    // unchanged; in a grid it labels each quadrant and carries the focus ring.
    const cell = document.createElement('div')
    cell.className = 'term-cell'
    cell.style.display = 'none'
    const head = this.buildCellHead()
    head.addEventListener('pointerdown', () => this.setFocused(this.tabs.indexOf(entry)))
    // v2: ⛶ maximize/restore this quadrant (focuses it first).
    const maxBtn = document.createElement('button')
    maxBtn.type = 'button'
    maxBtn.className = 'cell-max'
    maxBtn.title = 'Maximize / restore'
    maxBtn.setAttribute('aria-label', 'Maximize or restore this pane')
    maxBtn.addEventListener('pointerdown', (e) => e.stopPropagation())
    maxBtn.addEventListener('click', (e) => {
      e.stopPropagation()
      const idx = this.tabs.indexOf(entry)
      if (idx !== this.activeIndex) this.setFocused(idx)
      this.toggleMaximize()
    })
    // v3: 👁 monitor toggle — read-only preview (no PTY resize) vs live pane.
    const monBtn = document.createElement('button')
    monBtn.type = 'button'
    monBtn.className = 'cell-monitor-btn'
    monBtn.textContent = '👁'
    monBtn.title = 'Monitor (read-only) / go live'
    monBtn.setAttribute('aria-label', 'Toggle read-only monitor for this pane')
    monBtn.addEventListener('pointerdown', (e) => e.stopPropagation())
    monBtn.addEventListener('click', (e) => {
      e.stopPropagation()
      this.toggleMonitor(this.tabs.indexOf(entry))
    })
    // 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)
    entry.cell = cell
    this.paneHost.appendChild(cell)
    this.tabs.push(entry)
    session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
    session.connect()
    return entry
  }

  newTab(): void {
    // M6: open the new tab in the active tab's current directory, if known.
    const cwd = this.tabs[this.activeIndex]?.session.cwd ?? undefined
    this.addEntry(null, null, cwd)
    this.persist()
    this.rebuild()
    this.activate(this.tabs.length - 1)
  }

  /** 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}\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', mode?: PermissionMode): void {
    const n = this.countOpenWithTitlePrefix(repoName)
    const label = n === 0 ? repoName : `${repoName} #${n + 1}`
    // B4-FR2: when a permission mode is chosen, upgrade the launch command to
    // `claude --permission-mode <mode>` (non-default only); 'auto' is gated (M5).
    const effectiveCmd = mode !== undefined ? this.buildClaudeCmd(this.resolveMode(mode)) : cmd
    this.addEntry(null, label, repoPath || undefined, effectiveCmd)
    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
  }

  /* ── 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,
    // else keybar/voice/approval would target an invisible (display:none) session
    // with no focus ring. Pull an off-board tab onto the last slot first, then
    // resolve its new index. (Single mode has no board, so this is a no-op.)
    if (this.gridLayout !== 'single' && !this.isVisible(i)) {
      const entry = this.tabs[i]
      this.moveTab(i, layoutCapacity(this.gridLayout) - 1)
      i = entry ? this.tabs.indexOf(entry) : layoutCapacity(this.gridLayout) - 1
    }
    this.maybeAskNotify() // first switch is a user gesture — request notif permission
    this.homeForced = false // showing a terminal dismisses any home overlay
    this.activeIndex = i
    const entry = this.tabs[i]
    if (entry) entry.hasActivity = false // viewing clears the unread dot
    this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
    // Pane show/hide + focus is owned by applyLayout (via updateHomeView): in
    // single mode only the active pane shows; in a grid the first-N show and the
    // active one is the focused quadrant.
    this.updateHomeView() // hide the seg control + home panels now a tab is active
    this.updateApprovalBar()
    if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session
    this.persist()
  }

  closeTab(i: number): void {
    if (i < 0 || i >= this.tabs.length) return
    this.maximized = false // structural change exits maximize
    const [entry] = this.tabs.splice(i, 1)
    entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
    if (entry) this.stopMonitor(entry) // v3: stop the monitor poll + preview term
    entry?.session.dispose() // removes session.el (the .term-pane)
    entry?.cell?.remove() // also drop the grid cell wrapper
    if (this.editingIndex === i) this.editingIndex = -1
    if (this.tabs.length === 0) {
      // v0.5: closing the last tab returns to the home screen — no auto-blank tab.
      this.activeIndex = -1
      this.homeForced = false // back to the natural "no tabs → home" state
      this.persist()
      this.rebuild() // updateHomeView() (in rebuild) shows the chooser
      this.updateApprovalBar()
      return
    }
    const next = Math.min(i, this.tabs.length - 1)
    this.persist()
    this.rebuild()
    this.activate(next)
  }

  private moveTab(from: number, to: number): void {
    if (from < 0 || to < 0 || from === to || from >= this.tabs.length || to >= this.tabs.length) {
      return
    }
    const activeEntry = this.tabs[this.activeIndex]
    const [moved] = this.tabs.splice(from, 1)
    if (!moved) return
    this.tabs.splice(to, 0, moved)
    if (activeEntry) this.activeIndex = this.tabs.indexOf(activeEntry)
    this.persist()
    this.rebuild()
  }

  private commitRename(i: number, value: string): void {
    const entry = this.tabs[i]
    if (entry) {
      const trimmed = value.trim()
      entry.customTitle = trimmed.length > 0 ? trimmed : null // empty → revert to auto
    }
    this.editingIndex = -1
    this.persist()
    this.rebuild()
  }

  /** Route key-bar input to the currently active tab. */
  sendToActive(data: string): void {
    this.tabs[this.activeIndex]?.session.send(data)
  }

  /** Re-assert the active tab's size (latest-writer-wins) when this device
   *  regains focus, so a shared session snaps back to this screen's size. */
  refitActive(): void {
    this.tabs[this.activeIndex]?.session.refit()
  }

  /** Apply theme + font settings to every terminal (M3). */
  applySettings(s: Settings): void {
    this.settings = s
    const theme = THEMES[s.theme] ?? THEMES['dark']!
    for (const t of this.tabs) t.session.applyTheme(theme, s.fontSize)
  }

  /** A read-only view of all tabs for the dashboard (M7). */
  snapshot(): Array<{
    idx: number
    title: string
    conn: string
    claude: string
    active: boolean
    pending: boolean
  }> {
    return this.tabs.map((t, idx) => ({
      idx,
      title: this.displayTitle(t, idx),
      conn: t.session.status,
      claude: t.session.claudeStatus,
      active: idx === this.activeIndex,
      pending: t.session.pendingApproval,
    }))
  }

  focusTab(idx: number): void {
    this.activate(idx)
  }

  /** Scrollback search in the active tab (M1). */
  findInActive(query: string, dir: 'next' | 'prev'): void {
    const s = this.tabs[this.activeIndex]?.session
    if (!s) return
    if (dir === 'next') s.findNext(query)
    else s.findPrevious(query)
  }

  clearActiveSearch(): void {
    this.tabs[this.activeIndex]?.session.clearSearch()
  }

  /** Ask for notification permission once, on a user gesture (H2/H4). */
  private maybeAskNotify(): void {
    if (this.notifyAsked) return
    this.notifyAsked = true
    if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
      void Notification.requestPermission()
    }
  }

  /** Browser notification for a background tab needing approval. */
  private notify(entry: TabEntry): void {
    if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return
    const title = this.displayTitle(entry, this.tabs.indexOf(entry))
    new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' })
  }

  /* ── split-grid (watch board) ────────────────────────────────────── */

  /** Whether tab `idx` is on-screen (the focused pane in single mode; any of the
   *  first-N in a grid). Also gates OS-notification suppression. */
  private isVisible(idx: number): boolean {
    return isVisibleIndex(idx, this.activeIndex, this.tabs.length, this.gridLayout)
  }

  /** Make tab `idx` the focused pane (via the board-aware activate). */
  private setFocused(idx: number): void {
    if (idx < 0 || idx >= this.tabs.length) return
    if (idx === this.activeIndex) return // already focused — avoid churn
    this.activate(idx) // activate() pulls an off-board tab onto the grid first
  }

  /** The current split-grid layout (for the toolbar toggle). */
  getGridLayout(): GridLayout {
    return this.gridLayout
  }

  /** Switch layout (from the toolbar toggle). Persists, keeps the focused pane
   *  on-board under a smaller capacity, then re-renders. */
  setGridLayout(layout: GridLayout): void {
    if (layout === this.gridLayout) {
      this.gridToggle?.refresh()
      return
    }
    this.gridLayout = layout
    this.maximized = false // a layout change exits maximize
    saveGridLayout(layout)
    // Re-activate the focused pane under the new layout: activate() is board-aware,
    // so if the capacity shrank and the pane is now off-board it gets pulled on;
    // otherwise it's just a re-render. With no active tab (home), re-apply the class.
    if (this.activeIndex >= 0 && this.activeIndex < this.tabs.length) {
      this.activate(this.activeIndex)
    } else {
      this.updateHomeView()
    }
    this.gridToggle?.refresh()
  }

  /** Register the toolbar toggle so setGridLayout can re-sync its pressed state. */
  setGridToggle(toggle: GridToggle): void {
    this.gridToggle = toggle
  }

  /** v3: the current layout + its split, for saving as a preset. */
  gridArrangement(): { layout: GridLayout; split: TrackSplit } {
    return { layout: this.gridLayout, split: splitForLayout(this.gridSplits, this.gridLayout) }
  }

  /** v3: apply a saved preset — its layout plus its custom track fractions. */
  applyGridPreset(preset: { layout: GridLayout; split: TrackSplit }): void {
    this.gridSplits = { ...this.gridSplits, [preset.layout]: preset.split }
    saveGridSplits(this.gridSplits)
    if (preset.layout === this.gridLayout) {
      this.updateHomeView() // same layout, new split → just re-apply the template
      this.gridToggle?.refresh()
    } else {
      this.setGridLayout(preset.layout)
    }
  }

  /** Re-assert every VISIBLE pane's size (latest-writer-wins) when this device
   *  regains focus — in a grid all visible quadrants reclaim size, not just the
   *  focused one. */
  refitVisible(): void {
    this.tabs.forEach((entry, idx) => {
      if (this.isVisible(idx)) entry.session.refit()
    })
  }

  /** v2: cycle the focused quadrant among the visible panes (wired to Ctrl+`). */
  cycleFocus(dir: 1 | -1): void {
    if (this.gridLayout === 'single') return
    const cap = Math.min(this.tabs.length, layoutCapacity(this.gridLayout))
    if (cap <= 1) return
    const cur = this.activeIndex >= 0 && this.activeIndex < cap ? this.activeIndex : 0
    this.setFocused((cur + dir + cap) % cap)
  }

  /** v2: expand/restore the focused quadrant to fill the grid (others stay live
   *  behind it via CSS grid-area + z-index). No-op in single mode. */
  toggleMaximize(): void {
    if (this.gridLayout === 'single') return
    this.maximized = !this.maximized
    this.updateHomeView()
  }

  /** v3: flip a quadrant between live (interactive) and read-only monitor mode. */
  private toggleMonitor(idx: number): void {
    const entry = this.tabs[idx]
    if (!entry || this.gridLayout === 'single') return
    entry.monitor = !entry.monitor
    this.updateHomeView() // applyLayout starts/stops the monitor
  }

  /** Begin polling a read-only preview into the cell (idempotent). */
  private startMonitor(entry: TabEntry, cell: HTMLDivElement): void {
    if (entry.monitorHandle || entry.session.id === null) return
    const host = document.createElement('div')
    host.className = 'cell-monitor'
    cell.insertBefore(host, entry.session.el)
    entry.monitorEl = host
    entry.monitorHandle = mountCellMonitor(host, entry.session.id)
  }

  /** Stop monitoring and tear down the preview (idempotent). */
  private stopMonitor(entry: TabEntry): void {
    if (entry.monitorHandle) {
      entry.monitorHandle.dispose()
      entry.monitorHandle = null
    }
    if (entry.monitorEl) {
      entry.monitorEl.remove()
      entry.monitorEl = null
    }
  }

  /** v2: make a grid cell a drop target for a tab dragged from the tab bar —
   *  dropping assigns that tab to this cell's slot (grid only) and focuses it. */
  private wireCellDropTarget(cell: HTMLElement, slotIndex: () => number): void {
    cell.addEventListener('dragover', (e) => {
      if (this.dragIndex < 0 || this.gridLayout === 'single') return
      e.preventDefault()
      cell.classList.add('drag-target')
    })
    cell.addEventListener('dragleave', () => cell.classList.remove('drag-target'))
    cell.addEventListener('drop', (e) => {
      cell.classList.remove('drag-target')
      if (this.dragIndex < 0 || this.gridLayout === 'single') return
      e.preventDefault()
      const moved = this.tabs[this.dragIndex]
      this.moveTab(this.dragIndex, slotIndex())
      this.dragIndex = -1
      if (moved) this.setFocused(this.tabs.indexOf(moved))
    })
  }

  /** Show/hide panes, set the grid class, order cells, and render placeholders.
   *  Single source of pane visibility (called via updateHomeView). */
  private applyLayout(showHome: boolean): void {
    const layout: GridLayout = showHome ? 'single' : this.gridLayout
    this.paneHost.classList.remove(
      'lay-single',
      'lay-split-2',
      'lay-row-3',
      'lay-grid-4',
      'lay-grid-6',
    )
    this.paneHost.classList.add(`lay-${layout}`)
    // A single 'grid' marker keeps the shared cell CSS (headers, focus ring,
    // inline approve, …) independent of how many layouts exist.
    this.paneHost.classList.toggle('grid', layout !== 'single')

    // v3: apply custom track fractions (draggable splitters) as inline templates;
    // clear them in single mode so #term is not a grid.
    if (layout !== 'single') {
      const sp = splitForLayout(this.gridSplits, layout)
      this.paneHost.style.gridTemplateColumns = tracksToTemplate(sp.cols)
      this.paneHost.style.gridTemplateRows = tracksToTemplate(sp.rows)
    } else {
      this.paneHost.style.gridTemplateColumns = ''
      this.paneHost.style.gridTemplateRows = ''
    }

    const cap = layoutCapacity(this.gridLayout)
    const visibleCount = showHome ? 0 : Math.min(this.tabs.length, cap)

    this.tabs.forEach((entry, idx) => {
      const cell = entry.cell
      if (!cell) return
      const visible = !showHome && this.isVisible(idx)
      cell.style.order = String(idx) // grid places visible cells in tab order
      if (visible && entry.monitor && entry.session.id !== null && layout !== 'single') {
        // Monitor mode: keep the live pane hidden (so it never fits/resizes the
        // shared PTY) and show a polled read-only preview instead.
        cell.style.display = 'flex'
        entry.session.hide()
        this.startMonitor(entry, cell)
      } else if (visible) {
        cell.style.display = 'flex'
        this.stopMonitor(entry)
        entry.session.show({ focus: idx === this.activeIndex })
      } else {
        cell.style.display = 'none'
        this.stopMonitor(entry)
        entry.session.hide()
      }
      this.renderCell(entry)
    })

    // Fill empty grid slots (fewer sessions than the layout holds) with a
    // "+ New session" placeholder so the board reads as a fixed N-up grid.
    const placeholders =
      showHome || this.gridLayout === 'single' ? 0 : Math.max(0, cap - visibleCount)
    this.renderPlaceholders(placeholders, visibleCount)
    this.renderGutters(layout)
  }

  /* ── v3: draggable splitters ──────────────────────────────────────── */

  /** Replace the gutter handles for the current layout (none in single mode). */
  private renderGutters(layout: GridLayout): void {
    // Never rebuild handles mid-drag — the dragged element owns the pointer
    // listeners and would lose them (existing handles stay valid for this layout).
    if (this.draggingGutter) return
    this.paneHost.querySelectorAll('.grid-gutter').forEach((g) => g.remove())
    if (layout === 'single') return
    const { cols, rows } = layoutTracks(layout)
    const sp = splitForLayout(this.gridSplits, layout)
    this.placeAxisGutters('col', cols, sp.cols, true)
    this.placeAxisGutters('row', rows, sp.rows, true)
  }

  /** Create (create=true) or reposition the gutter handles for one axis. */
  private placeAxisGutters(
    axis: 'col' | 'row',
    count: number,
    fractions: number[],
    create: boolean,
  ): void {
    const total = fractions.reduce((a, b) => a + b, 0) || 1
    const existing = create
      ? null
      : this.paneHost.querySelectorAll<HTMLElement>(`.grid-gutter-${axis}`)
    let acc = 0
    for (let g = 0; g < count - 1; g++) {
      acc += fractions[g] ?? 1
      const pct = (acc / total) * 100
      const handle = create ? this.makeGutter(axis, g) : existing?.[g]
      if (!handle) continue
      if (axis === 'col') handle.style.left = `${pct}%`
      else handle.style.top = `${pct}%`
      if (create) this.paneHost.appendChild(handle)
    }
  }

  private makeGutter(axis: 'col' | 'row', gutterIndex: number): HTMLElement {
    const g = document.createElement('div')
    g.className = `grid-gutter grid-gutter-${axis}`
    g.setAttribute('role', 'separator')
    g.setAttribute('aria-orientation', axis === 'col' ? 'vertical' : 'horizontal')
    g.addEventListener('pointerdown', (e) => this.beginGutterDrag(e, axis, gutterIndex))
    return g
  }

  /** Drag a gutter: translate pixel motion into an fr delta and re-template live. */
  private beginGutterDrag(e: PointerEvent, axis: 'col' | 'row', gutterIndex: number): void {
    const layout = this.gridLayout
    if (layout === 'single') return
    e.preventDefault()
    e.stopPropagation()
    const handle = e.currentTarget as HTMLElement
    const rect = this.paneHost.getBoundingClientRect()
    const area = axis === 'col' ? rect.width : rect.height
    const start = axis === 'col' ? e.clientX : e.clientY
    const base = splitForLayout(this.gridSplits, layout)
    const startFractions = (axis === 'col' ? base.cols : base.rows).slice()
    const total = startFractions.reduce((a, b) => a + b, 0)
    this.draggingGutter = true
    try {
      handle.setPointerCapture(e.pointerId)
    } catch {
      // setPointerCapture may be unavailable (e.g. jsdom) — drag still works
    }
    const move = (ev: PointerEvent): void => {
      if (area <= 0) return
      const pos = axis === 'col' ? ev.clientX : ev.clientY
      const delta = ((pos - start) / area) * total
      this.setSplit(layout, axis, adjustSplit(startFractions, gutterIndex, delta))
    }
    const up = (): void => {
      this.draggingGutter = false
      handle.removeEventListener('pointermove', move)
      handle.removeEventListener('pointerup', up)
      handle.removeEventListener('pointercancel', up)
      saveGridSplits(this.gridSplits) // persist only on release
    }
    handle.addEventListener('pointermove', move)
    handle.addEventListener('pointerup', up)
    handle.addEventListener('pointercancel', up)
  }

  /** Apply a new fraction array live (template + gutter positions), no persist. */
  private setSplit(layout: GridLayout, axis: 'col' | 'row', fractions: number[]): void {
    const cur = splitForLayout(this.gridSplits, layout)
    const next: TrackSplit =
      axis === 'col' ? { cols: fractions, rows: cur.rows } : { cols: cur.cols, rows: fractions }
    this.gridSplits = { ...this.gridSplits, [layout]: next }
    if (axis === 'col') this.paneHost.style.gridTemplateColumns = tracksToTemplate(fractions)
    else this.paneHost.style.gridTemplateRows = tracksToTemplate(fractions)
    const { cols, rows } = layoutTracks(layout)
    this.placeAxisGutters(axis, axis === 'col' ? cols : rows, fractions, false)
  }

  /** Replace the placeholder cells with `count` new ones ordered after the panes. */
  private renderPlaceholders(count: number, startOrder: number): void {
    this.paneHost.querySelectorAll('.term-cell.slot-empty').forEach((e) => e.remove())
    for (let i = 0; i < count; i++) {
      const slot = document.createElement('div')
      slot.className = 'term-cell slot-empty'
      slot.style.order = String(startOrder + i)
      const btn = document.createElement('button')
      btn.type = 'button'
      btn.className = 'slot-new'
      const plus = document.createElement('b')
      plus.textContent = '+'
      const label = document.createElement('span')
      label.textContent = 'New session'
      btn.append(plus, label)
      btn.addEventListener('click', () => this.newTab())
      slot.appendChild(btn)
      this.paneHost.appendChild(slot)
    }
  }

  /** Build a cell header (name + Claude-status chip). Hidden by CSS in single mode. */
  private buildCellHead(): HTMLDivElement {
    const head = document.createElement('div')
    head.className = 'cell-head'
    const name = document.createElement('span')
    name.className = 'cell-name'
    const status = document.createElement('span')
    status.className = 'cell-status'
    head.append(name, status)
    return head
  }

  /** Sync one cell's header text, focus ring, pending glow, and inline approve. */
  private renderCell(entry: TabEntry): void {
    const cell = entry.cell
    if (!cell) return
    const idx = this.tabs.indexOf(entry)
    const focused = idx === this.activeIndex
    const grid = this.gridLayout !== 'single' && !this.homeForced && this.tabs.length > 0
    const maximized = grid && this.maximized && focused
    cell.classList.toggle('focused', grid && focused)
    // While a quadrant is maximized, a covered (non-focused) pane's amber pulse
    // would bleed around the overlay — keep it silent until un-maximized.
    cell.classList.toggle(
      'pending',
      grid && entry.session.pendingApproval && (!this.maximized || focused),
    )
    cell.classList.toggle('maximized', maximized)
    const maxBtn = cell.querySelector<HTMLElement>('.cell-max')
    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)
    const chip = cell.querySelector<HTMLElement>('.cell-status')
    if (chip) {
      const cs = entry.session.claudeStatus
      chip.className = `cell-status cs-${cs}`
      const glyph = claudeIcon(cs)
      chip.textContent = cs === 'unknown' ? '' : glyph ? `${glyph} ${cs}` : cs
    }
    this.renderInlineApprove(entry, cell, idx, focused, grid)
  }

  /** Inline ✓/✗ footer for a visible NON-focused quadrant with a simple 'tool'
   *  gate. The focused pane uses the global approval bar (which also handles the
   *  3-choice 'plan' gate); a non-focused 'plan' gate just glows amber — click to
   *  focus and resolve via the bar. */
  private renderInlineApprove(
    entry: TabEntry,
    cell: HTMLDivElement,
    idx: number,
    focused: boolean,
    grid: boolean,
  ): void {
    const session = entry.session
    const wantInline =
      grid &&
      !focused &&
      this.isVisible(idx) &&
      session.pendingApproval &&
      session.pendingGate === 'tool'

    let footer = cell.querySelector<HTMLElement>('.cell-approve')
    if (!wantInline) {
      footer?.remove()
      return
    }
    if (!footer) {
      footer = document.createElement('div')
      footer.className = 'cell-approve'
      footer.addEventListener('pointerdown', (e) => e.stopPropagation()) // don't refocus
      const label = document.createElement('span')
      label.className = 'cell-approve-label'
      const btns = document.createElement('span')
      btns.className = 'cell-approve-btns'
      const yes = document.createElement('button')
      yes.type = 'button'
      yes.className = 'cell-approve-yes'
      yes.textContent = '✓'
      yes.title = 'Approve'
      yes.addEventListener('click', () => {
        session.approve()
        this.updateApprovalBar()
        this.renderCell(entry)
      })
      const no = document.createElement('button')
      no.type = 'button'
      no.className = 'cell-approve-no'
      no.textContent = '✗'
      no.title = 'Reject'
      no.addEventListener('click', () => {
        session.reject()
        this.updateApprovalBar()
        this.renderCell(entry)
      })
      btns.append(yes, no)
      footer.append(label, btns)
      cell.appendChild(footer)
    }
    const label = footer.querySelector('.cell-approve-label')
    if (label) label.textContent = session.pendingTool ? `Approve ${session.pendingTool}?` : 'Approve?'
  }

  /* ── rendering ───────────────────────────────────────────────────── */

  /** In-place update of one tab's classes/label/dot/gauge (never destroys DOM).
   *  Single `refreshTab` owner for status dot, stuck badge (A5) and telemetry
   *  gauge (B2), per SP10/M4. */
  private refreshTab(entry: TabEntry): void {
    // Keep the grid cell (header text, focus ring, pending glow, inline approve)
    // in sync too — independent of whether the tab-bar element exists yet.
    this.renderCell(entry)
    const el = entry.el
    if (!el) return
    const idx = this.tabs.indexOf(entry)
    el.classList.toggle('active', idx === this.activeIndex)
    el.classList.toggle('unread', entry.hasActivity && idx !== this.activeIndex)
    const cs = entry.session.claudeStatus
    el.classList.toggle('claude-waiting', cs === 'waiting' && idx !== this.activeIndex)
    el.classList.toggle('claude-stuck', cs === 'stuck' && idx !== this.activeIndex) // A5
    const title = this.displayTitle(entry, idx)
    el.title = title
    const dot = el.querySelector('.tab-dot')
    if (dot) dot.className = `tab-dot dot-${entry.session.status}`
    const label = el.querySelector('.tab-label')
    if (label) label.textContent = title
    const claude = el.querySelector('.tab-claude')
    if (claude) claude.textContent = claudeIcon(cs)
    // W2: "⧗N" when the session has queued follow-ups, else empty (badge hidden).
    const queue = el.querySelector('.tab-queue')
    if (queue) {
      const n = entry.session.queueLength
      queue.textContent = n > 0 ? `⧗${n}` : ''
    }
    const gauge = el.querySelector<HTMLElement>('.tab-gauge')
    if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS, this.costBudgetUsd) // B2 + W3(b)
  }

  /** Full rebuild — ONLY for structural changes (add/close/reorder/rename). */
  private rebuild(): void {
    this.tabBar.replaceChildren()

    // Home (⌂): overlay the Sessions/Projects chooser over the open tabs so you
    // can start another session/project without closing the current one.
    const home = document.createElement('button')
    home.className = 'tab-home'
    home.textContent = '⌂'
    home.title = 'Home — Sessions & Projects'
    home.setAttribute('aria-label', 'Home — Sessions & Projects')
    home.addEventListener('click', () => this.toggleHome())
    this.tabBar.appendChild(home)
    this.homeBtn = home

    this.tabs.forEach((entry, idx) => {
      const tabEl = document.createElement('div')
      entry.el = tabEl
      tabEl.className = 'tab'

      // Switch on pointerdown (fires before any drag; reliable with draggable, works on touch).
      tabEl.addEventListener('pointerdown', () => this.activate(idx))
      // Middle-click closes.
      tabEl.addEventListener('auxclick', (e) => {
        if (e.button === 1) {
          e.preventDefault()
          this.closeTab(idx)
        }
      })

      // Drag-to-reorder (disabled while renaming).
      if (idx !== this.editingIndex) {
        tabEl.draggable = true
        tabEl.addEventListener('dragstart', (e) => {
          this.dragIndex = idx
          e.dataTransfer?.setData('text/plain', String(idx))
          tabEl.classList.add('dragging')
        })
        tabEl.addEventListener('dragover', (e) => {
          e.preventDefault()
          tabEl.classList.add('drag-over')
        })
        tabEl.addEventListener('dragleave', () => tabEl.classList.remove('drag-over'))
        tabEl.addEventListener('drop', (e) => {
          e.preventDefault()
          tabEl.classList.remove('drag-over')
          this.moveTab(this.dragIndex, idx)
        })
        tabEl.addEventListener('dragend', () => {
          this.dragIndex = -1
          tabEl.classList.remove('dragging')
        })
      }

      const dot = document.createElement('span')
      dot.className = `tab-dot dot-${entry.session.status}`
      tabEl.appendChild(dot)

      if (idx === this.editingIndex) {
        const input = document.createElement('input')
        input.className = 'tab-rename'
        input.value = this.displayTitle(entry, idx)
        input.addEventListener('keydown', (e) => {
          if (e.key === 'Enter') this.commitRename(idx, input.value)
          else if (e.key === 'Escape') {
            this.editingIndex = -1
            this.rebuild()
          }
          e.stopPropagation()
        })
        input.addEventListener('blur', () => this.commitRename(idx, input.value))
        input.addEventListener('pointerdown', (e) => e.stopPropagation()) // don't re-activate
        tabEl.appendChild(input)
        requestAnimationFrame(() => {
          input.focus()
          input.select()
        })
      } else {
        const label = document.createElement('span')
        label.className = 'tab-label'
        label.textContent = this.displayTitle(entry, idx)
        label.addEventListener('dblclick', () => {
          this.editingIndex = idx
          this.rebuild()
        })
        tabEl.appendChild(label)

        const claude = document.createElement('span')
        claude.className = 'tab-claude'
        tabEl.appendChild(claude)

        // W2: pending inject-queue depth badge (filled in by refreshTab).
        const queue = document.createElement('span')
        queue.className = 'tab-queue'
        tabEl.appendChild(queue)

        // B2: per-tab telemetry gauge container (filled in by refreshTab).
        const gauge = document.createElement('span')
        gauge.className = 'tab-gauge'
        tabEl.appendChild(gauge)

        const close = document.createElement('button')
        close.className = 'tab-close'
        close.textContent = '×'
        close.title = 'Close tab (the shell keeps running on the server)'
        close.setAttribute('aria-label', 'Close tab')
        close.addEventListener('pointerdown', (e) => e.stopPropagation())
        close.addEventListener('click', (e) => {
          e.stopPropagation()
          this.closeTab(idx)
        })
        tabEl.appendChild(close)
      }

      // Apply current active/unread state.
      this.refreshTab(entry)
      this.tabBar.appendChild(tabEl)
    })

    const add = document.createElement('button')
    add.className = 'tab-add'
    add.textContent = '+'
    add.title = 'New session'
    add.setAttribute('aria-label', 'New session')
    add.addEventListener('click', () => this.newTab())
    this.tabBar.appendChild(add)

    // A4 timeline toggle + A1 push bell — re-appended each rebuild so they
    // survive tabBar.replaceChildren() (the bell's subtree is mounted once).
    const tl = document.createElement('button')
    tl.className = 'tab-timeline'
    tl.innerHTML = ICON_TIMELINE
    tl.dataset.tip = 'Activity timeline'
    tl.setAttribute('aria-label', 'Activity timeline')
    tl.classList.toggle('active', this.timelineOpen)
    tl.addEventListener('click', () => this.toggleTimeline())
    this.timelineBtn = tl
    this.tabBar.appendChild(tl)
    this.tabBar.appendChild(this.pushHost)

    // Show/hide the home screen based on whether any tab is open.
    this.updateHomeView()
  }
}
