"use client"

import Image from "next/image"
import { useEffect, useRef, useCallback, useState, useMemo, KeyboardEvent, DragEvent, ClipboardEvent } from "react"
import { MessagesSquare, SendHorizonal, Check, Eye, EyeOff, Play, Loader2, Milestone, X, MessageCircle, FileEdit, FilePlus, Terminal, ChevronDown, ChevronRight, MoreHorizontal, Zap, Square, Pause, BarChart3, LayoutGrid, ListOrdered, History, Compass, PenLine, Inbox, SkipForward, Undo2, BookOpen, Settings, SlidersHorizontal, Stethoscope, FileOutput, Trash2, Globe, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { ChatMessage, TuiPrompt } from "@/lib/pty-chat-parser"
import { PendingImage, processImageFile, generateImageId, MAX_PENDING_IMAGES } from "@/lib/image-utils"
import {
  useGSDWorkspaceState,
  useGSDWorkspaceActions,
  buildPromptCommand,
  type CompletedToolExecution,
  type ActiveToolExecution,
  type PendingUiRequest,
  type TurnSegment,
} from "@/lib/sf-workspace-store"
import { deriveWorkflowAction } from "@/lib/workflow-actions"
import { useTerminalFontSize } from "@/lib/use-terminal-font-size"

/* ─── ActionPanel types ─── */

// ActionPanelConfig removed — all commands now route through the main bridge.

/* ─── GSD Action Definitions ─── */

/**
 * Defines every /gsd subcommand available in the chat input bar.
 * Top 3 are shown as standalone buttons; the rest live in the overflow menu.
 * All commands dispatch through the main bridge session.
 */
interface GSDActionDef {
  label: string
  command: string
  icon: LucideIcon
  description: string
  category: "workflow" | "visibility" | "correction" | "knowledge" | "config" | "maintenance"
  /** When true, this command is disabled while auto-mode is active (injects competing LLM prompt) */
  disabledDuringAuto?: boolean
}

const GSD_ACTIONS: GSDActionDef[] = [
  // ── Top 3 (standalone buttons) ──
  { label: "Discuss",   command: "/gsd discuss",   icon: MessageCircle,     description: "Start guided milestone/slice discussion",                    category: "workflow",    disabledDuringAuto: true },
  { label: "Next",      command: "/gsd next",      icon: Play,              description: "Execute next task, then pause",                              category: "workflow" },
  { label: "Auto",      command: "/gsd auto",      icon: Zap,               description: "Run all queued units continuously",                         category: "workflow" },
  // ── Overflow: Workflow ──
  { label: "Stop",      command: "/gsd stop",      icon: Square,            description: "Stop auto-mode gracefully",                                  category: "workflow" },
  { label: "Pause",     command: "/gsd pause",     icon: Pause,             description: "Pause auto-mode (preserves state)",                          category: "workflow" },
  // ── Overflow: Visibility ──
  { label: "Status",    command: "/gsd status",    icon: BarChart3,         description: "Show progress dashboard",                                    category: "visibility" },
  { label: "Visualize", command: "/gsd visualize", icon: LayoutGrid,        description: "Interactive TUI (progress, deps, metrics, timeline)",        category: "visibility" },
  { label: "Queue",     command: "/gsd queue",     icon: ListOrdered,       description: "Show queued/dispatched units and execution order",            category: "visibility" },
  { label: "History",   command: "/gsd history",   icon: History,           description: "View execution history with cost/phase/model details",        category: "visibility" },
  // ── Overflow: Course correction ──
  { label: "Steer",     command: "/gsd steer",     icon: Compass,           description: "Apply user override to active work",                         category: "correction" },
  { label: "Capture",   command: "/gsd capture",   icon: PenLine,           description: "Quick-capture a thought to CAPTURES.md",                     category: "correction" },
  { label: "Triage",    command: "/gsd triage",    icon: Inbox,             description: "Classify and route pending captures",                        category: "correction",  disabledDuringAuto: true },
  { label: "Skip",      command: "/gsd skip",      icon: SkipForward,       description: "Prevent a unit from auto-mode dispatch",                     category: "correction" },
  { label: "Undo",      command: "/gsd undo",      icon: Undo2,             description: "Revert last completed unit",                                 category: "correction" },
  // ── Overflow: Knowledge ──
  { label: "Knowledge", command: "/gsd knowledge", icon: BookOpen,          description: "Add rule, pattern, or lesson to KNOWLEDGE.md",               category: "knowledge" },
  // ── Overflow: Configuration ──
  { label: "Mode",      command: "/gsd mode",      icon: SlidersHorizontal, description: "Set workflow mode (solo/team)",                               category: "config" },
  { label: "Prefs",     command: "/gsd prefs",     icon: Settings,          description: "Manage preferences (global/project)",                        category: "config" },
  // ── Overflow: Maintenance ──
  { label: "Doctor",    command: "/gsd doctor",    icon: Stethoscope,       description: "Diagnose and repair .gsd/ state",                            category: "maintenance" },
  { label: "Export",    command: "/gsd export",    icon: FileOutput,        description: "Export milestone/slice results (JSON or Markdown)",           category: "maintenance" },
  { label: "Cleanup",   command: "/gsd cleanup",   icon: Trash2,            description: "Remove merged branches or snapshots",                        category: "maintenance" },
  { label: "Remote",    command: "/gsd remote",    icon: Globe,             description: "Control remote auto-mode (Slack/Discord)",                    category: "maintenance" },
]

/** Top 3 shown as standalone buttons next to chat input */
const TOP_ACTIONS = GSD_ACTIONS.slice(0, 3)
/** Remaining actions in the overflow menu */
const OVERFLOW_ACTIONS = GSD_ACTIONS.slice(3)

const CATEGORY_LABELS: Record<GSDActionDef["category"], string> = {
  workflow: "Workflow",
  visibility: "Visibility",
  correction: "Course Correction",
  knowledge: "Knowledge",
  config: "Configuration",
  maintenance: "Maintenance",
}

function groupByCategory(actions: GSDActionDef[]): Array<{ category: GSDActionDef["category"]; label: string; items: GSDActionDef[] }> {
  const seen = new Map<GSDActionDef["category"], GSDActionDef[]>()
  for (const a of actions) {
    let group = seen.get(a.category)
    if (!group) {
      group = []
      seen.set(a.category, group)
    }
    group.push(a)
  }
  return Array.from(seen.entries()).map(([cat, items]) => ({ category: cat, label: CATEGORY_LABELS[cat], items }))
}

/**
 * ChatMode — main view for the Chat tab.
 *
 * All /gsd commands dispatch through the main bridge session.
 * Commands that inject competing LLM prompts (discuss, triage)
 * are disabled while auto-mode is active.
 *
 * Observability:
 *   - This component mounts only when activeView === "chat" (no hidden pre-init).
 *   - sessionStorage key "gsd-active-view:<cwd>" equals "chat" when this view is active.
 *   - Header toolbar: data-testid="chat-mode-action-bar" confirms toolbar rendered.
 *   - Primary button: data-testid="chat-primary-action" reflects current workflowAction label.
 *   - Secondary buttons: data-testid="chat-secondary-action-{command}".
 */
export function ChatMode({ className }: { className?: string }) {
  const state = useGSDWorkspaceState()
  const { sendCommand } = useGSDWorkspaceActions()

  const bridge = state.boot?.bridge ?? null

  const handleAction = useCallback(
    (command: string) => {
      void sendCommand(buildPromptCommand(command, bridge))
    },
    [sendCommand, bridge],
  )

  return (
    <div className={cn("flex h-full flex-col overflow-hidden bg-background", className)}>
      {/* ── Header bar ── */}
      <ChatModeHeader
        onPrimaryAction={handleAction}
        onSecondaryAction={handleAction}
      />

      {/* ── Main chat pane ── */}
      <ChatPane
        sessionId="gsd-main"
        command="gsd"
        className="flex-1"
        onOpenAction={(action) => handleAction(action.command)}
      />
    </div>
  )
}

/* ─── Header ─── */

interface ChatModeHeaderProps {
  onPrimaryAction: (command: string) => void
  onSecondaryAction: (command: string) => void
}

/**
 * ChatModeHeader — action toolbar for Chat Mode.
 *
 * Single-row layout matching the Power User Mode header: title + badge left-aligned,
 * workflow action buttons immediately to the right (no second row).
 *
 * Observability:
 *   - data-testid="chat-mode-action-bar" on the workflow button row
 *   - data-testid="chat-primary-action" on the primary button
 *   - data-testid="chat-secondary-action-{command}" on each secondary button
 */
function ChatModeHeader({ onPrimaryAction, onSecondaryAction }: ChatModeHeaderProps) {
  const state = useGSDWorkspaceState()

  const boot = state.boot
  const workspace = boot?.workspace ?? null
  const auto = boot?.auto ?? null

  const workflowAction = deriveWorkflowAction({
    phase: workspace?.active.phase ?? "pre-planning",
    autoActive: auto?.active ?? false,
    autoPaused: auto?.paused ?? false,
    onboardingLocked: boot?.onboarding.locked ?? false,
    commandInFlight: state.commandInFlight,
    bootStatus: state.bootStatus,
    hasMilestones: (workspace?.milestones.length ?? 0) > 0,
    projectDetectionKind: boot?.projectDetection?.kind ?? null,
  })

  const handlePrimary = () => {
    if (!workflowAction.primary) return
    onPrimaryAction(workflowAction.primary.command)
  }

  // Derive a short GSD state badge label
  const stateBadge = (() => {
    if (state.bootStatus !== "ready") return state.bootStatus
    const phase = workspace?.active.phase
    if (!phase) return "idle"
    if (auto?.active && !auto?.paused) return "auto"
    if (auto?.paused) return "paused"
    return phase
  })()

  return (
    <div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
      {/* Left: title + state badge */}
      <div className="flex items-center gap-2">
        <MessagesSquare className="h-4 w-4 text-muted-foreground" />
        <span className="font-medium">Chat Mode</span>
        <span className="rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wide">
          {stateBadge}
        </span>
      </div>

      {/* Right: workflow action buttons */}
      <div className="flex items-center gap-2" data-testid="chat-mode-action-bar">
          {workflowAction.primary && (
            <button
              data-testid="chat-primary-action"
              onClick={handlePrimary}
              disabled={workflowAction.disabled}
              className={cn(
                "inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors",
                workflowAction.primary.variant === "destructive"
                  ? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
                  : "bg-primary text-primary-foreground hover:bg-primary/90",
                workflowAction.disabled && "cursor-not-allowed opacity-50",
              )}
              title={workflowAction.disabledReason}
            >
              {state.commandInFlight ? (
                <Loader2 className="h-3 w-3 animate-spin" />
              ) : workflowAction.isNewMilestone ? (
                <Milestone className="h-3 w-3" />
              ) : (
                <Play className="h-3 w-3" />
              )}
              {workflowAction.primary.label}
            </button>
          )}
          {workflowAction.secondaries.map((action) => (
            <button
              key={action.command}
              data-testid={`chat-secondary-action-${action.command}`}
              onClick={() => onSecondaryAction(action.command)}
              disabled={workflowAction.disabled}
              className={cn(
                "inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-medium transition-colors hover:bg-accent",
                workflowAction.disabled && "cursor-not-allowed opacity-50",
              )}
              title={workflowAction.disabledReason}
            >
              {action.label}
            </button>
          ))}
          {state.commandInFlight && (
            <span className="flex items-center gap-1 text-xs text-muted-foreground">
              <Loader2 className="h-3 w-3 animate-spin" />
            </span>
          )}
        </div>
    </div>
  )
}


type ShikiHighlighter = {
  codeToHtml: (code: string, options: { lang: string; theme: string }) => string
}

let chatHighlighterPromise: Promise<ShikiHighlighter> | null = null

function getChatHighlighter(): Promise<ShikiHighlighter> {
  if (!chatHighlighterPromise) {
    chatHighlighterPromise = import("shiki")
      .then((mod) =>
        mod.createHighlighter({
          themes: ["github-dark-default", "github-light-default"],
          langs: [
            "typescript", "tsx", "javascript", "jsx",
            "json", "jsonc", "markdown", "mdx",
            "css", "scss", "less", "html", "xml",
            "yaml", "toml", "bash", "python", "ruby",
            "rust", "go", "java", "kotlin", "swift",
            "c", "cpp", "csharp", "php", "sql",
            "graphql", "dockerfile", "makefile",
            "lua", "diff", "ini", "dotenv",
          ],
        }),
      )
      .catch((err) => {
        chatHighlighterPromise = null
        throw err
      })
  }
  return chatHighlighterPromise
}

/* ─── Markdown renderer for assistant bubbles ─── */

/**
 * Renders markdown content using react-markdown + remark-gfm + shiki code blocks.
 * Dynamic imports keep the main bundle lean.
 * Falls back to plain text if modules fail to load.
 *
 * Observability:
 *   - console.debug("[ChatBubble] markdown modules loaded") fires once on first render
 */
function MarkdownContent({ content }: { content: string }) {
  const [rendered, setRendered] = useState<React.ReactNode | null>(null)
  const [ready, setReady] = useState(false)
  const isDark = useIsDark()

  useEffect(() => {
    let cancelled = false

    Promise.all([
      import("react-markdown"),
      import("remark-gfm"),
      getChatHighlighter(),
    ])
      .then(([ReactMarkdownMod, remarkGfmMod, highlighter]) => {
        if (cancelled) return
        console.debug("[ChatBubble] markdown modules loaded")

        const ReactMarkdown = ReactMarkdownMod.default
        const remarkGfm = remarkGfmMod.default

        const shikiTheme = isDark ? "github-dark-default" : "github-light-default"

        const buildComponents = (h: typeof highlighter) => ({
          code({ className, children, ...props }: React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }) {
            const match = /language-(\w+)/.exec(className || "")
            const codeStr = String(children).replace(/\n$/, "")

            if (match) {
              try {
                const highlighted = h.codeToHtml(codeStr, {
                  lang: match[1],
                  theme: shikiTheme,
                })
                return (
                  <div
                    className="chat-code-block my-3 rounded-xl overflow-x-auto text-sm shadow-sm border border-border/50"
                    dangerouslySetInnerHTML={{ __html: highlighted }}
                  />
                )
              } catch { /* unsupported language — fall through */ }
            }

            const isInline = !className && !String(children).includes("\n")
            if (isInline) {
              return (
                <code
                  className="rounded-md bg-muted px-1.5 py-0.5 text-[0.85em] font-mono text-foreground"
                  {...props}
                >
                  {children}
                </code>
              )
            }

            return (
              <pre className={cn("my-3 overflow-x-auto rounded-xl p-4 text-sm border border-border/50", isDark ? "bg-[#0d1117]" : "bg-[#f6f8fa]")}>
                <code className="font-mono">{children}</code>
              </pre>
            )
          },
          pre({ children }: { children?: React.ReactNode }) {
            return <>{children}</>
          },
          table({ children }: { children?: React.ReactNode }) {
            return (
              <div className="my-4 overflow-x-auto rounded-lg border border-border">
                <table className="min-w-full border-collapse text-sm">{children}</table>
              </div>
            )
          },
          th({ children }: { children?: React.ReactNode }) {
            return (
              <th className="border-b border-border bg-muted/50 px-3 py-2 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wide">
                {children}
              </th>
            )
          },
          td({ children }: { children?: React.ReactNode }) {
            return (
              <td className="border-b border-border/50 px-3 py-2 text-sm last:border-0">
                {children}
              </td>
            )
          },
          a({ href, children }: { href?: string; children?: React.ReactNode }) {
            return (
              <a
                href={href}
                className="text-info underline underline-offset-2 hover:text-info transition-colors"
                target="_blank"
                rel="noopener noreferrer"
              >
                {children}
              </a>
            )
          },
          h1({ children }: { children?: React.ReactNode }) {
            return <h1 className="mt-4 mb-2 text-base font-semibold text-foreground first:mt-0">{children}</h1>
          },
          h2({ children }: { children?: React.ReactNode }) {
            return <h2 className="mt-3 mb-1.5 text-sm font-semibold text-foreground first:mt-0">{children}</h2>
          },
          h3({ children }: { children?: React.ReactNode }) {
            return <h3 className="mt-2 mb-1 text-sm font-medium text-foreground first:mt-0">{children}</h3>
          },
          ul({ children }: { children?: React.ReactNode }) {
            return <ul className="my-2 ml-4 list-disc space-y-0.5 text-sm [&>li]:text-foreground">{children}</ul>
          },
          ol({ children }: { children?: React.ReactNode }) {
            return <ol className="my-2 ml-4 list-decimal space-y-0.5 text-sm [&>li]:text-foreground">{children}</ol>
          },
          blockquote({ children }: { children?: React.ReactNode }) {
            return <blockquote className="my-3 border-l-2 border-primary/40 pl-3 text-sm text-muted-foreground italic">{children}</blockquote>
          },
          hr() {
            return <hr className="my-4 border-border/50" />
          },
          p({ children }: { children?: React.ReactNode }) {
            return <p className="mb-2 text-sm leading-relaxed last:mb-0 text-foreground">{children}</p>
          },
          img({ alt, src }: { alt?: string; src?: string }) {
            return (
              <span className="my-2 block rounded-lg border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground italic">
                🖼 {alt || src || "image"}
              </span>
            )
          },
        })

        setRendered(
          <ReactMarkdown remarkPlugins={[remarkGfm]} components={buildComponents(highlighter) as import("react-markdown").Components}>
            {content}
          </ReactMarkdown>,
        )
        setReady(true)
      })
      .catch(() => {
        if (!cancelled) setReady(true)
      })

    return () => { cancelled = true }
   
  }, [content, isDark]) // re-render when content changes (streaming) or theme toggles

  if (!ready) {
    // Plain text fallback while modules load
    return (
      <span className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
        {content}
      </span>
    )
  }

  if (!rendered) {
    return (
      <span className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
        {content}
      </span>
    )
  }

  return <div className="chat-markdown min-w-0">{rendered}</div>
}

/* ─── TuiSelectPrompt ─── */

/**
 * Renders a GSD arrow-key select prompt as a native clickable list.
 *
 * Clicking an option calculates the arrow-key delta from the current
 * PTY-tracked selection, sends that many \x1b[A/\x1b[B + \r to the PTY,
 * and transitions to a static post-submission state.
 *
 * Observability:
 *   - Logs "[TuiSelectPrompt] mounted kind=select label=%s" on mount
 *   - Logs "[TuiSelectPrompt] submit delta=%d keystrokes=%j" on submit
 *   - data-testid="tui-select-prompt" on container
 *   - data-testid="tui-select-option-{i}" on each option button
 *   - data-testid="tui-prompt-submitted" on post-submission element
 */
function TuiSelectPrompt({
  prompt,
  onSubmit,
}: {
  prompt: TuiPrompt
  onSubmit: (data: string) => void
}) {
  const [localIndex, setLocalIndex] = useState(prompt.selectedIndex ?? 0)
  const [submitted, setSubmitted] = useState(false)
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    console.log("[TuiSelectPrompt] mounted kind=select label=%s", prompt.label)
    // Auto-focus the container so keyboard events are captured immediately
    containerRef.current?.focus()
  }, [prompt.label])

  const submitIndex = useCallback(
    (clickedIndex: number) => {
      const delta = clickedIndex - localIndex
      let keystrokes = ""
      if (delta > 0) {
        keystrokes = "\x1b[B".repeat(delta)
      } else if (delta < 0) {
        keystrokes = "\x1b[A".repeat(Math.abs(delta))
      }
      keystrokes += "\r"

      console.log(
        "[TuiSelectPrompt] submit delta=%d keystrokes=%j",
        delta,
        keystrokes,
      )

      setLocalIndex(clickedIndex)
      setSubmitted(true)
      onSubmit(keystrokes)
    },
    [localIndex, onSubmit],
  )

  const handleKeyDown = useCallback(
    (e: KeyboardEvent<HTMLDivElement>) => {
      if (submitted) return
      if (e.key === "ArrowUp") {
        e.preventDefault()
        setLocalIndex((i) => Math.max(0, i - 1))
      } else if (e.key === "ArrowDown") {
        e.preventDefault()
        setLocalIndex((i) => Math.min(prompt.options.length - 1, i + 1))
      } else if (e.key === "Enter") {
        e.preventDefault()
        submitIndex(localIndex)
      }
    },
    [submitted, localIndex, prompt.options.length, submitIndex],
  )

  if (submitted) {
    const selectedLabel = prompt.options[localIndex] ?? ""
    return (
      <div
        data-testid="tui-prompt-submitted"
        className="mt-2 flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary"
      >
        <Check className="h-3.5 w-3.5 flex-shrink-0" />
        <span className="font-medium">{selectedLabel}</span>
      </div>
    )
  }

  return (
    <div
      ref={containerRef}
      data-testid="tui-select-prompt"
      tabIndex={0}
      onKeyDown={handleKeyDown}
      className="mt-2 rounded-xl border border-border bg-background p-1.5 shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-border"
      aria-label={`Select: ${prompt.label}`}
      role="listbox"
      aria-activedescendant={`tui-select-option-${localIndex}`}
    >
      {prompt.label && (
        <p className="mb-1.5 px-2 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
          {prompt.label}
        </p>
      )}
      {prompt.options.map((option, i) => {
        const isSelected = i === localIndex
        const description = prompt.descriptions?.[i]
        return (
          <button
            key={i}
            id={`tui-select-option-${i}`}
            data-testid={`tui-select-option-${i}`}
            role="option"
            aria-selected={isSelected}
            onClick={() => submitIndex(i)}
            className={cn(
              "flex w-full items-start gap-2 rounded-lg px-3 py-1.5 text-left text-sm transition-colors",
              isSelected
                ? "bg-primary/15 text-primary font-medium"
                : "text-foreground hover:bg-muted",
            )}
          >
            <span className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center">
              {isSelected ? (
                <Check className="h-3 w-3 text-primary" />
              ) : (
                <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30" />
              )}
            </span>
            <span className="min-w-0">
              <span className="block">{option}</span>
              {description && (
                <span className="mt-0.5 block text-xs font-normal text-muted-foreground">
                  {description}
                </span>
              )}
            </span>
          </button>
        )
      })}
    </div>
  )
}

/* ─── TuiTextPrompt ─── */

/**
 * Renders a GSD text prompt as a native labeled input field.
 *
 * Submitting sends the typed value + "\r" to the PTY (carriage return = Enter).
 * After submission shows a static "✓ Submitted" confirmation (value not echoed).
 *
 * Observability:
 *   - Logs "[TuiTextPrompt] mounted kind=text label=%s" on mount
 *   - Logs "[TuiTextPrompt] submitted label=%s" on submit
 *   - data-testid="tui-text-prompt" on container
 *   - data-testid="tui-prompt-submitted" on post-submission element
 */
function TuiTextPrompt({
  prompt,
  onSubmit,
}: {
  prompt: TuiPrompt
  onSubmit: (data: string) => void
}) {
  const [value, setValue] = useState("")
  const [submitted, setSubmitted] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    console.log("[TuiTextPrompt] mounted kind=text label=%s", prompt.label)
    inputRef.current?.focus()
  }, [prompt.label])

  const handleSubmit = useCallback(() => {
    if (submitted) return
    console.log("[TuiTextPrompt] submitted label=%s", prompt.label)
    setSubmitted(true)
    onSubmit(value + "\r")
  }, [submitted, value, prompt.label, onSubmit])

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter") {
        e.preventDefault()
        handleSubmit()
      }
    },
    [handleSubmit],
  )

  if (submitted) {
    return (
      <div
        data-testid="tui-prompt-submitted"
        className="mt-2 flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary"
      >
        <Check className="h-3.5 w-3.5 flex-shrink-0" />
        <span className="font-medium">✓ Submitted</span>
      </div>
    )
  }

  return (
    <div
      data-testid="tui-text-prompt"
      className="mt-2 rounded-xl border border-border bg-background p-3 shadow-sm"
    >
      {prompt.label && (
        <p className="mb-2 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
          {prompt.label}
        </p>
      )}
      <div className="flex gap-2">
        <Input
          ref={inputRef}
          value={value}
          onChange={(e) => setValue(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Type your answer…"
          className="flex-1 h-8 text-sm"
          aria-label={prompt.label || "Text input"}
        />
        <button
          onClick={handleSubmit}
          disabled={!value.trim()}
          className={cn(
            "flex h-8 items-center justify-center rounded-lg px-3 text-xs font-medium transition-all",
            value.trim()
              ? "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-95 shadow-sm"
              : "bg-muted text-muted-foreground cursor-not-allowed",
          )}
        >
          Submit
        </button>
      </div>
    </div>
  )
}

/* ─── TuiPasswordPrompt ─── */

/**
 * Renders a GSD password/API-key prompt as a native masked input field.
 *
 * Submitting sends the typed value + "\r" to the PTY.
 * The entered value is NEVER shown in the DOM, logs, or post-submission text.
 * After submission shows "{label} — entered ✓" with no value echo.
 *
 * Observability:
 *   - Logs "[TuiPasswordPrompt] mounted kind=password label=%s" on mount
 *   - Logs "[TuiPasswordPrompt] submitted label=%s" on submit (value not logged)
 *   - data-testid="tui-password-prompt" on container
 *   - data-testid="tui-prompt-submitted" on post-submission element
 */
function TuiPasswordPrompt({
  prompt,
  onSubmit,
}: {
  prompt: TuiPrompt
  onSubmit: (data: string) => void
}) {
  const [value, setValue] = useState("")
  const [submitted, setSubmitted] = useState(false)
  const [showPassword, setShowPassword] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    console.log("[TuiPasswordPrompt] mounted kind=password label=%s", prompt.label)
    inputRef.current?.focus()
  }, [prompt.label])

  const handleSubmit = useCallback(() => {
    if (submitted) return
    // Value intentionally not logged — redaction constraint
    console.log("[TuiPasswordPrompt] submitted label=%s", prompt.label)
    setSubmitted(true)
    onSubmit(value + "\r")
  }, [submitted, value, prompt.label, onSubmit])

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter") {
        e.preventDefault()
        handleSubmit()
      }
    },
    [handleSubmit],
  )

  if (submitted) {
    const displayLabel = prompt.label || "Value"
    return (
      <div
        data-testid="tui-prompt-submitted"
        className="mt-2 flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary"
      >
        <Check className="h-3.5 w-3.5 flex-shrink-0" />
        <span className="font-medium">{displayLabel} — entered ✓</span>
      </div>
    )
  }

  return (
    <div
      data-testid="tui-password-prompt"
      className="mt-2 rounded-xl border border-border bg-background p-3 shadow-sm"
    >
      {prompt.label && (
        <p className="mb-2 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
          {prompt.label}
        </p>
      )}
      <div className="flex gap-2">
        <div className="relative flex-1">
          <Input
            ref={inputRef}
            type={showPassword ? "text" : "password"}
            value={value}
            onChange={(e) => setValue(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder="Enter value…"
            className="h-8 pr-9 text-sm"
            aria-label={prompt.label || "Password input"}
            autoComplete="off"
          />
          <button
            type="button"
            onClick={() => setShowPassword((s) => !s)}
            tabIndex={-1}
            aria-label={showPassword ? "Hide input" : "Show input"}
            className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-muted-foreground transition-colors"
          >
            {showPassword ? (
              <EyeOff className="h-3.5 w-3.5" />
            ) : (
              <Eye className="h-3.5 w-3.5" />
            )}
          </button>
        </div>
        <button
          onClick={handleSubmit}
          disabled={!value}
          className={cn(
            "flex h-8 items-center justify-center rounded-lg px-3 text-xs font-medium transition-all",
            value
              ? "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-95 shadow-sm"
              : "bg-muted text-muted-foreground cursor-not-allowed",
          )}
        >
          Submit
        </button>
      </div>
      <p className="mt-1.5 text-[10px] text-muted-foreground">
        Value is transmitted securely and not stored in chat history.
      </p>
    </div>
  )
}

/* ─── StreamingCursor ─── */

function StreamingCursor() {
  return (
    <span
      aria-hidden="true"
      className="ml-0.5 inline-block h-3.5 w-0.5 translate-y-0.5 rounded-full bg-current opacity-70"
      style={{ animation: "chat-cursor 1s ease-in-out infinite" }}
    />
  )
}


function createLocalMessageId(): string {
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
    return crypto.randomUUID()
  }
  return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`
}

/* ─── Theme detection hook ─── */

function useIsDark(): boolean {
  const [isDark, setIsDark] = useState(() =>
    typeof document !== "undefined" && document.documentElement.classList.contains("dark"),
  )
  useEffect(() => {
    if (typeof document === "undefined") return
    const el = document.documentElement
    const observer = new MutationObserver(() => {
      setIsDark(el.classList.contains("dark"))
    })
    observer.observe(el, { attributes: true, attributeFilter: ["class"] })
    return () => observer.disconnect()
  }, [])
  return isDark
}

/* ─── PlatformLogoIcon ─── */

/**
 * Renders the platform logo icon, dynamically switching between
 * light and dark variants based on the current theme.
 */
function PlatformLogoIcon({ className }: { className?: string }) {
  const isDark = useIsDark()
  return (
    <Image
      src={isDark ? "/logo-icon-white.svg" : "/logo-icon-black.svg"}
      alt=""
      width={24}
      height={32}
      unoptimized
      className={cn("h-4 w-auto", className)}
    />
  )
}

/* ─── InlineThinking ─── */

/**
 * Thinking indicator rendered inline inside an assistant bubble.
 * Shows a collapsible preview of the LLM's reasoning with a visible,
 * well-styled block that shows more context lines.
 */
function InlineThinking({ content, isStreaming }: { content: string; isStreaming: boolean }) {
  const [expanded, setExpanded] = useState(false)
  const scrollRef = useRef<HTMLDivElement>(null)
  const lines = content.split("\n").filter((l) => l.trim())
  const previewLines = lines.slice(-5)
  const hasMore = lines.length > 5

  // Auto-scroll the expanded view to the bottom when streaming
  useEffect(() => {
    if (expanded && isStreaming && scrollRef.current) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight
    }
  }, [expanded, isStreaming, content])

  return (
    <div className="mb-3">
      <button
        onClick={() => setExpanded((e) => !e)}
        className={cn(
          "group w-full rounded-xl border px-3.5 py-2.5 text-left transition-all",
          "border-border/50 bg-muted/50 hover:bg-muted/50",
        )}
      >
        {/* Header row */}
        <div className="flex items-center gap-2">
          {isStreaming ? (
            <span className="relative flex h-2 w-2 flex-shrink-0">
              <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-muted-foreground/30" />
              <span className="relative inline-flex h-2 w-2 rounded-full bg-muted-foreground/50" />
            </span>
          ) : (
            <span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded bg-muted-foreground/10">
              <span className="text-[9px] text-muted-foreground">💭</span>
            </span>
          )}
          <span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
            {isStreaming ? "Thinking…" : "Thought process"}
          </span>
          {hasMore && !expanded && (
            <span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
              {lines.length} lines
            </span>
          )}
          <span className="ml-auto flex-shrink-0">
            {expanded
              ? <ChevronDown className="h-3 w-3 text-muted-foreground transition-transform" />
              : <ChevronRight className="h-3 w-3 text-muted-foreground transition-transform group-hover:text-muted-foreground" />
            }
          </span>
        </div>

        {/* Collapsed preview — show 5 lines */}
        {!expanded && (
          <div className="mt-2 space-y-0.5 border-l-2 border-muted-foreground/10 pl-3">
            {previewLines.map((line, i) => (
              <p key={i} className="text-[12px] leading-relaxed text-muted-foreground line-clamp-1">
                {line}
              </p>
            ))}
            {isStreaming && <StreamingCursor />}
          </div>
        )}

        {/* Expanded view — scrollable with more space */}
        {expanded && (
          <div
            ref={scrollRef}
            className="mt-2 max-h-[400px] overflow-y-auto overscroll-contain rounded-lg border border-border/50 bg-background/50 p-3 text-[12px] leading-[1.7] text-muted-foreground whitespace-pre-wrap scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
          >
            {content}
            {isStreaming && <StreamingCursor />}
          </div>
        )}
      </button>
    </div>
  )
}

/* ─── ChatBubble ─── */

/**
 * Renders a single ChatMessage as a styled bubble.
 *
 * - assistant: left-aligned bubble with full markdown rendering + syntax-highlighted code blocks
 * - user: right-aligned outgoing bubble with plain text
 * - system: small centered muted line (no bubble chrome)
 * - incomplete messages show an animated streaming cursor
 * - when message.prompt.kind === 'select', TuiSelectPrompt renders below content
 */
function ChatBubble({
  message,
  onSubmitPrompt,
  isThinking,
}: {
  message: ChatMessage
  onSubmitPrompt?: (data: string) => void
  isThinking?: boolean
}) {
  if (message.role === "system") {
    return (
      <div className="flex items-center justify-center py-1">
        <span className="text-[11px] text-muted-foreground italic px-3">
          {message.content}
        </span>
      </div>
    )
  }

  if (message.role === "user") {
    return (
      <div className="flex justify-end">
        <div className="max-w-[72%] rounded-2xl rounded-br-md bg-primary px-4 py-2.5 text-sm text-primary-foreground shadow-sm">
          {message.images && message.images.length > 0 && (
            <div className="flex gap-1.5 mb-2 flex-wrap">
              {message.images.map((img, idx) => (
                <Image
                  key={idx}
                  src={`data:${img.mimeType};base64,${img.data}`}
                  alt={`Attached image ${idx + 1}`}
                  width={32}
                  height={32}
                  unoptimized
                  className="h-8 w-8 rounded object-cover border border-primary-foreground/20"
                />
              ))}
            </div>
          )}
          <span className="whitespace-pre-wrap leading-relaxed">{message.content}</span>
          {!message.complete && <StreamingCursor />}
        </div>
      </div>
    )
  }

  // assistant
  const hasSelectPrompt =
    message.prompt?.kind === "select" &&
    !message.complete &&
    onSubmitPrompt != null

  const hasTextPrompt =
    message.prompt?.kind === "text" &&
    !message.complete &&
    onSubmitPrompt != null

  const hasPasswordPrompt =
    message.prompt?.kind === "password" &&
    !message.complete &&
    onSubmitPrompt != null

  const hasAnyPrompt = hasSelectPrompt || hasTextPrompt || hasPasswordPrompt

  return (
    <div className="flex justify-start gap-3">
      <div className="mt-1 flex-shrink-0 flex h-7 w-7 items-center justify-center rounded-full bg-card border border-border">
        <PlatformLogoIcon className="h-3.5 w-auto" />
      </div>
      <div className="max-w-[82%] min-w-0 rounded-2xl rounded-tl-md border border-border bg-card px-4 py-3 shadow-sm">
        {/* Minimal waiting indicator — shown when streaming starts but no content yet */}
        {isThinking && !message.content && (
          <div className="flex items-center gap-2 py-1">
            <span className="relative flex h-2 w-2">
              <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-muted-foreground/30" />
              <span className="relative inline-flex h-2 w-2 rounded-full bg-muted-foreground/50" />
            </span>
            <span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
              Thinking…
            </span>
          </div>
        )}
        {message.content && <MarkdownContent content={message.content} />}
        {!message.complete && !hasAnyPrompt && <StreamingCursor />}
        {hasSelectPrompt && (
          <TuiSelectPrompt
            prompt={message.prompt!}
            onSubmit={onSubmitPrompt!}
          />
        )}
        {hasTextPrompt && (
          <TuiTextPrompt
            prompt={message.prompt!}
            onSubmit={onSubmitPrompt!}
          />
        )}
        {hasPasswordPrompt && (
          <TuiPasswordPrompt
            prompt={message.prompt!}
            onSubmit={onSubmitPrompt!}
          />
        )}
      </div>
    </div>
  )
}

/* ─── ChatMessageList ─── */

/**
 * Renders ChatMessage[] as a scrollable list of ChatBubble components.
 *
 * Scroll behavior:
 *   - Auto-scrolls to bottom on new messages ONLY when the user is within 100px of bottom
 *   - If the user has scrolled up to read history, auto-scroll is suppressed
 */
function ChatMessageList({
  messages,
  onSubmitPrompt,
  fontSize,
}: {
  messages: ChatMessage[]
  onSubmitPrompt: (data: string) => void
  fontSize?: number
}) {
  const scrollRef = useRef<HTMLDivElement>(null)
  const isNearBottomRef = useRef(true)
  const prevMessageCountRef = useRef(messages.length)

  const handleScroll = useCallback(() => {
    const el = scrollRef.current
    if (!el) return
    const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
    isNearBottomRef.current = distanceFromBottom < 100
  }, [])

  // Scroll to bottom on new messages (if user is near bottom)
  useEffect(() => {
    const el = scrollRef.current
    if (!el) return

    const isNewMessage = messages.length !== prevMessageCountRef.current
    prevMessageCountRef.current = messages.length

    if (isNearBottomRef.current) {
      el.scrollTop = el.scrollHeight
    }

    // If a new message arrives while scrolled up, still update the count but don't scroll
    void isNewMessage
  }, [messages])

  return (
    <div
      ref={scrollRef}
      onScroll={handleScroll}
      className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
      style={fontSize ? { fontSize: `${fontSize}px` } : undefined}
    >
      {messages.map((msg) => (
        <ChatBubble key={msg.id} message={msg} onSubmitPrompt={onSubmitPrompt} />
      ))}
      {/* Bottom spacer for scroll anchor */}
      <div className="h-2" />
    </div>
  )
}

/* ─── ChatInputBar ─── */

/**
 * Text input bar at the bottom of ChatPane.
 *
 * - Enter: send input + "\r" and clear
 * - Shift+Enter: insert newline (multiline)
 * - Disabled when disconnected; shows "Disconnected" badge
 * - Send button visible when input has content and connected
 * - Top 3 action buttons (Discuss, Next, Auto) shown standalone
 * - Overflow menu (⋯) contains all remaining /gsd subcommands grouped by category
 * - Every action has a tooltip with description on hover
 */
function ChatInputBar({
  onSendInput,
  connected,
  onOpenAction,
}: {
  onSendInput: (data: string, images?: PendingImage[]) => void
  connected: boolean
  onOpenAction?: (action: GSDActionDef) => void
}) {
  const autoActive = useGSDWorkspaceState().boot?.auto?.active ?? false
  const [value, setValue] = useState("")
  const [overflowOpen, setOverflowOpen] = useState(false)
  const [pendingImages, setPendingImages] = useState<PendingImage[]>([])
  const [isDragging, setIsDragging] = useState(false)
  const [imageNotice, setImageNotice] = useState<string | null>(null)
  const textareaRef = useRef<HTMLTextAreaElement>(null)
  const dragCounterRef = useRef(0)

  // Cleanup blob URLs on unmount
  useEffect(() => {
    return () => {
      pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl))
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  const addImages = useCallback(async (files: File[]) => {
    setImageNotice(null)

    const imageFiles = files.filter((f) => f.type.startsWith("image/"))
    if (imageFiles.length === 0) return

    setPendingImages((prev) => {
      const remaining = MAX_PENDING_IMAGES - prev.length
      if (remaining <= 0) {
        setImageNotice(`Maximum ${MAX_PENDING_IMAGES} images per message`)
        return prev
      }
      return prev // return current, processing happens below
    })

    // Process files outside setState to handle async
    const currentCount = pendingImages.length
    const toProcess = imageFiles.slice(0, MAX_PENDING_IMAGES - currentCount)

    if (toProcess.length < imageFiles.length) {
      setImageNotice(`Maximum ${MAX_PENDING_IMAGES} images per message`)
    }

    const newImages: PendingImage[] = []
    for (const file of toProcess) {
      try {
        const result = await processImageFile(file)
        const previewUrl = URL.createObjectURL(file)
        newImages.push({
          id: generateImageId(),
          data: result.data,
          mimeType: result.mimeType,
          previewUrl,
        })
      } catch (err) {
        console.warn("[chat-input] image processing failed:", err instanceof Error ? err.message : err)
        setImageNotice(err instanceof Error ? err.message : "Failed to process image")
      }
    }

    if (newImages.length > 0) {
      setPendingImages((prev) => {
        const combined = [...prev, ...newImages]
        if (combined.length > MAX_PENDING_IMAGES) {
          // Revoke excess
          combined.slice(MAX_PENDING_IMAGES).forEach((img) => URL.revokeObjectURL(img.previewUrl))
          setImageNotice(`Maximum ${MAX_PENDING_IMAGES} images per message`)
          return combined.slice(0, MAX_PENDING_IMAGES)
        }
        return combined
      })
    }
  }, [pendingImages.length])

  const removeImage = useCallback((id: string) => {
    setPendingImages((prev) => {
      const removed = prev.find((img) => img.id === id)
      if (removed) URL.revokeObjectURL(removed.previewUrl)
      return prev.filter((img) => img.id !== id)
    })
    setImageNotice(null)
  }, [])

  const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragging(false)
    dragCounterRef.current = 0
    const files = Array.from(e.dataTransfer.files)
    void addImages(files)
  }, [addImages])

  const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
    e.preventDefault()
    e.stopPropagation()
  }, [])

  const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
    e.preventDefault()
    e.stopPropagation()
    dragCounterRef.current += 1
    setIsDragging(true)
  }, [])

  const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
    e.preventDefault()
    e.stopPropagation()
    dragCounterRef.current -= 1
    if (dragCounterRef.current <= 0) {
      dragCounterRef.current = 0
      setIsDragging(false)
    }
  }, [])

  const handlePaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
    const items = e.clipboardData?.files
    if (items && items.length > 0) {
      const imageFiles = Array.from(items).filter((f) => f.type.startsWith("image/"))
      if (imageFiles.length > 0) {
        e.preventDefault()
        void addImages(imageFiles)
      }
      // If no image files in clipboard, let normal text paste proceed (no-regression)
    }
  }, [addImages])

  const handleSend = useCallback(() => {
    const trimmed = value.trim()
    if (!trimmed && pendingImages.length === 0) return
    if (!connected) return
    onSendInput(value + "\r", pendingImages.length > 0 ? pendingImages : undefined)
    setValue("")
    // Don't revoke URLs here — they'll be used in the chat bubble for the sent message
    setPendingImages([])
    setImageNotice(null)
    if (textareaRef.current) {
      textareaRef.current.style.height = "auto"
    }
  }, [value, connected, onSendInput, pendingImages])

  const handleKeyDown = useCallback(
    (e: KeyboardEvent<HTMLTextAreaElement>) => {
      if (e.key === "Enter" && !e.shiftKey) {
        e.preventDefault()
        handleSend()
      }
    },
    [handleSend],
  )

  const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setValue(e.target.value)
    const el = e.target
    el.style.height = "auto"
    el.style.height = `${Math.min(el.scrollHeight, 160)}px`
  }, [])

  const hasContent = value.trim().length > 0 || pendingImages.length > 0
  const overflowGroups = useMemo(() => groupByCategory(OVERFLOW_ACTIONS), [])

  return (
    <div className="flex-shrink-0 border-t border-border bg-card px-4 py-3 backdrop-blur-sm">
      <div
        className="flex items-end gap-2"
        onDrop={handleDrop}
        onDragOver={handleDragOver}
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
      >
        {/* Input + send button */}
        <div
          className={cn(
            "flex flex-1 flex-col rounded-xl border bg-background transition-colors",
            connected
              ? "border-border focus-within:ring-1 focus-within:ring-border/30"
              : "border-border/50 opacity-80",
            isDragging && connected && "border-primary/60 ring-2 ring-primary/20 bg-primary/5",
          )}
        >
          {/* Thumbnail preview row */}
          {pendingImages.length > 0 && (
            <div className="flex items-center gap-2 px-3 pt-2.5 pb-1 flex-wrap">
              {pendingImages.map((img) => (
                <div key={img.id} className="relative group flex-shrink-0">
                  <Image
                    src={img.previewUrl}
                    alt="Pending image"
                    width={48}
                    height={48}
                    unoptimized
                    className="h-12 w-12 rounded-lg object-cover border border-border/50"
                  />
                  <button
                    onClick={() => removeImage(img.id)}
                    aria-label="Remove image"
                    className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-destructive-foreground text-[10px] opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
                  >
                    <X className="h-2.5 w-2.5" />
                  </button>
                </div>
              ))}
              {imageNotice && (
                <span className="text-[10px] text-muted-foreground italic">{imageNotice}</span>
              )}
            </div>
          )}
          <div className="flex items-end gap-2">
          <textarea
            ref={textareaRef}
            value={value}
            onChange={handleInput}
            onKeyDown={handleKeyDown}
            onPaste={handlePaste}
            disabled={!connected}
            rows={1}
            aria-label="Send message"
            placeholder={
              connected
                ? "Message…"
                : "Connecting…"
            }
            className="min-h-[40px] flex-1 resize-none bg-transparent px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:text-muted-foreground"
            style={{ height: "40px", maxHeight: "160px", overflowY: "auto" }}
          />
          <div className="flex flex-shrink-0 items-end pb-1.5 pr-1.5 gap-1">
            {!connected && (
              <span className="px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wide">
                Disconnected
              </span>
            )}
            <button
              onClick={handleSend}
              disabled={!connected || !hasContent}
              aria-label="Send"
              className={cn(
                "flex h-7 w-7 items-center justify-center rounded-lg transition-all",
                hasContent && connected
                  ? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 active:scale-95"
                  : "bg-muted text-muted-foreground cursor-not-allowed",
              )}
            >
              <SendHorizonal className="h-3.5 w-3.5" />
            </button>
          </div>
        </div>
        </div>

        {/* ── Top 3 action buttons with tooltips ── */}
        {onOpenAction && (
          <TooltipProvider delayDuration={300}>
            {TOP_ACTIONS.map((action) => {
              const Icon = action.icon
              const isDisabled = action.disabledDuringAuto && autoActive
              return (
                <Tooltip key={action.command}>
                  <TooltipTrigger asChild>
                    <button
                      onClick={() => onOpenAction(action)}
                      disabled={isDisabled}
                      aria-label={action.description}
                      className={cn(
                        "flex flex-shrink-0 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 py-2.5 text-xs font-medium text-foreground transition-colors hover:bg-accent",
                        isDisabled && "cursor-not-allowed opacity-40",
                      )}
                    >
                      <Icon className="h-3.5 w-3.5 text-muted-foreground" />
                      {action.label}
                    </button>
                  </TooltipTrigger>
                  <TooltipContent side="top" sideOffset={6}>
                    <p className="font-medium">{action.label}</p>
                    <p className="text-[10px] opacity-80">
                      {isDisabled ? "Disabled while auto-mode is running" : action.description}
                    </p>
                  </TooltipContent>
                </Tooltip>
              )
            })}

            {/* ── Overflow menu ── */}
            <Popover open={overflowOpen} onOpenChange={setOverflowOpen}>
              <Tooltip>
                <TooltipTrigger asChild>
                  <PopoverTrigger asChild>
                    <button
                      aria-label="More GSD commands"
                      className={cn(
                        "flex flex-shrink-0 items-center justify-center rounded-xl border border-border bg-background p-2.5 text-foreground transition-colors hover:bg-accent",
                        overflowOpen && "bg-accent",
                      )}
                    >
                      <MoreHorizontal className="h-4 w-4 text-muted-foreground" />
                    </button>
                  </PopoverTrigger>
                </TooltipTrigger>
                {!overflowOpen && (
                  <TooltipContent side="top" sideOffset={6}>
                    More commands
                  </TooltipContent>
                )}
              </Tooltip>

              <PopoverContent
                side="top"
                align="end"
                sideOffset={8}
                className="w-64 max-h-[420px] overflow-y-auto rounded-xl border border-border bg-popover p-2 shadow-lg"
              >
                {overflowGroups.map((group, gi) => (
                  <div key={group.category}>
                    {gi > 0 && <div className="my-1.5 border-t border-border/50" />}
                    <p className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
                      {group.label}
                    </p>
                    {group.items.map((action) => {
                      const Icon = action.icon
                      const isDisabled = action.disabledDuringAuto && autoActive
                      return (
                        <Tooltip key={action.command}>
                          <TooltipTrigger asChild>
                            <button
                              onClick={() => {
                                if (isDisabled) return
                                setOverflowOpen(false)
                                onOpenAction(action)
                              }}
                              disabled={isDisabled}
                              className={cn(
                                "flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm text-foreground transition-colors hover:bg-accent",
                                isDisabled && "cursor-not-allowed opacity-40",
                              )}
                            >
                              <Icon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
                              <span className="flex-1 truncate">{action.label}</span>
                            </button>
                          </TooltipTrigger>
                          <TooltipContent side="left" sideOffset={8}>
                            <p className="font-medium">{action.label}</p>
                            <p className="text-[10px] opacity-80">
                              {isDisabled ? "Disabled while auto-mode is running" : action.description}
                            </p>
                          </TooltipContent>
                        </Tooltip>
                      )
                    })}
                  </div>
                ))}
              </PopoverContent>
            </Popover>
          </TooltipProvider>
        )}
      </div>
    </div>
  )
}

/* ─── Placeholder state ─── */

function PlaceholderState({
  connected,
  runningLabel,
  notice,
  primaryAction,
  onPrimaryAction,
}: {
  connected: boolean
  runningLabel?: string
  notice?: string | null
  primaryAction?: { label: string; icon: LucideIcon } | null
  onPrimaryAction?: () => void
}) {
  const showSpinner = connected && Boolean(runningLabel)

  return (
    <div className="flex flex-1 flex-col items-center justify-center text-center py-16">
      <div className="flex h-12 w-12 items-center justify-center rounded-full border border-border bg-card">
        {showSpinner ? (
          <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
        ) : (
          <MessagesSquare className="h-6 w-6 text-muted-foreground" />
        )}
      </div>
      <div className="mt-3 space-y-1">
        <p className="text-sm font-medium text-foreground">Chat Mode</p>
        {showSpinner ? (
          <p className="max-w-xs text-xs text-muted-foreground">
            Running {runningLabel}…
          </p>
        ) : notice ? (
          <p className="max-w-xs text-xs text-muted-foreground">{notice}</p>
        ) : !connected ? (
          <p className="max-w-xs text-xs text-muted-foreground">
            Connecting to GSD session…
          </p>
        ) : primaryAction && onPrimaryAction ? (
          <div className="mt-4">
            <button
              onClick={onPrimaryAction}
              className="inline-flex items-center gap-2 rounded-xl border border-border bg-background px-5 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent active:scale-[0.98]"
            >
              <primaryAction.icon className="h-4 w-4 text-muted-foreground" />
              {primaryAction.label}
            </button>
          </div>
        ) : (
          <p className="max-w-xs text-xs text-muted-foreground">
            Connected — waiting for GSD output…
          </p>
        )}
      </div>
    </div>
  )
}


/* ─── InlineUiRequest ─── */

/**
 * Renders a bridge-level PendingUiRequest inline in the chat message flow.
 * Supports select (single + multi), confirm, input, and editor requests.
 * After submission, transitions to a static confirmation state.
 *
 * The FocusedPanel (Sheet overlay in app-shell) is the fallback surface for
 * these same requests in non-chat views. Whichever the user interacts with
 * first resolves the request — the store deduplicates.
 */
function InlineUiRequest({ request }: { request: PendingUiRequest }) {
  const { respondToUiRequest, dismissUiRequest } = useGSDWorkspaceActions()
  const isSubmitting = useGSDWorkspaceState().commandInFlight === "extension_ui_response"

  const handleSubmit = useCallback((value: Record<string, unknown>) => {
    void respondToUiRequest(request.id, value)
  }, [respondToUiRequest, request.id])

  const handleDismiss = useCallback(() => {
    void dismissUiRequest(request.id)
  }, [dismissUiRequest, request.id])

  return (
    <div className="flex justify-start gap-3" data-testid="inline-ui-request" data-request-id={request.id}>
      <div className="mt-1 flex-shrink-0 flex h-7 w-7 items-center justify-center rounded-full bg-card border border-border">
        <PlatformLogoIcon className="h-3.5 w-auto" />
      </div>
      <div className="max-w-[82%] min-w-0 rounded-2xl rounded-tl-md border border-border bg-card px-4 py-3 shadow-sm">
        {request.title && (
          <p className="mb-2.5 text-sm font-medium text-foreground">{request.title}</p>
        )}
        {request.method === "select" && (
          <InlineSelect request={request} onSubmit={handleSubmit} disabled={isSubmitting} />
        )}
        {request.method === "confirm" && (
          <InlineConfirm request={request} onSubmit={handleSubmit} onDismiss={handleDismiss} disabled={isSubmitting} />
        )}
        {request.method === "input" && (
          <InlineInput request={request} onSubmit={handleSubmit} disabled={isSubmitting} />
        )}
        {request.method === "editor" && (
          <InlineEditor request={request} onSubmit={handleSubmit} disabled={isSubmitting} />
        )}
      </div>
    </div>
  )
}

function InlineSelect({
  request,
  onSubmit,
  disabled,
}: {
  request: Extract<PendingUiRequest, { method: "select" }>
  onSubmit: (value: Record<string, unknown>) => void
  disabled: boolean
}) {
  const isMulti = Boolean(request.allowMultiple)
  const [singleValue, setSingleValue] = useState("")
  const [multiValues, setMultiValues] = useState<Set<string>>(new Set())
  const [submitted, setSubmitted] = useState(false)

  const handleSubmit = useCallback(() => {
    setSubmitted(true)
    onSubmit({ value: isMulti ? Array.from(multiValues) : singleValue })
  }, [isMulti, singleValue, multiValues, onSubmit])

  if (submitted) {
    return (
      <div className="flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary">
        <Check className="h-3.5 w-3.5 flex-shrink-0" />
        <span className="font-medium">{isMulti ? `${multiValues.size} selected` : singleValue}</span>
      </div>
    )
  }

  const canSubmit = isMulti ? multiValues.size > 0 : singleValue !== ""

  return (
    <div className="space-y-1.5">
      {request.options.map((option, i) => {
        if (isMulti) {
          const checked = multiValues.has(option)
          return (
            <button
              key={i}
              onClick={() => {
                const next = new Set(multiValues)
                if (checked) next.delete(option); else next.add(option)
                setMultiValues(next)
              }}
              disabled={disabled}
              className={cn(
                "flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors",
                checked ? "bg-primary/15 text-primary font-medium" : "text-foreground hover:bg-muted",
              )}
            >
              <span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border border-border">
                {checked && <Check className="h-2.5 w-2.5 text-primary" />}
              </span>
              <span>{option}</span>
            </button>
          )
        }
        const selected = singleValue === option
        return (
          <button
            key={i}
            onClick={() => setSingleValue(option)}
            disabled={disabled}
            className={cn(
              "flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors",
              selected ? "bg-primary/15 text-primary font-medium" : "text-foreground hover:bg-muted",
            )}
          >
            <span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
              {selected ? (
                <Check className="h-3 w-3 text-primary" />
              ) : (
                <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30" />
              )}
            </span>
            <span>{option}</span>
          </button>
        )
      })}
      <button
        onClick={handleSubmit}
        disabled={disabled || !canSubmit}
        className={cn(
          "mt-2 flex w-full items-center justify-center rounded-lg px-3 py-2 text-xs font-medium transition-all",
          canSubmit && !disabled
            ? "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98] shadow-sm"
            : "bg-muted text-muted-foreground cursor-not-allowed",
        )}
      >
        {isMulti ? `Submit (${multiValues.size})` : "Submit"}
      </button>
    </div>
  )
}

function InlineConfirm({
  request,
  onSubmit,
  onDismiss,
  disabled,
}: {
  request: Extract<PendingUiRequest, { method: "confirm" }>
  onSubmit: (value: Record<string, unknown>) => void
  onDismiss: () => void
  disabled: boolean
}) {
  const [resolved, setResolved] = useState<boolean | null>(null)

  if (resolved !== null) {
    return (
      <div className="flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary">
        <Check className="h-3.5 w-3.5 flex-shrink-0" />
        <span className="font-medium">{resolved ? "Confirmed" : "Cancelled"}</span>
      </div>
    )
  }

  return (
    <div className="space-y-2.5">
      <p className="text-sm text-foreground leading-relaxed">{request.message}</p>
      <div className="flex gap-2">
        <button
          onClick={() => { setResolved(true); onSubmit({ value: true }) }}
          disabled={disabled}
          className="flex-1 rounded-lg bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90 active:scale-[0.98] shadow-sm transition-all"
        >
          Confirm
        </button>
        <button
          onClick={() => { setResolved(false); onDismiss() }}
          disabled={disabled}
          className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-xs font-medium text-foreground hover:bg-accent transition-colors"
        >
          Cancel
        </button>
      </div>
    </div>
  )
}

function InlineInput({
  request,
  onSubmit,
  disabled,
}: {
  request: Extract<PendingUiRequest, { method: "input" }>
  onSubmit: (value: Record<string, unknown>) => void
  disabled: boolean
}) {
  const [value, setValue] = useState("")
  const [submitted, setSubmitted] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => { inputRef.current?.focus() }, [])

  if (submitted) {
    return (
      <div className="flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary">
        <Check className="h-3.5 w-3.5 flex-shrink-0" />
        <span className="font-medium">Submitted</span>
      </div>
    )
  }

  const handleSubmit = () => {
    if (!value.trim() || disabled) return
    setSubmitted(true)
    onSubmit({ value })
  }

  return (
    <div className="flex gap-2">
      <Input
        ref={inputRef}
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleSubmit() } }}
        placeholder={request.placeholder || "Type your answer…"}
        disabled={disabled}
        className="flex-1 h-8 text-sm"
      />
      <button
        onClick={handleSubmit}
        disabled={disabled || !value.trim()}
        className={cn(
          "flex h-8 items-center justify-center rounded-lg px-3 text-xs font-medium transition-all",
          value.trim() && !disabled
            ? "bg-primary text-primary-foreground hover:bg-primary/90 active:scale-95 shadow-sm"
            : "bg-muted text-muted-foreground cursor-not-allowed",
        )}
      >
        Submit
      </button>
    </div>
  )
}

function InlineEditor({
  request,
  onSubmit,
  disabled,
}: {
  request: Extract<PendingUiRequest, { method: "editor" }>
  onSubmit: (value: Record<string, unknown>) => void
  disabled: boolean
}) {
  const [value, setValue] = useState(request.prefill || "")
  const [submitted, setSubmitted] = useState(false)

  if (submitted) {
    return (
      <div className="flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-2 text-sm text-primary">
        <Check className="h-3.5 w-3.5 flex-shrink-0" />
        <span className="font-medium">Submitted</span>
      </div>
    )
  }

  return (
    <div className="space-y-2">
      <textarea
        value={value}
        onChange={(e) => setValue(e.target.value)}
        disabled={disabled}
        className="w-full min-h-[120px] rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-border/30 resize-y"
        autoFocus
      />
      <button
        onClick={() => { setSubmitted(true); onSubmit({ value }) }}
        disabled={disabled}
        className="flex w-full items-center justify-center rounded-lg bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90 active:scale-[0.98] shadow-sm transition-all"
      >
        Submit
      </button>
    </div>
  )
}

/* ─── Chat Pane ─── */

interface ChatPaneProps {
  sessionId?: string
  command?: string
  commandArgs?: string[]
  className?: string
  initialCommand?: string
  onCompletionSignal?: () => void
  onOpenAction?: (action: GSDActionDef) => void
  activityLabel?: string
  suppressTerminalChrome?: boolean
  suppressInitialEcho?: boolean
}

/* ─── ToolExecutionBlock ─── */

/**
 * Renders a completed tool execution as a collapsible block.
 * Edit tool shows a syntax-highlighted unified diff.
 * Write tool shows the file path and a preview.
 * Bash tool shows the command and output.
 * Other tools show a compact summary.
 */
function ToolExecutionBlock({ tool }: { tool: CompletedToolExecution }) {
  const [expanded, setExpanded] = useState(false)
  const autoExpandedRef = useRef(false)
  const normalizedToolName = typeof tool.name === "string" ? tool.name.toLowerCase() : ""

  const path = typeof tool.args?.path === "string" ? tool.args.path : typeof tool.args?.file_path === "string" ? tool.args.file_path : null
  const shortPath = path ? (path.startsWith(process.env.HOME ?? "/Users") ? "~" + path.slice((process.env.HOME ?? "").length) : path) : null
  const isError = tool.result?.isError ?? false
  const diff = tool.result?.details?.diff as string | undefined

  // Choose icon and label
  const icon = normalizedToolName === "edit" ? <FileEdit className="h-3.5 w-3.5" />
    : normalizedToolName === "write" ? <FilePlus className="h-3.5 w-3.5" />
    : <Terminal className="h-3.5 w-3.5" />

  const label = normalizedToolName === "edit" ? "Edit"
    : normalizedToolName === "write" ? "Write"
    : normalizedToolName === "bash" ? "$"
    : tool.name

  // For bash, show the command
  const bashCommand = normalizedToolName === "bash" && typeof tool.args?.command === "string" ? tool.args.command : null

  // Result text (for bash output, read result, etc.)
  const resultText = tool.result?.content
    ?.filter((c) => c.type === "text" && c.text)
    .map((c) => c.text)
    .join("\n") ?? ""

  useEffect(() => {
    if (autoExpandedRef.current) return
    const hasVisibleResult = Boolean(diff || resultText.trim() || isError)
    if (!hasVisibleResult) return
    autoExpandedRef.current = true
    setExpanded(true)
  }, [diff, resultText, isError])

  return (
    <div className="flex justify-start gap-3">
      <div className="w-7 flex-shrink-0" />
      <div className="max-w-[82%] min-w-0 w-full">
        <button
          onClick={() => setExpanded((e) => !e)}
          className={cn(
            "w-full rounded-lg border px-3 py-2 text-left text-xs transition-colors",
            isError
              ? "border-destructive/30 bg-destructive/5 hover:bg-destructive/10"
              : "border-border/50 bg-muted/50 hover:bg-muted/50",
          )}
        >
          {/* Header */}
          <div className="flex items-center gap-2">
            <span className={cn("flex-shrink-0", isError ? "text-destructive" : "text-muted-foreground")}>
              {icon}
            </span>
            <span className={cn("font-mono font-medium", isError ? "text-destructive" : "text-muted-foreground")}>
              {label}
            </span>
            {shortPath && (
              <span className="truncate font-mono text-info/80">{shortPath}</span>
            )}
            {bashCommand && !shortPath && (
              <span className="truncate font-mono text-muted-foreground">{bashCommand.length > 60 ? bashCommand.slice(0, 60) + "…" : bashCommand}</span>
            )}
            <span className="ml-auto flex-shrink-0 text-muted-foreground">
              {expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
            </span>
          </div>

          {/* Expanded content */}
          {expanded && diff && (
            <div className="mt-2 overflow-x-auto rounded-md border border-border/50 bg-background p-2 font-mono text-[11px] leading-relaxed">
              {diff.split("\n").map((line, i) => {
                const isAdd = line.startsWith("+")
                const isRemove = line.startsWith("-")
                const isContext = line.startsWith(" ")
                return (
                  <div
                    key={i}
                    className={cn(
                      "whitespace-pre",
                      isAdd && "bg-success/10 text-success",
                      isRemove && "bg-destructive/10 text-destructive",
                      isContext && "text-muted-foreground",
                      !isAdd && !isRemove && !isContext && "text-muted-foreground",
                    )}
                  >
                    {line}
                  </div>
                )
              })}
            </div>
          )}

          {/* Expanded: bash output or other result */}
          {expanded && !diff && resultText && (
            <div className="mt-2 max-h-[200px] overflow-y-auto rounded-md border border-border/50 bg-background p-2 font-mono text-[11px] leading-relaxed text-muted-foreground whitespace-pre-wrap">
              {resultText.length > 2000 ? resultText.slice(0, 2000) + "\n…" : resultText}
            </div>
          )}

          {/* Error message */}
          {expanded && isError && resultText && (
            <div className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 p-2 text-[11px] text-destructive whitespace-pre-wrap">
              {resultText}
            </div>
          )}
        </button>
      </div>
    </div>
  )
}

/**
 * ChatPane — bridge event-driven chat rendering.
 *
 * Consumes structured agent events from the workspace store:
 * - streamingAssistantText: live text deltas from the LLM
 * - streamingThinkingText: live thinking/reasoning deltas
 * - liveTranscript: completed text blocks from previous turns
 * - activeToolExecution: currently running tool call
 *
 * User messages are tracked locally and sent via submitInput().
 * No terminal buffer parsing — all data comes from the bridge event stream.
 *
 * Observability:
 *   - data-testid="chat-pane-store-driven" on the root element
 *   - ChatInputBar shows "Disconnected" badge when bridge is not connected
 */
export function ChatPane({ className, onOpenAction }: ChatPaneProps) {
  const state = useGSDWorkspaceState()
  const { submitInput, sendCommand, pushChatUserMessage } = useGSDWorkspaceActions()
  const [terminalFontSize] = useTerminalFontSize()

  const connected = state.connectionState === "connected"
  const isStreaming = state.boot?.bridge.sessionState?.isStreaming ?? false
  const bridge = state.boot?.bridge ?? null

  // ── Derive smart CTA for the placeholder state ──
  const workflowAction = deriveWorkflowAction({
    phase: state.boot?.workspace?.active.phase ?? "pre-planning",
    autoActive: state.boot?.auto?.active ?? false,
    autoPaused: state.boot?.auto?.paused ?? false,
    onboardingLocked: state.boot?.onboarding.locked ?? false,
    commandInFlight: state.commandInFlight,
    bootStatus: state.bootStatus,
    hasMilestones: (state.boot?.workspace?.milestones.length ?? 0) > 0,
    projectDetectionKind: state.boot?.projectDetection?.kind ?? null,
  })

  const placeholderCTA = useMemo((): { label: string; icon: LucideIcon } | null => {
    if (!workflowAction.primary || workflowAction.disabled) return null
    const phase = state.boot?.workspace?.active.phase ?? "pre-planning"
    const autoActive = state.boot?.auto?.active ?? false
    const autoPaused = state.boot?.auto?.paused ?? false

    if (autoActive && !autoPaused) {
      return { label: "Stop Auto", icon: Square }
    }
    if (autoPaused) {
      return { label: "Resume Auto", icon: Play }
    }
    if (phase === "complete") {
      return { label: "New Milestone", icon: Milestone }
    }
    if (phase === "planning") {
      return { label: "Plan", icon: Play }
    }
    if (phase === "executing" || phase === "summarizing") {
      return { label: "Start Auto", icon: Zap }
    }
    if (phase === "pre-planning") {
      return { label: "Initialize Project", icon: Play }
    }
    return { label: "Continue", icon: Play }
  }, [workflowAction, state.boot?.workspace?.active.phase, state.boot?.auto?.active, state.boot?.auto?.paused])

  const handlePlaceholderCTA = useCallback(() => {
    if (!workflowAction.primary) return
    void sendCommand(buildPromptCommand(workflowAction.primary.command, bridge))
  }, [workflowAction, sendCommand, bridge])

  /** Send user text — adds a user bubble and dispatches via the store */
  const handleUserInput = useCallback((data: string, images?: PendingImage[]) => {
    const text = data.replace(/\r$/, "").trim()
    if (!text && (!images || images.length === 0)) return

    const userMsg: ChatMessage = {
      id: createLocalMessageId(),
      role: "user",
      content: text,
      complete: true,
      timestamp: Date.now(),
      images: images?.map((i) => ({ data: i.data, mimeType: i.mimeType })),
    }
    pushChatUserMessage(userMsg)
    void submitInput(text, images)
  }, [submitInput, pushChatUserMessage])

  // Build unified timeline from store state.
  // Uses the segment-ordered data to render thinking/text/tool blocks
  // in their actual chronological order within each turn.
  type TimelineItem =
    | { kind: "thinking"; content: string; id: string }
    | { kind: "message"; message: ChatMessage }
    | { kind: "tool"; tool: CompletedToolExecution }
    | { kind: "active-tool"; tool: ActiveToolExecution }
    | { kind: "streaming-thinking"; content: string }
    | { kind: "streaming-message"; content: string; isThinking: boolean }
    | { kind: "ui-request"; request: PendingUiRequest }

  const timeline = useMemo((): TimelineItem[] => {
    const items: TimelineItem[] = []
    const transcriptBlocks = state.liveTranscript
    const segmentBlocks = state.completedTurnSegments
    const userMsgs = state.chatUserMessages

    // Interleave: user messages alternate with assistant turns.
    // For completed turns, render from segments to preserve chronological order.
    for (let i = 0; i < Math.max(userMsgs.length, transcriptBlocks.length); i++) {
      if (i < userMsgs.length) {
        items.push({ kind: "message", message: userMsgs[i] })
      }
      if (i < segmentBlocks.length && segmentBlocks[i].length > 0) {
        // Render each segment in order
        for (const seg of segmentBlocks[i]) {
          if (seg.kind === "thinking") {
            items.push({ kind: "thinking", content: seg.content, id: `turn-${i}-thinking-${items.length}` })
          } else if (seg.kind === "text") {
            items.push({
              kind: "message",
              message: {
                id: `turn-${i}-text-${items.length}`,
                role: "assistant",
                content: seg.content,
                complete: true,
                timestamp: i + 1,
              },
            })
          } else if (seg.kind === "tool") {
            items.push({ kind: "tool", tool: seg.tool })
          }
        }
      } else if (i < transcriptBlocks.length && transcriptBlocks[i].trim()) {
        // Fallback: no segments stored yet (shouldn't happen for new turns, but safe)
        items.push({
          kind: "message",
          message: {
            id: `transcript-${i}`,
            role: "assistant",
            content: transcriptBlocks[i],
            complete: true,
            timestamp: i + 1,
          },
        })
      }
    }

    // Current turn: render finalized segments, then any in-flight content
    for (const seg of state.currentTurnSegments) {
      if (seg.kind === "thinking") {
        items.push({ kind: "thinking", content: seg.content, id: `current-thinking-${items.length}` })
      } else if (seg.kind === "text") {
        items.push({
          kind: "message",
          message: {
            id: `current-text-${items.length}`,
            role: "assistant",
            content: seg.content,
            complete: true,
            timestamp: Date.now(),
          },
        })
      } else if (seg.kind === "tool") {
        items.push({ kind: "tool", tool: seg.tool })
      }
    }

    // Active tool execution indicator
    if (state.activeToolExecution) {
      items.push({ kind: "active-tool", tool: state.activeToolExecution })
    }

    // Currently streaming thinking (live, not yet finalized into a segment)
    if (state.streamingThinkingText.length > 0) {
      items.push({ kind: "streaming-thinking", content: state.streamingThinkingText })
    }

    // Currently streaming text (live)
    if (state.streamingAssistantText.length > 0) {
      items.push({
        kind: "streaming-message",
        content: state.streamingAssistantText,
        isThinking: false,
      })
    }

    // If only thinking is happening (no text yet, no tool), show a minimal indicator
    if (
      state.streamingThinkingText.length === 0 &&
      state.streamingAssistantText.length === 0 &&
      !state.activeToolExecution &&
      isStreaming &&
      state.currentTurnSegments.length === 0
    ) {
      // Pure waiting state — streaming started but nothing produced yet
      items.push({ kind: "streaming-message", content: "", isThinking: true })
    }

    // Pending UI requests — at the end
    for (const req of state.pendingUiRequests) {
      items.push({ kind: "ui-request", request: req })
    }

    return items
  }, [state.liveTranscript, state.completedTurnSegments, state.currentTurnSegments, state.streamingAssistantText, state.streamingThinkingText, state.activeToolExecution, state.pendingUiRequests, state.chatUserMessages, isStreaming])

  // Prompt submit handler for TUI prompts (select/text/password)
  const handlePromptSubmit = useCallback((data: string) => {
    void submitInput(data.replace(/\r$/, ""))
  }, [submitInput])

  const showPlaceholder = timeline.length === 0 && !isStreaming

  // Show an "awaiting input" indicator when the session is idle (connected,
  // not streaming, has timeline content) so the UI does not appear stuck (#2707).
  const showAwaitingInput = connected && !isStreaming && timeline.length > 0
    && !state.activeToolExecution
    && state.pendingUiRequests.length === 0

  // Auto-scroll ref
  const scrollRef = useRef<HTMLDivElement>(null)
  const isNearBottomRef = useRef(true)

  const handleScroll = useCallback(() => {
    const el = scrollRef.current
    if (!el) return
    isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100
  }, [])

  useEffect(() => {
    const el = scrollRef.current
    if (!el) return
    if (isNearBottomRef.current) {
      el.scrollTop = el.scrollHeight
    }
  }, [timeline])

  return (
    <div
      data-testid="chat-pane-store-driven"
      className={cn("flex flex-col overflow-hidden", className)}
    >
      <div className="flex flex-1 flex-col overflow-hidden">
        {showPlaceholder ? (
          <PlaceholderState
            connected={connected}
            runningLabel={isStreaming ? "responding" : undefined}
            primaryAction={placeholderCTA}
            onPrimaryAction={handlePlaceholderCTA}
          />
        ) : (
          <div
            ref={scrollRef}
            onScroll={handleScroll}
            className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
            style={terminalFontSize !== 13 ? { fontSize: `${terminalFontSize}px` } : undefined}
          >
            {timeline.map((item, idx) => {
              switch (item.kind) {
                case "message":
                  return (
                    <ChatBubble
                      key={item.message.id}
                      message={item.message}
                      onSubmitPrompt={handlePromptSubmit}
                    />
                  )
                case "thinking":
                  return (
                    <div key={item.id} className="flex justify-start gap-3">
                      <div className="w-7 flex-shrink-0" />
                      <div className="max-w-[82%] min-w-0">
                        <InlineThinking content={item.content} isStreaming={false} />
                      </div>
                    </div>
                  )
                case "streaming-thinking":
                  return (
                    <div key="streaming-thinking" className="flex justify-start gap-3">
                      <div className="w-7 flex-shrink-0" />
                      <div className="max-w-[82%] min-w-0">
                        <InlineThinking content={item.content} isStreaming={true} />
                      </div>
                    </div>
                  )
                case "streaming-message":
                  return (
                    <ChatBubble
                      key="streaming-message"
                      message={{
                        id: "streaming-current",
                        role: "assistant",
                        content: item.content,
                        complete: false,
                        timestamp: Date.now(),
                      }}
                      isThinking={item.isThinking}
                    />
                  )
                case "tool":
                  return <ToolExecutionBlock key={item.tool.id} tool={item.tool} />
                case "active-tool":
                  return (
                    <div key={`active-${item.tool.id}`} className="flex justify-start gap-3">
                      <div className="w-7 flex-shrink-0" />
                      <div className="max-w-[82%] min-w-0">
                        <div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/50 px-3.5 py-2">
                          <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
                          <span className="font-mono text-xs text-muted-foreground">
                            {item.tool.name}
                          </span>
                          {Boolean(item.tool.args?.path) && (
                            <span className="font-mono text-xs text-info/80 truncate">
                              {String(item.tool.args?.path)}
                            </span>
                          )}
                        </div>
                      </div>
                    </div>
                  )
                case "ui-request":
                  return <InlineUiRequest key={item.request.id} request={item.request} />
              }
            })}
            {showAwaitingInput && (
              <div className="flex items-center gap-2 px-1 py-1 text-xs text-muted-foreground animate-in fade-in duration-500">
                <span className="inline-block h-2 w-2 rounded-full bg-emerald-500/70 animate-pulse" />
                Ready for your input
              </div>
            )}
            <div className="h-2" />
          </div>
        )}
      </div>

      <ChatInputBar
        onSendInput={handleUserInput}
        connected={connected}
        onOpenAction={onOpenAction}
      />
    </div>
  )
}
