"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import {
	forwardRef,
	useCallback,
	useEffect,
	useImperativeHandle,
	useMemo,
	useRef,
	useState,
} from "react";
import { ChatMessage } from "./chat-message";
import {
	FilePickerModal,
	type SelectedFile,
} from "./file-picker-modal";
import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor";
import {
	DropdownMenu,
	DropdownMenuContent,
	DropdownMenuItem,
	DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { UnicodeSpinner } from "./unicode-spinner";

// ── Attachment types & helpers ──

type AttachedFile = {
	id: string;
	name: string;
	path: string;
	/** True while the file is still uploading to the server. */
	uploading?: boolean;
	/** Local blob URL for instant preview before upload completes. */
	localUrl?: string;
};

function getFileCategory(
	name: string,
): "image" | "video" | "audio" | "pdf" | "code" | "document" | "other" {
	const ext = name.split(".").pop()?.toLowerCase() ?? "";
	if (
		[
			"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp",
			"ico", "tiff", "heic",
		].includes(ext)
	)
		{return "image";}
	if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext))
		{return "video";}
	if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext))
		{return "audio";}
	if (ext === "pdf") {return "pdf";}
	if (
		[
			"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
			"cpp", "c", "h", "css", "html", "json", "yaml", "yml",
			"toml", "md", "sh", "bash", "sql", "swift", "kt",
		].includes(ext)
	)
		{return "code";}
	if (
		[
			"doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt",
			"rtf", "csv", "pages", "numbers", "key",
		].includes(ext)
	)
		{return "document";}
	return "other";
}

function shortenPath(path: string): string {
	return path
		.replace(/^\/Users\/[^/]+/, "~")
		.replace(/^\/home\/[^/]+/, "~")
		.replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
}

const categoryMeta: Record<string, { bg: string; fg: string }> = {
	image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" },
	video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" },
	audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
	pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" },
	code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
	document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
	other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
};

function FileTypeIcon({ category }: { category: string }) {
	const props = {
		width: 16,
		height: 16,
		viewBox: "0 0 24 24",
		fill: "none",
		stroke: "currentColor",
		strokeWidth: 2,
		strokeLinecap: "round" as const,
		strokeLinejoin: "round" as const,
	};
	switch (category) {
		case "image":
			return (
				<svg {...props}>
					<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
					<circle cx="9" cy="9" r="2" />
					<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
				</svg>
			);
		case "video":
			return (
				<svg {...props}>
					<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
					<rect x="2" y="6" width="14" height="12" rx="2" />
				</svg>
			);
		case "audio":
			return (
				<svg {...props}>
					<path d="M9 18V5l12-2v13" />
					<circle cx="6" cy="18" r="3" />
					<circle cx="18" cy="16" r="3" />
				</svg>
			);
		case "pdf":
			return (
				<svg {...props}>
					<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
					<path d="M14 2v6h6" />
					<path d="M10 13h4" />
					<path d="M10 17h4" />
				</svg>
			);
		case "code":
			return (
				<svg {...props}>
					<polyline points="16 18 22 12 16 6" />
					<polyline points="8 6 2 12 8 18" />
				</svg>
			);
		case "document":
			return (
				<svg {...props}>
					<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
					<path d="M14 2v6h6" />
					<path d="M16 13H8" />
					<path d="M16 17H8" />
					<path d="M10 9H8" />
				</svg>
			);
		default:
			return (
				<svg {...props}>
					<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
					<path d="M14 2v6h6" />
				</svg>
			);
	}
}

function QueueItem({
	msg,
	idx,
	onEdit,
	onSendNow,
	onRemove,
}: {
	msg: QueuedMessage;
	idx: number;
	onEdit: (id: string, text: string) => void;
	onSendNow: (id: string) => void;
	onRemove: (id: string) => void;
}) {
	const [editing, setEditing] = useState(false);
	const [draft, setDraft] = useState(msg.text);
	const inputRef = useRef<HTMLTextAreaElement>(null);

	const autoResize = () => {
		const el = inputRef.current;
		if (!el) {return;}
		el.style.height = "auto";
		el.style.height = `${el.scrollHeight}px`;
	};

	useEffect(() => {
		if (editing) {
			inputRef.current?.focus();
			const len = inputRef.current?.value.length ?? 0;
			inputRef.current?.setSelectionRange(len, len);
			autoResize();
		}
	}, [editing]);

	const commitEdit = () => {
		const trimmed = draft.trim();
		if (trimmed && trimmed !== msg.text) {
			onEdit(msg.id, trimmed);
		} else {
			setDraft(msg.text);
		}
		setEditing(false);
	};

	return (
		<div
			className={`flex items-start gap-2.5 group py-2 ${idx > 0 ? "border-t" : ""}`}
			style={idx > 0 ? { borderColor: "var(--color-border)" } : undefined}
		>
			<span
				className="shrink-0 mt-px text-[11px] font-medium tabular-nums w-4"
				style={{ color: "var(--color-text-muted)" }}
			>
				{idx + 1}
			</span>
			{editing ? (
				<textarea
					ref={inputRef}
					className="flex-1 text-[13px] leading-[1.45] min-w-0 resize-none rounded-md px-2 py-1 outline-none"
					style={{
						color: "var(--color-text-secondary)",
						background: "var(--color-bg)",
						border: "1px solid var(--color-border)",
					}}
					rows={1}
					value={draft}
					onChange={(e) => { setDraft(e.target.value); autoResize(); }}
					onBlur={commitEdit}
					onKeyDown={(e) => {
						if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); }
						if (e.key === "Escape") { setDraft(msg.text); setEditing(false); }
					}}
				/>
			) : (
				<div className="flex-1 min-w-0 flex items-center gap-2">
					{msg.attachedFiles.length > 0 && (
						<div className="flex gap-1 shrink-0">
							{msg.attachedFiles.map((af) => {
								const cat = getFileCategory(af.name);
								const src = cat === "image"
									? (af.localUrl || `/api/workspace/raw-file?path=${encodeURIComponent(af.path)}`)
									: af.path ? `/api/workspace/thumbnail?path=${encodeURIComponent(af.path)}&size=100` : undefined;
								return (
									<img
										key={af.id}
										src={src}
										alt={af.name}
										className="rounded object-cover"
										style={{ height: 28, width: 28, background: "var(--color-surface-hover)" }}
										onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
									/>
								);
							})}
						</div>
					)}
					<p
						className="text-[13px] leading-[1.45] line-clamp-1 min-w-0"
						style={{ color: "var(--color-text-secondary)" }}
					>
						{msg.text || `${msg.attachedFiles.length} ${msg.attachedFiles.length === 1 ? "file" : "files"}`}
					</p>
				</div>
			)}
			{!editing && (
				<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
					{/* Edit */}
					<button
						type="button"
						className="rounded-md p-1 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800"
						title="Edit message"
						onClick={() => { setDraft(msg.text); setEditing(true); }}
					>
						<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-stone-400">
							<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
							<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
						</svg>
					</button>
					{/* Send now */}
					<button
						type="button"
						className="rounded-md p-1 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800"
						title="Send now"
						onClick={() => onSendNow(msg.id)}
					>
						<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-stone-400">
							<path d="M12 19V5" />
							<path d="m5 12 7-7 7 7" />
						</svg>
					</button>
					{/* Delete */}
					<button
						type="button"
						className="rounded-md p-1 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800"
						title="Remove from queue"
						onClick={() => onRemove(msg.id)}
					>
						<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-stone-400">
							<path d="M3 6h18" />
							<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
							<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
						</svg>
					</button>
				</div>
			)}
		</div>
	);
}

function AttachmentStrip({
	files,
	compact,
	onRemove,
	onClearAll: _onClearAll,
}: {
	files: AttachedFile[];
	compact?: boolean;
	onRemove: (id: string) => void;
	onClearAll: () => void;
}) {
	if (files.length === 0) {return null;}

	return (
		<div className={`${compact ? "px-2" : "px-3"} pt-2`}>
			<div
				className="flex gap-2 overflow-x-auto pb-1"
				style={{ scrollbarWidth: "thin" }}
			>
				{files.map((af) => {
					const category = getFileCategory(
						af.name,
					);
					const meta =
						categoryMeta[category] ??
						categoryMeta.other;
					const short = shortenPath(af.path);

					return (
						<div
							key={af.id}
							className="relative group flex-shrink-0 rounded-xl overflow-hidden"
							style={{
								background:
									"var(--color-surface-hover)",
								border: "1px solid var(--color-border)",
							}}
						>
							{/* Remove button */}
							<button
								type="button"
								onClick={() =>
									onRemove(af.id)
								}
								className="absolute top-1 right-1 z-10 w-[18px] h-[18px] rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
								style={{
									background:
										"rgba(0,0,0,0.55)",
									color: "white",
									backdropFilter:
										"blur(4px)",
								}}
							>
								<svg
									width="8"
									height="8"
									viewBox="0 0 24 24"
									fill="none"
									stroke="currentColor"
									strokeWidth="3"
									strokeLinecap="round"
								>
									<path d="M18 6 6 18" />
									<path d="m6 6 12 12" />
								</svg>
							</button>

							{category === "image" ? (
								/* Image thumbnail — no filename */
								<img
									src={af.localUrl || `/api/workspace/raw-file?path=${encodeURIComponent(af.path)}`}
									alt={af.name}
									className="block rounded-xl object-cover"
									style={{
										height: 80,
										width: "auto",
										minWidth: 60,
										maxWidth: 140,
										opacity: af.uploading ? 0.6 : 1,
										background: "var(--color-bg-secondary)",
									}}
									onError={(e) => {
										(e.currentTarget as HTMLImageElement).style.display = "none";
									}}
								/>
							) : category === "pdf" && af.path ? (
								/* PDF thumbnail via Quick Look */
								<img
									src={`/api/workspace/thumbnail?path=${encodeURIComponent(af.path)}&size=200`}
									alt={af.name}
									className="block rounded-xl object-cover"
									style={{
										height: 80,
										width: "auto",
										minWidth: 60,
										maxWidth: 140,
										opacity: af.uploading ? 0.6 : 1,
										background: "var(--color-bg-secondary)",
									}}
									onError={(e) => {
										(e.currentTarget as HTMLImageElement).style.display = "none";
									}}
								/>
							) : (
								<div className="flex items-center gap-2.5 px-3 py-2.5" style={{ opacity: af.uploading ? 0.6 : 1 }}>
									<div
										className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
										style={{
											background: meta.bg,
											color: meta.fg,
										}}
									>
										<FileTypeIcon category={category} />
									</div>
									<div className="min-w-0 max-w-[140px]">
										<p
											className="text-[11px] font-medium truncate"
											style={{ color: "var(--color-text)" }}
											title={af.path || af.name}
										>
											{af.name}
										</p>
										<p
											className="text-[9px] truncate"
											style={{ color: "var(--color-text-muted)" }}
											title={af.path || af.name}
										>
											{af.uploading ? "Uploading..." : short}
										</p>
									</div>
								</div>
							)}
						</div>
					);
				})}
			</div>
		</div>
	);
}

// ── SSE stream parser for reconnection ──
// Converts raw SSE events (AI SDK v6 wire format) into UIMessage parts.

type ParsedPart =
	| { type: "text"; text: string }
	| { type: "user-message"; id?: string; text: string }
	| { type: "reasoning"; text: string; state?: string }
	| {
			type: "dynamic-tool";
			toolName: string;
			toolCallId: string;
			state: string;
			input?: Record<string, unknown>;
			output?: Record<string, unknown>;
		};

export function createStreamParser() {
	const parts: ParsedPart[] = [];
	let currentTextIdx = -1;
	let currentReasoningIdx = -1;

	function processEvent(event: Record<string, unknown>) {
		const t = event.type as string;

		switch (t) {
			case "user-message":
				currentTextIdx = -1;
				currentReasoningIdx = -1;
				parts.push({
					type: "user-message",
					id: event.id as string | undefined,
					text: (event.text as string) ?? "",
				});
				break;
			case "reasoning-start":
				parts.push({
					type: "reasoning",
					text: "",
					state: "streaming",
				});
				currentReasoningIdx = parts.length - 1;
				break;
			case "reasoning-delta": {
				if (currentReasoningIdx >= 0) {
					const p = parts[currentReasoningIdx] as {
						type: "reasoning";
						text: string;
					};
					p.text += event.delta as string;
				}
				break;
			}
			case "reasoning-end":
				if (currentReasoningIdx >= 0) {
					const p = parts[currentReasoningIdx] as {
						type: "reasoning";
						state?: string;
					};
					delete p.state;
				}
				currentReasoningIdx = -1;
				break;
			case "text-start":
				parts.push({ type: "text", text: "" });
				currentTextIdx = parts.length - 1;
				break;
			case "text-delta": {
				if (currentTextIdx >= 0) {
					const p = parts[currentTextIdx] as {
						type: "text";
						text: string;
					};
					p.text += event.delta as string;
				}
				break;
			}
			case "text-end":
				currentTextIdx = -1;
				break;
			case "tool-input-start":
				parts.push({
					type: "dynamic-tool",
					toolCallId: event.toolCallId as string,
					toolName: event.toolName as string,
					state: "input-available",
					input: {},
				});
				break;
			case "tool-input-available":
				for (let i = parts.length - 1; i >= 0; i--) {
					const p = parts[i];
					if (
						p.type === "dynamic-tool" &&
						p.toolCallId === event.toolCallId
					) {
						p.input =
							(event.input as Record<string, unknown>) ??
							{};
						break;
					}
				}
				break;
			case "tool-output-available":
				for (let i = parts.length - 1; i >= 0; i--) {
					const p = parts[i];
					if (
						p.type === "dynamic-tool" &&
						p.toolCallId === event.toolCallId
					) {
						p.state = "output-available";
						p.output =
							(event.output as Record<
								string,
								unknown
							>) ?? {};
						break;
					}
				}
				break;
			case "tool-output-error":
				for (let i = parts.length - 1; i >= 0; i--) {
					const p = parts[i];
					if (
						p.type === "dynamic-tool" &&
						p.toolCallId === event.toolCallId
					) {
						p.state = "error";
						p.output = {
							error: event.errorText as string,
						};
						break;
					}
				}
				break;
		}
	}

	return {
		processEvent,
		getParts: (): ParsedPart[] => parts.map((p) => ({ ...p })),
	};
}

/** Imperative handle for parent-driven session control (main page). */
export type ChatPanelHandle = {
	loadSession: (sessionId: string) => Promise<void>;
	newSession: () => Promise<void>;
	/** Create a new session and immediately send a message. */
	sendNewMessage: (text: string) => Promise<void>;
	/** Insert a file mention into the chat editor (e.g. from sidebar drag). */
	insertFileMention?: (name: string, path: string) => void;
};

export type FileContext = {
	path: string;
	filename: string;
	/** When true the path refers to a directory rather than a file. */
	isDirectory?: boolean;
};

type FileScopedSession = {
	id: string;
	title: string;
	createdAt: number;
	updatedAt: number;
	messageCount: number;
};

/** A message waiting to be sent after the current agent run finishes. */
type QueuedMessage = {
	id: string;
	text: string;
	mentionedFiles: Array<{ name: string; path: string }>;
	attachedFiles: AttachedFile[];
	createdAt: number;
};

export type SubagentSpawnInfo = {
	childSessionKey: string;
	runId: string;
	task: string;
	label?: string;
	parentSessionId: string;
	status?: "running" | "completed" | "error";
};

type ChatPanelProps = {
	/** When set, scopes sessions to this file and prepends content as context. */
	fileContext?: FileContext;
	/** Compact mode for workspace sidebar (smaller UI, built-in session tabs). */
	compact?: boolean;
	/** Override the header title when a session is active (e.g. show the session's actual title). */
	sessionTitle?: string;
	/** Session ID to auto-load on mount (for non-file panels that remount after navigation). */
	initialSessionId?: string;
	/** Called when file content may have changed after agent edits. */
	onFileChanged?: (newContent: string) => void;
	/** Called when active session changes (for external sidebar highlighting). */
	onActiveSessionChange?: (sessionId: string | null) => void;
	/** Called when session list needs refresh (for external sidebar). */
	onSessionsChange?: () => void;
	/** Called when the agent spawns a subagent. */
	onSubagentSpawned?: (info: SubagentSpawnInfo) => void;
	/** Called when user clicks a subagent card in the chat to view its output. */
	onSubagentClick?: (task: string) => void;
	/** Called when user clicks an inline file path in chat output. */
	onFilePathClick?: (path: string) => Promise<boolean | void> | boolean | void;
	/** Called when user deletes the current session (e.g. from header menu). */
	onDeleteSession?: (sessionId: string) => void;
	/** Called when user renames the current session. */
	onRenameSession?: (sessionId: string, newTitle: string) => void;
};

export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
	function ChatPanelInner(
		{
			fileContext,
			compact,
			sessionTitle,
			initialSessionId,
			onFileChanged,
			onActiveSessionChange,
			onSessionsChange,
			onSubagentSpawned,
			onSubagentClick,
			onFilePathClick,
			onDeleteSession,
			onRenameSession: _onRenameSession,
		},
		ref,
	) {
		const editorRef = useRef<ChatEditorHandle>(null);
		const [editorEmpty, setEditorEmpty] = useState(true);
		const [currentSessionId, setCurrentSessionId] = useState<
			string | null
		>(null);
		const [loadingSession, setLoadingSession] = useState(false);
		const messagesEndRef = useRef<HTMLDivElement>(null);

		// ── Attachment state ──
		const [attachedFiles, setAttachedFiles] = useState<
			AttachedFile[]
		>([]);
		const [showFilePicker, setShowFilePicker] =
			useState(false);

		// ── Reconnection state ──
		const [isReconnecting, setIsReconnecting] = useState(false);
		const reconnectAbortRef = useRef<AbortController | null>(null);

		// Track persisted messages to avoid double-saves
		const savedMessageIdsRef = useRef<Set<string>>(new Set());
		// Set when /new or + triggers a new session
		const newSessionPendingRef = useRef(false);
		// Whether the next message should include file context
		const isFirstFileMessageRef = useRef(true);

		// File-scoped session list (compact mode only)
		const [fileSessions, setFileSessions] = useState<
			FileScopedSession[]
		>([]);

		// ── Message queue (messages to send after current run completes) ──
		const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
		const [rawView, _setRawView] = useState(false);

		const filePath = fileContext?.path ?? null;

		// ── Ref-based session ID for transport ──
		const sessionIdRef = useRef<string | null>(null);
		useEffect(() => {
			sessionIdRef.current = currentSessionId;
		}, [currentSessionId]);

		// ── Transport (per-instance) ──
		const transport = useMemo(
			() =>
				new DefaultChatTransport({
					api: "/api/chat",
					body: () => {
						const sid = sessionIdRef.current;
						return sid ? { sessionId: sid } : {};
					},
				}),
			[],
		);

		const { messages, sendMessage, status, stop, error, setMessages } =
			useChat({ transport });

		const isStreaming =
			status === "streaming" ||
			status === "submitted" ||
			isReconnecting;

		// Auto-scroll to bottom on new messages, but only when the user
		// is already near the bottom.  If the user scrolls up during
		// streaming, we stop auto-scrolling until they return to the
		// bottom (or a new user message is sent).
		const scrollContainerRef = useRef<HTMLDivElement>(null);
		const userScrolledAwayRef = useRef(false);
		const scrollRafRef = useRef(0);

		// Detect when the user scrolls away from the bottom.
		useEffect(() => {
			const el = scrollContainerRef.current;
			if (!el) {return;}

			const onScroll = () => {
				const distanceFromBottom =
					el.scrollHeight - el.scrollTop - el.clientHeight;
				// Threshold: if within 80px of the bottom, consider "at bottom"
				userScrolledAwayRef.current = distanceFromBottom > 80;
			};

			el.addEventListener("scroll", onScroll, { passive: true });
			return () => el.removeEventListener("scroll", onScroll);
		}, []);

		// Auto-scroll effect — skips when user has scrolled away.
		useEffect(() => {
			if (userScrolledAwayRef.current) {return;}
			if (scrollRafRef.current) {return;}
			scrollRafRef.current = requestAnimationFrame(() => {
				scrollRafRef.current = 0;
				messagesEndRef.current?.scrollIntoView({
					behavior: "smooth",
				});
			});
		}, [messages]);

		// ── Session persistence helpers ──

		const createSession = useCallback(
			async (title: string): Promise<string> => {
				const body: Record<string, string> = { title };
				if (filePath) {
					body.filePath = filePath;
				}
				const res = await fetch("/api/web-sessions", {
					method: "POST",
					headers: { "Content-Type": "application/json" },
					body: JSON.stringify(body),
				});
				const data = await res.json();
				return data.session.id;
			},
			[filePath],
		);

		// ── Stream reconnection ──
		// Attempts to reconnect to an active agent run for the given session.
		// Replays buffered SSE events and streams live updates.
		const attemptReconnect = useCallback(
			async (
				sessionId: string,
				baseMessages: Array<{
					id: string;
					role: "user" | "assistant" | "system";
					parts: UIMessage["parts"];
				}>,
			): Promise<boolean> => {
				const abort = new AbortController();
				reconnectAbortRef.current = abort;

				try {
					const res = await fetch(
						`/api/chat/stream?sessionId=${encodeURIComponent(sessionId)}`,
						{ signal: abort.signal },
					);
					if (!res.ok || !res.body) {
						return false; // No active run
					}

					// If the run already completed (still in the grace
					// period), skip the expensive SSE replay -- the
					// persisted messages we already loaded are final.
					if (res.headers.get("X-Run-Active") === "false") {
						void res.body.cancel();
						return false;
					}

					setIsReconnecting(true);

					const parser = createStreamParser();
					const reader = res.body.getReader();
					const decoder = new TextDecoder();
					const reconnectMsgId = `reconnect-${sessionId}`;
					let buffer = "";
					let frameRequested = false;

					const updateUI = () => {
						// Guard: if the session was switched while a
						// rAF was pending, don't overwrite the new
						// session's messages with stale data.
						if (abort.signal.aborted) {return;}
						const assistantMsg = {
							id: reconnectMsgId,
							role: "assistant" as const,
							parts: parser.getParts() as UIMessage["parts"],
						};
						setMessages([
							...baseMessages,
							assistantMsg,
						]);
					};

					// Read the SSE stream
					// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- loop reads until done
					while (true) {
						const { done, value } =
							await reader.read();
						if (done) {break;}

						buffer += decoder.decode(value, {
							stream: true,
						});

						// Parse SSE events (data: <json>\n\n)
						let idx;
						while (
							(idx = buffer.indexOf("\n\n")) !== -1
						) {
							const chunk = buffer.slice(0, idx);
							buffer = buffer.slice(idx + 2);

							if (chunk.startsWith("data: ")) {
								try {
									const event = JSON.parse(
										chunk.slice(6),
									);
									parser.processEvent(event);
								} catch {
									/* skip malformed events */
								}
							}
						}

						// Batch UI updates to animation frames
						if (!frameRequested) {
							frameRequested = true;
							requestAnimationFrame(() => {
								frameRequested = false;
								updateUI();
							});
						}
					}

					// Final update after stream ends
					updateUI();

					// Mark all messages as saved (server persisted them)
					if (!abort.signal.aborted) {
						for (const m of baseMessages) {
							savedMessageIdsRef.current.add(m.id);
						}
						savedMessageIdsRef.current.add(reconnectMsgId);
					}

					setIsReconnecting(false);
					reconnectAbortRef.current = null;
					return true;
				} catch (err) {
					if (
						(err as Error).name !== "AbortError"
					) {
						console.error(
							"Reconnection error:",
							err,
						);
					}
					setIsReconnecting(false);
					reconnectAbortRef.current = null;
					return false;
				}
			},
			[setMessages],
		);

		// ── File-scoped session initialization ──
		const fetchFileSessionsRef = useRef<
			(() => Promise<FileScopedSession[]>) | null
		>(null);

		fetchFileSessionsRef.current = async () => {
			if (!filePath) {
				return [];
			}
			try {
				const res = await fetch(
					`/api/web-sessions?filePath=${encodeURIComponent(filePath)}`,
				);
				const data = await res.json();
				return (data.sessions || []) as FileScopedSession[];
			} catch {
				return [];
			}
		};

		useEffect(() => {
			if (!filePath) {
				return;
			}
			let cancelled = false;

			sessionIdRef.current = null;
			setCurrentSessionId(null);
			onActiveSessionChange?.(null);
			setMessages([]);
			savedMessageIdsRef.current.clear();
			isFirstFileMessageRef.current = true;

			void (async () => {
				const sessions =
					(await fetchFileSessionsRef.current?.()) ?? [];
				if (cancelled) {
					return;
				}
				setFileSessions(sessions);

				if (sessions.length > 0) {
					const latest = sessions[0];
					setCurrentSessionId(latest.id);
					sessionIdRef.current = latest.id;
					onActiveSessionChange?.(latest.id);
					isFirstFileMessageRef.current = false;

					try {
						const msgRes = await fetch(
							`/api/web-sessions/${latest.id}`,
						);
						if (cancelled) {
							return;
						}
						const msgData = await msgRes.json();
						const sessionMessages: Array<{
							id: string;
							role: "user" | "assistant";
							content: string;
							parts?: Array<Record<string, unknown>>;
							_streaming?: boolean;
						}> = msgData.messages || [];

						// Filter out in-progress streaming messages
						// (will be rebuilt from the live SSE stream)
						const hasStreaming = sessionMessages.some(
							(m) => m._streaming,
						);
						const completedMessages = hasStreaming
							? sessionMessages.filter(
									(m) => !m._streaming,
								)
							: sessionMessages;

						const uiMessages = completedMessages.map(
							(msg) => {
								savedMessageIdsRef.current.add(msg.id);
								return {
									id: msg.id,
									role: msg.role,
									parts: (msg.parts ?? [
										{
											type: "text" as const,
											text: msg.content,
										},
									]) as UIMessage["parts"],
								};
							},
						);
					if (!cancelled) {
						setMessages(uiMessages);
					}

						// Always try to reconnect to a potentially
						// active agent run. The stream endpoint returns
						// 404 gracefully if no run exists, avoiding the
						// 2-second persistence timing gap for _streaming.
						if (!cancelled) {
							await attemptReconnect(
								latest.id,
								uiMessages,
							);
						}
					} catch {
						// ignore
					}
				}
			})();

			return () => {
				cancelled = true;
			};
			// eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters
		}, [filePath, attemptReconnect]);

		// ── Non-file panel: auto-restore session on mount ──
		// When the main ChatPanel remounts after navigation (e.g. user viewed
		// a file then returned to chat), re-load the previously active session
		// and reconnect to any active stream.
		const initialSessionHandled = useRef(false);
		useEffect(() => {
			if (filePath || !initialSessionId || initialSessionHandled.current) {
				return;
			}
			initialSessionHandled.current = true;
			void handleSessionSelect(initialSessionId);
			// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount
		}, []);

		// ── Poll for subagent spawns during active streaming ──
		const [hasRunningSubagents, setHasRunningSubagents] = useState(false);

		useEffect(() => {
			if (!currentSessionId || !onSubagentSpawned) {return;}
			let cancelled = false;

			const poll = async () => {
				try {
					const res = await fetch(
						`/api/chat/subagents?sessionId=${encodeURIComponent(currentSessionId)}`,
					);
					if (cancelled || !res.ok) {return;}
					const data = await res.json();
					const subagents: Array<{
						sessionKey: string;
						runId: string;
						task: string;
						label?: string;
						status: "running" | "completed" | "error";
					}> = data.subagents ?? [];
					let anyRunning = false;
					for (const sa of subagents) {
						if (sa.status === "running") {anyRunning = true;}
						onSubagentSpawned({
							childSessionKey: sa.sessionKey,
							runId: sa.runId,
							task: sa.task,
							label: sa.label,
							parentSessionId: currentSessionId,
							status: sa.status,
						});
					}
					if (!cancelled) {setHasRunningSubagents(anyRunning);}
				} catch { /* ignore */ }
			};

			void poll();
			const id = setInterval(poll, 3_000);
			return () => { cancelled = true; clearInterval(id); };
		}, [currentSessionId, onSubagentSpawned]);

		// ── Post-stream side-effects (file-reload, session refresh) ──
		// Message persistence is handled server-side by ActiveRunManager,
		// so we only refresh the file sessions list and notify the parent
		// when the file content may have changed.
		const prevStatusRef = useRef(status);
		useEffect(() => {
			const wasStreaming =
				prevStatusRef.current === "streaming" ||
				prevStatusRef.current === "submitted";
			const isNowReady = status === "ready";

			if (wasStreaming && isNowReady && currentSessionId) {
				// Mark all current messages as saved — the server
				// already persisted them via ActiveRunManager.
				for (const m of messages) {
					savedMessageIdsRef.current.add(m.id);
				}

			if (filePath) {
				void fetchFileSessionsRef.current?.().then(
					(sessions) => {
						setFileSessions(sessions);
					},
				);
			}

			if (filePath && onFileChanged) {
					fetch(
						`/api/workspace/file?path=${encodeURIComponent(filePath)}`,
					)
						.then((r) => r.json())
						.then((data) => {
							if (data.content) {
								onFileChanged(data.content);
							}
						})
						.catch(() => {});
				}

				onSessionsChange?.();
			}
			prevStatusRef.current = status;
		}, [
			status,
			messages,
			currentSessionId,
			filePath,
			onFileChanged,
			onSessionsChange,
		]);

		// ── Actions ──

		// Ref for handleNewSession so handleEditorSubmit doesn't depend on the hook order
		const handleNewSessionRef = useRef<() => void>(() => {});

		/** Submit from the Tiptap editor (called on Enter or send button).
		 *  `overrideAttachments` is used by the queue system to pass saved attachments directly. */
		const handleEditorSubmit = useCallback(
			async (
				text: string,
				mentionedFiles: Array<{ name: string; path: string }>,
				overrideAttachments?: AttachedFile[],
			) => {
				const hasText = text.trim().length > 0;
				const hasMentions = mentionedFiles.length > 0;
				// Use override attachments (from queue) or current state
				const readyFiles = overrideAttachments
					? overrideAttachments.filter((f) => !f.uploading && f.path)
					: attachedFiles.filter((f) => !f.uploading && f.path);
				const hasFiles = readyFiles.length > 0;
				if (!hasText && !hasMentions && !hasFiles) {
					return;
				}

				const userText = text.trim();
				const currentAttachments = [...readyFiles];

				if (userText.toLowerCase() === "/new") {
					// Revoke blob URLs before clearing
					for (const f of attachedFiles) {
						if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
					}
					setAttachedFiles([]);
					handleNewSessionRef.current();
					return;
				}

				// Queue the message if the agent is still running.
				if (isStreaming) {
					// Clear attachment strip but keep blob URLs alive for queue thumbnails
					if (!overrideAttachments) {
						setAttachedFiles([]);
					}
					setQueuedMessages((prev) => [
						...prev,
						{
							id: crypto.randomUUID(),
							text: userText,
							mentionedFiles,
							attachedFiles: currentAttachments,
							createdAt: Date.now(),
						},
					]);
					return;
				}

				// Clear attachments (revoke blob URLs to free memory)
				if (!overrideAttachments && currentAttachments.length > 0) {
					for (const f of attachedFiles) {
						if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
					}
					setAttachedFiles([]);
				}

				let sessionId = currentSessionId;
				if (!sessionId) {
					const titleSource =
						userText || "File attachment";
					const title =
						titleSource.length > 60
							? titleSource.slice(0, 60) + "..."
							: titleSource;
					sessionId = await createSession(title);
					setCurrentSessionId(sessionId);
					sessionIdRef.current = sessionId;
					onActiveSessionChange?.(sessionId);
					onSessionsChange?.();

				if (filePath) {
					void fetchFileSessionsRef.current?.().then(
						(sessions) => {
							setFileSessions(sessions);
						},
					);
				}
				}

				// Build message with optional attachment prefix
				let messageText = userText;

				// Merge mention paths and attachment paths
				const allFilePaths = [
					...mentionedFiles.map((f) => f.path),
					...currentAttachments.map((f) => f.path),
				];
				if (allFilePaths.length > 0) {
					const prefix = `[Attached files: ${allFilePaths.join(", ")}]`;
					messageText = messageText
						? `${prefix}\n\n${messageText}`
						: prefix;
				}

				if (fileContext && isFirstFileMessageRef.current) {
					const label = fileContext.isDirectory ? "directory" : "file";
					messageText = `[Context: workspace ${label} '${fileContext.path}']\n\n${messageText}`;
					isFirstFileMessageRef.current = false;
				}

			// Reset scroll lock so we auto-scroll to the new user message
			userScrolledAwayRef.current = false;
			void sendMessage({ text: messageText });
		},
			[
				attachedFiles,
				isStreaming,
				currentSessionId,
				createSession,
				onActiveSessionChange,
				onSessionsChange,
				filePath,
				fileContext,
				sendMessage,
			],
		);

		// ── Queue flush: send the next queued message once the stream finishes ──
		const prevFlushStatusRef = useRef(status);
		useEffect(() => {
			const wasStreaming =
				prevFlushStatusRef.current === "streaming" ||
				prevFlushStatusRef.current === "submitted";
			const isNowReady = status === "ready";
			prevFlushStatusRef.current = status;

			if (wasStreaming && isNowReady && queuedMessages.length > 0) {
				const [next, ...rest] = queuedMessages;
				setQueuedMessages(rest);
				// Revoke blob URLs from queued attachments (no longer needed for thumbnails)
				for (const f of next.attachedFiles) {
					if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
				}
				// Use a microtask so React can settle the status update first.
				queueMicrotask(() => {
					void handleEditorSubmit(next.text, next.mentionedFiles, next.attachedFiles);
				});
			}
		}, [status, queuedMessages, handleEditorSubmit]);

		const handleSessionSelect = useCallback(
			async (sessionId: string) => {
				if (sessionId === currentSessionId) {
					return;
				}

			// Stop any active stream/reconnection for the old session.
			reconnectAbortRef.current?.abort();
			void stop();

				setLoadingSession(true);
				setCurrentSessionId(sessionId);
				sessionIdRef.current = sessionId;
				onActiveSessionChange?.(sessionId);
				savedMessageIdsRef.current.clear();
				isFirstFileMessageRef.current = false;
				setQueuedMessages([]);

				try {
					const response = await fetch(
						`/api/web-sessions/${sessionId}`,
					);
					if (!response.ok) {
						throw new Error("Failed to load session");
					}

					const data = await response.json();
					const sessionMessages: Array<{
						id: string;
						role: "user" | "assistant";
						content: string;
						parts?: Array<Record<string, unknown>>;
						_streaming?: boolean;
					}> = data.messages || [];

					const hasStreaming = sessionMessages.some(
						(m) => m._streaming,
					);
					const completedMessages = hasStreaming
						? sessionMessages.filter(
								(m) => !m._streaming,
							)
						: sessionMessages;

				const uiMessages = completedMessages.map(
					(msg) => {
						savedMessageIdsRef.current.add(msg.id);
						return {
							id: msg.id,
							role: msg.role,
							parts: (msg.parts ?? [
								{
									type: "text" as const,
									text: msg.content,
								},
							]) as UIMessage["parts"],
						};
					},
				);

				setMessages(uiMessages);

				// Clear loading state *before* reconnecting — the
				// persisted messages are now visible.  attemptReconnect
				// manages its own `isReconnecting` state which shows
				// "Resuming stream..." instead of "Loading session...".
				setLoadingSession(false);

				// Always try to reconnect -- the stream endpoint
				// returns 404 gracefully if no active run exists,
				// and this avoids missing runs whose _streaming
				// flag hasn't been persisted yet.
				await attemptReconnect(sessionId, uiMessages);
			} catch (err) {
				console.error("Error loading session:", err);
				setLoadingSession(false);
			}
			},
			[
				currentSessionId,
				setMessages,
				onActiveSessionChange,
				stop,
				attemptReconnect,
			],
		);

	const handleNewSession = useCallback(() => {
		reconnectAbortRef.current?.abort();
		void stop();
			setIsReconnecting(false);
			setCurrentSessionId(null);
			sessionIdRef.current = null;
			onActiveSessionChange?.(null);
			setMessages([]);
			savedMessageIdsRef.current.clear();
			isFirstFileMessageRef.current = true;
			newSessionPendingRef.current = false;
			setQueuedMessages([]);
			// Focus the chat input after state updates so "New Chat" is ready to type.
			requestAnimationFrame(() => {
				editorRef.current?.focus();
			});
		}, [setMessages, onActiveSessionChange, stop]);

		// Keep the ref in sync so handleEditorSubmit can call it
		handleNewSessionRef.current = handleNewSession;

		useImperativeHandle(
			ref,
			() => ({
			loadSession: handleSessionSelect,
			newSession: async () => { handleNewSession(); },
				sendNewMessage: async (text: string) => {
					handleNewSession();
					const title =
						text.length > 60 ? text.slice(0, 60) + "..." : text;
					const sessionId = await createSession(title);
					setCurrentSessionId(sessionId);
					sessionIdRef.current = sessionId;
					onActiveSessionChange?.(sessionId);
					onSessionsChange?.();
					userScrolledAwayRef.current = false;
					void sendMessage({ text });
				},
				insertFileMention: (name: string, path: string) => {
					editorRef.current?.insertFileMention(name, path);
				},
			}),
			[handleSessionSelect, handleNewSession, createSession, onActiveSessionChange, onSessionsChange, sendMessage],
		);

		// ── Stop handler (aborts server-side run + client-side stream) ──
		const handleStop = useCallback(async () => {
			// Abort reconnection stream if active (immediate visual feedback).
			reconnectAbortRef.current?.abort();
			setIsReconnecting(false);

			// Stop the server-side agent run and wait for confirmation so the
			// session is no longer in "running" state before we stop the
			// client-side stream (which may trigger queued message flush).
			if (currentSessionId) {
				try {
					await fetch("/api/chat/stop", {
						method: "POST",
						headers: {
							"Content-Type": "application/json",
						},
						body: JSON.stringify({
							sessionId: currentSessionId,
						}),
					});
				} catch { /* ignore */ }
			}

		// Stop the useChat transport stream (transitions status → "ready").
		void stop();
	}, [currentSessionId, stop]);

		// ── Queue handlers ──

		const removeQueuedMessage = useCallback((id: string) => {
			setQueuedMessages((prev) => prev.filter((m) => m.id !== id));
		}, []);

		const updateQueuedMessageText = useCallback((id: string, text: string) => {
			setQueuedMessages((prev) => prev.map((m) => m.id === id ? { ...m, text } : m));
		}, []);

		/** Force-send: stop the agent, then immediately submit this queued message. */
		const forceSendQueuedMessage = useCallback(
			async (id: string) => {
				const msg = queuedMessages.find((m) => m.id === id);
				if (!msg) {return;}
				// Remove it from the queue first.
				setQueuedMessages((prev) => prev.filter((m) => m.id !== id));
				// Stop the current agent run.
				await handleStop();
				// Submit the message after a short delay to let status settle.
				setTimeout(() => {
					void handleEditorSubmit(msg.text, msg.mentionedFiles, msg.attachedFiles);
				}, 100);
			},
			[queuedMessages, handleStop, handleEditorSubmit],
		);

		// ── Attachment handlers ──

		const handleFilesSelected = useCallback(
			(files: SelectedFile[]) => {
				const newFiles: AttachedFile[] = files.map(
					(f) => ({
						id: `${f.path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
						name: f.name,
						path: f.path,
					}),
				);
				setAttachedFiles((prev) => [
					...prev,
					...newFiles,
				]);
			},
			[],
		);

		const removeAttachment = useCallback((id: string) => {
			setAttachedFiles((prev) => {
				const removed = prev.find((f) => f.id === id);
				if (removed?.localUrl) {URL.revokeObjectURL(removed.localUrl);}
				return prev.filter((f) => f.id !== id);
			});
		}, []);

		const clearAllAttachments = useCallback(() => {
			setAttachedFiles((prev) => {
				for (const f of prev) {
					if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
				}
				return [];
			});
		}, []);

		/** Upload native files (e.g. dropped from Finder/Desktop) and attach them.
		 *  Shows files instantly with a local preview, then uploads in the background. */
		const uploadAndAttachNativeFiles = useCallback(
			(files: FileList) => {
				const fileArray = Array.from(files);

				// Immediately add placeholder entries with local blob URLs
				const placeholders: AttachedFile[] = fileArray.map((file) => ({
					id: `pending-${Date.now()}-${Math.random().toString(36).slice(2)}`,
					name: file.name,
					path: "",
					uploading: true,
					localUrl: URL.createObjectURL(file),
				}));
				setAttachedFiles((prev) => [...prev, ...placeholders]);

				// Upload each file in the background and update the entry
				for (let i = 0; i < fileArray.length; i++) {
					const file = fileArray[i];
					const placeholderId = placeholders[i].id;
					const localUrl = placeholders[i].localUrl;

					const form = new FormData();
					form.append("file", file);
					fetch("/api/workspace/upload", {
						method: "POST",
						body: form,
					})
						.then((res) => res.ok ? res.json() : null)
						.then((json: { ok?: boolean; path?: string } | null) => {
							if (json?.ok && json.path) {
								// Replace placeholder with the real uploaded file
								setAttachedFiles((prev) =>
									prev.map((f) =>
										f.id === placeholderId
											? { ...f, path: json.path!, uploading: false }
											: f,
									),
								);
							} else {
								// Upload failed — remove the placeholder
								setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholderId));
								if (localUrl) {URL.revokeObjectURL(localUrl);}
							}
						})
						.catch(() => {
							setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholderId));
							if (localUrl) {URL.revokeObjectURL(localUrl);}
						});
				}
			},
			[],
		);

		// ── Status label ──

		const _statusLabel = loadingSession
			? "Loading session..."
			: isReconnecting
					? "Resuming stream..."
					: status === "ready"
						? "Ready"
						: status === "submitted"
							? "Thinking..."
							: status === "streaming"
								? (hasRunningSubagents ? "Waiting for subagents..." : "Streaming...")
								: status === "error"
									? "Error"
									: status;

		// Show an inline Unicode spinner in the message flow when the AI
		// is thinking/streaming but hasn't produced visible text yet.
		const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null;
		const lastAssistantHasText =
			lastMsg?.role === "assistant" &&
			lastMsg.parts.some((p) => p.type === "text" && (p as { text: string }).text.length > 0);
		const showInlineSpinner = isStreaming && !lastAssistantHasText;

		// ── Render ──

		return (
			<div
				className="h-full flex flex-col"
				style={{ background: "var(--color-main-bg)" }}
			>
				{/* Header — sticky glass bar */}
				<header
					className={`${compact ? "px-3 py-2" : "px-3 py-2 md:px-6 md:py-3"} flex items-center justify-between z-20`}
					style={{
						background: "var(--color-bg-glass)",
					}}
				>
					<div className="min-w-0 flex-1">
						{compact && fileContext ? (
							<h2
								className="text-xs font-semibold truncate"
								style={{
									color: "var(--color-text)",
								}}
							>
								Chat: {fileContext.filename}
							</h2>
						) : (
							<h2
								className="text-sm font-semibold"
								style={{
									color: "var(--color-text)",
								}}
							>
								{currentSessionId
									? (sessionTitle || "Chat Session")
									: "New Chat"}
							</h2>
						)}
					</div>
					<div className="flex items-center gap-1 shrink-0">
						{currentSessionId && onDeleteSession && (
							<DropdownMenu>
								<DropdownMenuTrigger
									className="p-1.5 rounded-lg"
									style={{ color: "var(--color-text-muted)" }}
									title="More options"
									aria-label="More options"
								>
									<svg
										width="16"
										height="16"
										viewBox="0 0 24 24"
										fill="none"
										stroke="currentColor"
										strokeWidth="2"
										strokeLinecap="round"
										strokeLinejoin="round"
									>
										<circle cx="12" cy="12" r="1" />
										<circle cx="5" cy="12" r="1" />
										<circle cx="19" cy="12" r="1" />
									</svg>
								</DropdownMenuTrigger>
								<DropdownMenuContent align="end" side="bottom">
									<DropdownMenuItem
										variant="destructive"
										onSelect={() => onDeleteSession(currentSessionId)}
									>
										<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
										Delete
									</DropdownMenuItem>
								</DropdownMenuContent>
							</DropdownMenu>
						)}
						{compact && (
							<button
								type="button"
								onClick={() => handleNewSession()}
								className="p-1.5 rounded-lg"
								style={{
									color: "var(--color-text-muted)",
								}}
								title="New chat"
							>
								<svg
									width="14"
									height="14"
									viewBox="0 0 24 24"
									fill="none"
									stroke="currentColor"
									strokeWidth="2"
									strokeLinecap="round"
									strokeLinejoin="round"
								>
									<path d="M12 5v14" />
									<path d="M5 12h14" />
								</svg>
							</button>
						)}
					</div>
				</header>

				{/* File-scoped session tabs (compact mode) */}
				{compact && fileContext && fileSessions.length > 0 && (
					<div
						className="px-2 py-1.5 border-b flex gap-1 overflow-x-auto z-20"
						style={{
							borderColor: "var(--color-border)",
							background: "var(--color-bg-glass)",
						}}
					>
						{fileSessions.slice(0, 10).map((s) => (
							<button
								key={s.id}
								type="button"
								onClick={() =>
									handleSessionSelect(s.id)
								}
								className="px-2.5 py-1 text-[10px] rounded-full whitespace-nowrap shrink-0 font-medium"
								style={{
									background:
										s.id === currentSessionId
											? "var(--color-accent)"
											: "var(--color-surface-hover)",
									color:
										s.id === currentSessionId
											? "white"
											: "var(--color-text-muted)",
									border:
										s.id === currentSessionId
											? "none"
											: "1px solid var(--color-border)",
								}}
							>
								{s.title.length > 25
									? s.title.slice(0, 25) + "..."
									: s.title}
							</button>
						))}
					</div>
				)}

				<div
					ref={scrollContainerRef}
					className="flex-1 overflow-y-auto min-h-0"
				>
				{/* Messages */}
				<div
					className={compact ? "px-3" : "px-6"}
				>
					{loadingSession ? (
						<div className="flex items-center justify-center h-full min-h-[60vh]">
							<div className="text-center">
								<UnicodeSpinner
									name="braille"
									className="block text-2xl mx-auto mb-3"
									style={{ color: "var(--color-text-muted)" }}
								/>
								<p
									className="text-xs"
									style={{
										color: "var(--color-text-muted)",
									}}
								>
									Loading session...
								</p>
							</div>
						</div>
					) : messages.length === 0 ? (
						<div className="flex items-center justify-center h-full min-h-[60vh]">
							<div className="text-center max-w-md px-4">
								{compact ? (
									<p
										className="text-sm"
										style={{
											color: "var(--color-text-muted)",
										}}
									>
										Ask about this file
									</p>
								) : (
									<>
										<h3
											className="font-instrument text-3xl tracking-tight mb-2"
											style={{
												color: "var(--color-text)",
											}}
										>
											What can I help with?
										</h3>
										<p
											className="text-sm leading-relaxed"
											style={{
												color: "var(--color-text-muted)",
											}}
										>
											Send a message to start a
											conversation with your
											agent.
										</p>
									</>
								)}
							</div>
						</div>
					) : (
						<div
							className={`${compact ? "" : "max-w-2xl mx-auto"} py-3`}
						>
						{rawView ? (
							<pre
								className="text-xs whitespace-pre-wrap break-all font-mono p-4 rounded-xl"
								style={{ color: "var(--color-text)", background: "var(--color-surface-hover)" }}
							>
								{JSON.stringify(messages, null, 2)}
							</pre>
						) : messages.map((message, i) => (
							<ChatMessage
								key={message.id}
								message={message}
								isStreaming={isStreaming && i === messages.length - 1}
								onSubagentClick={onSubagentClick}
								onFilePathClick={onFilePathClick}
							/>
						))}
						{showInlineSpinner && (
							<div className="py-3 min-w-0">
								<UnicodeSpinner
									name="pulse"
									className="text-base"
									style={{ color: "var(--color-text-muted)" }}
								/>
							</div>
						)}
							<div ref={messagesEndRef} />
						</div>
					)}
				</div>

				{/* Transport-level error display */}
				{error && (
					<div
						className="px-3 py-2 flex items-center gap-2 sticky bottom-[72px] z-10"
						style={{
							background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
							borderColor: `color-mix(in srgb, var(--color-error) 18%, transparent)`,
							color: "var(--color-error)",
						}}
					>
						<svg
							width="14"
							height="14"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							strokeWidth="2"
							strokeLinecap="round"
							strokeLinejoin="round"
							className="shrink-0"
						>
							<circle cx="12" cy="12" r="10" />
							<line
								x1="12"
								y1="8"
								x2="12"
								y2="12"
							/>
							<line
								x1="12"
								y1="16"
								x2="12.01"
								y2="16"
							/>
						</svg>
						<p className="text-xs">{error.message}</p>
					</div>
				)}
				</div>

				{/* Input bar at bottom */}
				<div
					className={`${compact ? "px-3 py-2" : "px-3 pb-3 pt-0 md:px-6 md:pb-5"} z-20`}
					style={{ background: "var(--color-bg-glass)" }}
				>
					<div
						className={compact ? "" : "max-w-[720px] mx-auto"}
					>
						<div
							data-chat-drop-target=""
							className="rounded-3xl overflow-hidden border shadow-[0_0_32px_rgba(0,0,0,0.07)] transition-[outline,box-shadow] duration-150 ease-out data-drag-hover:outline-2 data-drag-hover:outline-dashed data-drag-hover:outline-(--color-accent) data-drag-hover:-outline-offset-2 data-drag-hover:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-accent)_15%,transparent),0_0_32px_rgba(0,0,0,0.07)]!"
							style={{
								background: "var(--color-surface)",
								borderColor: "var(--color-border)",
							}}
						onDragOver={(e) => {
							if (
								e.dataTransfer?.types.includes("application/x-file-mention") ||
								e.dataTransfer?.types.includes("Files")
							) {
								e.preventDefault();
								e.dataTransfer.dropEffect = "copy";
								// visual feedback
								(e.currentTarget as HTMLElement).setAttribute("data-drag-hover", "");
							}
						}}
						onDragLeave={(e) => {
							// Only remove when leaving the container itself (not entering a child)
							if (!e.currentTarget.contains(e.relatedTarget as Node)) {
								(e.currentTarget as HTMLElement).removeAttribute("data-drag-hover");
							}
						}}
						onDrop={(e) => {
							(e.currentTarget as HTMLElement).removeAttribute("data-drag-hover");

							// Sidebar file mention drop
							const data = e.dataTransfer?.getData("application/x-file-mention");
							if (data) {
								e.preventDefault();
								e.stopPropagation();
								try {
									const { name, path } = JSON.parse(data) as { name: string; path: string };
									if (name && path) {
										editorRef.current?.insertFileMention(name, path);
									}
								} catch {
									// ignore malformed data
								}
								return;
							}

							// Native file drop (from OS file manager / Desktop)
							const files = e.dataTransfer?.files;
							if (files && files.length > 0) {
								e.preventDefault();
								e.stopPropagation();
								 uploadAndAttachNativeFiles(files);
							}
						}}
						>
						{/* Queued messages indicator */}
						{queuedMessages.length > 0 && (
							<div className={compact ? "px-2 pt-2" : "px-3 pt-3"}>
								<div
									className="rounded-xl border overflow-hidden"
									style={{
										background: "var(--color-surface)",
										borderColor: "var(--color-border)",
										boxShadow: "var(--shadow-sm)",
									}}
								>
									<div
										className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider"
										style={{ color: "var(--color-text-muted)", background: "var(--color-surface-hover)" }}
									>
										Queue ({queuedMessages.length})
									</div>
									<div className="flex flex-col p-2">
										{queuedMessages.map((msg, idx) => (
											<QueueItem
												key={msg.id}
												msg={msg}
												idx={idx}
												onEdit={updateQueuedMessageText}
												onSendNow={forceSendQueuedMessage}
												onRemove={removeQueuedMessage}
											/>
										))}
									</div>
								</div>
							</div>
						)}

						{/* Attachment preview strip */}
						<AttachmentStrip
							files={attachedFiles}
							compact={compact}
							onRemove={removeAttachment}
							onClearAll={
								clearAllAttachments
							}
						/>

							<ChatEditor
								ref={editorRef}
								onSubmit={handleEditorSubmit}
								onChange={(isEmpty) =>
									setEditorEmpty(isEmpty)
								}
								onNativeFileDrop={uploadAndAttachNativeFiles}
							placeholder={
								compact && fileContext
									? `Ask about ${fileContext.isDirectory ? "this folder" : fileContext.filename}...`
									: isStreaming
										? "Type to queue a message..."
									: attachedFiles.length >
												0
											? "Add a message or send files..."
											: "Type @ to mention files..."
								}
								disabled={loadingSession}
								compact={compact}
							/>

						{/* Toolbar row */}
							<div
								className={`flex items-center justify-between ${compact ? "px-2 pb-1.5" : "px-3 pb-2.5"}`}
							>
							<div className="flex items-center gap-0.5">
								<button
									type="button"
									onClick={() =>
										setShowFilePicker(
											true,
										)
									}
									className="p-1.5 rounded-lg hover:opacity-80 transition-opacity"
									style={{
										color:
											attachedFiles.length >
											0
												? "var(--color-accent)"
												: "var(--color-text-muted)",
									}}
									title="Attach files"
								>
										<svg
											width="16"
											height="16"
											viewBox="0 0 24 24"
											fill="none"
											stroke="currentColor"
											strokeWidth="2"
											strokeLinecap="round"
											strokeLinejoin="round"
										>
											<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
										</svg>
									</button>
								</div>
							{/* Send / Stop / Queue buttons */}
							<div className="flex items-center gap-1.5">
								{isStreaming && (
									<button
										type="button"
										onClick={() => handleStop()}
										className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center`}
										style={{
											background: "var(--color-text)",
											color: "var(--color-bg)",
										}}
										title="Stop generating"
									>
										<svg
											width="8"
											height="8"
											viewBox="0 0 10 10"
											fill="currentColor"
										>
											<rect width="10" height="10" rx="1.5" />
										</svg>
									</button>
								)}
								{isStreaming ? (
									<button
										type="button"
										onClick={() => {
											editorRef.current?.submit();
										}}
										disabled={
											(editorEmpty &&
												attachedFiles.length === 0) ||
											loadingSession
										}
										className="h-7 px-3 rounded-full flex items-center gap-1.5 text-[12px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
										style={{
											background:
												!editorEmpty || attachedFiles.length > 0
													? "var(--color-accent)"
													: "var(--color-surface-hover)",
											color:
												!editorEmpty || attachedFiles.length > 0
													? "white"
													: "var(--color-text-muted)",
										}}
										title="Add to queue"
									>
										<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
											<polyline points="9 10 4 15 9 20" />
											<path d="M20 4v7a4 4 0 0 1-4 4H4" />
										</svg>
										Queue
									</button>
								) : (
									<button
										type="button"
										onClick={() => {
											editorRef.current?.submit();
										}}
										disabled={
											(editorEmpty &&
												attachedFiles.length === 0) ||
											loadingSession
										}
										className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed`}
										style={{
											background:
												!editorEmpty ||
												attachedFiles.length > 0
													? "var(--color-accent)"
													: "var(--color-text-muted)",
											color:
												!editorEmpty || attachedFiles.length > 0
													? "white"
													: "var(--color-bg)",
										}}
										title="Send message"
									>
										<svg
											width="14"
											height="14"
											viewBox="0 0 24 24"
											fill="none"
											stroke="currentColor"
											strokeWidth="2.5"
											strokeLinecap="round"
											strokeLinejoin="round"
										>
											<path d="M12 19V5" />
											<path d="m5 12 7-7 7 7" />
										</svg>
									</button>
								)}
							</div>
							</div>
						</div>
					</div>
				</div>

				{/* File picker modal */}
				<FilePickerModal
					open={showFilePicker}
					onClose={() =>
						setShowFilePicker(false)
					}
					onSelect={handleFilesSelected}
				/>

			</div>
		);
	},
);
