"use client";

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

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

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

/* ─── SF Action Definitions ─── */

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

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

function groupByCategory(actions: SFActionDef[]): Array<{
	category: SFActionDef["category"];
	label: string;
	items: SFActionDef[];
}> {
	const seen = new Map<SFActionDef["category"], SFActionDef[]>();
	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 /sf 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 "sf-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 = useSFWorkspaceState();
	const { sendCommand } = useSFWorkspaceActions();

	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="sf-main"
				command="sf"
				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 = useSFWorkspaceState();

	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 SF 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
						type="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
						type="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 = {
	codeToTokens: (
		code: string,
		options: { lang: string; theme: string },
	) => {
		tokens: Array<
			Array<{
				color?: string;
				content: string;
				fontStyle?: number;
				offset?: number;
			}>
		>;
		bg?: string;
		fg?: 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",
						],
					}) as Promise<ShikiHighlighter>,
			)
			.catch((err) => {
				chatHighlighterPromise = null;
				throw err;
			}) as Promise<ShikiHighlighter>;
	}
	return chatHighlighterPromise!;
}

function HighlightedCode({
	code,
	highlighter,
	lang,
	theme,
	className,
}: {
	code: string;
	highlighter: ShikiHighlighter;
	lang: string;
	theme: string;
	className?: string;
}) {
	const highlighted = highlighter.codeToTokens(code, { lang, theme });

	return (
		<pre
			className={className}
			style={{
				backgroundColor: highlighted.bg,
				color: highlighted.fg,
			}}
		>
			<code>
				{highlighted.tokens.map((line, lineNumber) => (
					<span
						key={`line-${lineNumber}-${line.map((token) => token.content).join("")}`}
					>
						{line.map((token, tokenIndex) => (
							<span
								key={`${token.content}-${token.offset ?? tokenIndex}`}
								style={{
									color: token.color,
									fontStyle:
										token.fontStyle === 1 || token.fontStyle === 3
											? "italic"
											: undefined,
									fontWeight:
										token.fontStyle === 2 || token.fontStyle === 3
											? "bold"
											: undefined,
								}}
							>
								{token.content}
							</span>
						))}
						{lineNumber < highlighted.tokens.length - 1 ? "\n" : null}
					</span>
				))}
			</code>
		</pre>
	);
}

/* ─── 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 {
								return (
									<HighlightedCode
										code={codeStr}
										highlighter={h}
										lang={match[1]}
										theme={shikiTheme}
										className="chat-code-block my-3 rounded-xl overflow-x-auto text-sm shadow-sm border border-border/50"
									/>
								);
							} 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 SF 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
						type="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 SF 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
					type="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 SF 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
					type="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]);

	return (
		<div className="mb-3">
			<button
				type="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>
	);
}

/* ─── 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 /sf 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: SFActionDef) => void;
}) {
	const autoActive = useSFWorkspaceState().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
	}, [pendingImages.forEach]);

	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
										type="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
								type="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
											type="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
											type="button"
											aria-label="More SF 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
															type="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 SF session…
					</p>
				) : primaryAction && onPrimaryAction ? (
					<div className="mt-4">
						<button
							type="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 SF 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 } = useSFWorkspaceActions();
	const isSubmitting =
		useSFWorkspaceState().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
							type="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
						type="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
				type="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
					type="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
					type="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
				type="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"
			/>
			<button
				type="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: SFActionDef) => 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;
		// eslint-disable-next-line react-hooks/set-state-in-effect -- ref-guarded, runs at most once
		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
					type="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 = useSFWorkspaceState();
	const { submitInput, sendCommand, pushChatUserMessage } =
		useSFWorkspaceActions();
	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 Autonomous", icon: Square };
		}
		if (autoPaused) {
			return { label: "Resume Autonomous", 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 Autonomous", 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,
						// eslint-disable-next-line react-hooks/purity -- display timestamp for in-progress message
						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;
		}
	}, []);

	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,
												// eslint-disable-next-line react-hooks/purity -- display timestamp for streaming message
												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>
	);
}
