/**
 * 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, type ProjectsPanel } from './projects.js'
import type { ClaudeStatus, PermissionMode, UiConfig } from '../src/types.js'
import { renderTelemetryGauge } from './preview-grid.js'
import { mountPushToggle } from './push.js'
import { mountQuickReply } from './quick-reply.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'

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'

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)
  // 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
  // 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
  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),
    })

    this.segControl = this.buildSegControl()
    this.paneHost.appendChild(this.segControl)

    // 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
    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) })
  }

  /** 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
  }

  /** 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
      }
    } 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: hide every terminal pane so the chooser is on top.
      for (const t of this.tabs) t.session.hide()
      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)
    }
  }

  /** 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'
    if (session.pendingGate === 'plan') {
      label.textContent = 'Claude finished planning — how should it proceed?'
      this.approvalBar.append(label, ...this.planGateButtons(session))
    } else {
      label.textContent = `Claude wants to use ${session.pendingTool ?? 'a tool'}`
      this.approvalBar.append(label, ...this.toolGateButtons(session))
    }
    this.approvalBar.style.display = 'flex'
  }

  /** 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(),
      // 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 when a background tab needs approval (H2/H4).
        if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
          this.notify(entry)
        }
      },
      // B2: telemetry is the single source of truth on the session; just re-render.
      onTelemetry: () => this.refreshTab(entry),
    })
    entry = {
      session,
      customTitle,
      autoTitle: null,
      hasActivity: false,
      el: null,
      timelineHandle: null,
    }
    this.paneHost.appendChild(session.el)
    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
  }

  activate(i: number): void {
    if (i < 0 || i >= this.tabs.length) return
    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, idx) => (idx === i ? t.session.show() : t.session.hide()))
    this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
    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
    const [entry] = this.tabs.splice(i, 1)
    entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
    entry?.session.dispose()
    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' })
  }

  /* ── 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 {
    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)
    const gauge = el.querySelector<HTMLElement>('.tab-gauge')
    if (gauge) renderTelemetryGauge(gauge, entry.session.telemetry, STATUSLINE_TTL_MS) // B2
  }

  /** 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)

        // 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()
  }
}
