import {
	ActionBarMorePrimitive,
	ActionBarPrimitive,
	AuiIf,
	BranchPickerPrimitive,
	ComposerPrimitive,
	ErrorPrimitive,
	MessagePrimitive,
	SuggestionPrimitive,
	ThreadPrimitive,
	useAui,
	useAuiState,
} from "@assistant-ui/react";
import {
	ArrowDownIcon,
	ArrowUpIcon,
	BookOpenIcon,
	BrainIcon,
	CheckIcon,
	ChevronDownIcon,
	ChevronLeftIcon,
	ChevronRightIcon,
	CopyIcon,
	DownloadIcon,
	FileTextIcon,
	ListIcon,
	MoreHorizontalIcon,
	PencilIcon,
	RefreshCwIcon,
	SearchIcon,
	SquareIcon,
	WrenchIcon,
} from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type {
	ComponentProps,
	Dispatch,
	FC,
	KeyboardEvent,
	ReactNode,
	SetStateAction,
} from "react";
import {
	useCallback,
	useEffect,
	useId,
	useMemo,
	useRef,
	useState,
} from "react";
import TextareaAutosize from "react-textarea-autosize";
import { useActivityPanel } from "@/components/assistant-ui/activity-panel";
import {
	ComposerAddAttachment,
	ComposerAttachments,
	UserMessageAttachments,
} from "@/components/assistant-ui/attachment";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { Reasoning } from "@/components/assistant-ui/reasoning";
import {
	type ClawRunStatus,
	fetchLatestRunStatus,
	toReplayRepository,
} from "@/components/assistant-ui/thread-list";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import {
	Dialog,
	DialogContent,
	DialogDescription,
	DialogFooter,
	DialogHeader,
	DialogTitle,
	DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
	Tooltip,
	TooltipContent,
	TooltipTrigger,
} from "@/components/ui/tooltip";
import {
	ACTIVE_SESSION_CHANGED_EVENT,
	clearActiveSessionId,
	clearPendingWorkspaceSessionId,
	markFreshLocalId,
	PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
	readActiveSessionId,
	readPendingWorkspaceSessionId,
	writeActiveSessionId,
	writePendingWorkspaceSessionId,
} from "@/lib/claw-active-session";
import { fetchSessionReplaySnapshot } from "@/lib/claw-session-cache";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { readSessionIdFromUrl } from "@/lib/claw-session-url";
import { dispatchSkillToggled } from "@/lib/use-training-mode";
import { cn } from "@/lib/utils";

type ComposerInsertEvent = CustomEvent<{ text: string }>;
const INPUT_QUEUE_CHANGED_EVENT = "claw-input-queue-changed";

type SkillSummary = {
	name: string;
	description?: string;
	when_to_use?: string;
	enabled?: boolean;
	configurable?: boolean;
};

type SkillSyncResponse = {
	skills?: SkillSummary[];
	updated?: boolean;
	before?: string;
	after?: string;
	changed_files?: string[];
	message?: string;
	error?: string;
	detail?: string;
};

type SlashCommandSummary = {
	names: string[];
	primary: string;
	description?: string;
	webui_supported?: boolean;
};

type ClawState = {
	model?: string;
	active_session_id?: string | null;
};

type ClawStoredSession = Parameters<typeof toReplayRepository>[0] & {
	model?: string;
};

type SessionInputQueueItem = {
	id: number;
	session_id: string;
	run_id?: string;
	kind: "next_turn" | "guidance";
	status: "pending" | "consumed" | "cancelled";
	content: string;
	created_at?: number;
	updated_at?: number;
	consumed_at?: number | null;
	metadata?: Record<string, unknown>;
};

type ModelOption = {
	id: string;
	provider?: string;
	model_type?: string;
};

type ContextBudget = {
	model?: string;
	context_window_tokens?: number;
	projected_input_tokens?: number;
	message_tokens?: number;
	chat_overhead_tokens?: number;
	reserved_output_tokens?: number;
	reserved_compaction_buffer_tokens?: number;
	reserved_schema_tokens?: number;
	hard_input_limit_tokens?: number;
	soft_input_limit_tokens?: number;
	overflow_tokens?: number;
	soft_overflow_tokens?: number;
	exceeds_hard_limit?: boolean;
	exceeds_soft_limit?: boolean;
	token_counter_backend?: string;
	token_counter_source?: string;
	token_counter_accurate?: boolean;
};

function scheduleSessionListRefresh() {
	if (typeof window === "undefined") return;
	window.dispatchEvent(new Event("claw-sessions-changed"));
	for (const delay of [300, 1000, 2500]) {
		window.setTimeout(() => {
			window.dispatchEvent(new Event("claw-sessions-changed"));
		}, delay);
	}
}

export const Thread: FC = () => {
	useRefreshReplayedRun();
	useRefreshCurrentRun();
	return (
		<ThreadPrimitive.Root
			className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
			style={{
				["--thread-max-width" as string]: "44rem",
				["--composer-radius" as string]: "24px",
				["--composer-padding" as string]: "10px",
			}}
		>
			<ThreadPrimitive.Viewport
				turnAnchor="top"
				data-slot="aui_thread-viewport"
				className="relative flex flex-1 flex-col overflow-x-auto overflow-y-auto scroll-smooth"
			>
				<ChatTopActions />
				<div className="mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col px-4 pt-4">
					<AuiIf condition={(s) => s.thread.isEmpty}>
						<ThreadWelcome />
					</AuiIf>

					<div
						data-slot="aui_message-group"
						className="mb-10 flex flex-col gap-y-8 empty:hidden"
					>
						<ThreadPrimitive.Messages>
							{() => <ThreadMessage />}
						</ThreadPrimitive.Messages>
					</div>

					<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mt-auto flex flex-col gap-4 overflow-visible rounded-t-(--composer-radius) bg-background pb-4 md:pb-6">
						<ThreadScrollToBottom />
						<Composer />
					</ThreadPrimitive.ViewportFooter>
				</div>
			</ThreadPrimitive.Viewport>
		</ThreadPrimitive.Root>
	);
};

function useRefreshReplayedRun() {
	const { replaySession } = useClawSessionReplay();
	const replayedRun = useReplayedRunState();

	useEffect(() => {
		if (!replayedRun.active || !replayedRun.sessionId) return;
		const activeSessionId = replayedRun.sessionId;
		let cancelled = false;
		let lastSignature: string | null = null;

		async function refreshIfFinished() {
			const snapshot = await fetchSessionReplaySnapshot<
				ClawStoredSession,
				ClawRunStatus
			>(activeSessionId);
			if (cancelled || !snapshot) return;
			if (snapshot.signature === lastSignature) return;
			lastSignature = snapshot.signature;
			replaySession(
				activeSessionId,
				toReplayRepository(
					snapshot.session,
					activeSessionId,
					snapshot.runStatus,
				),
			);
			if (!isActiveRunStatus(snapshot.runStatus)) {
				window.dispatchEvent(new Event("claw-sessions-changed"));
			}
		}

		refreshIfFinished();
		const interval = window.setInterval(refreshIfFinished, 3000);
		return () => {
			cancelled = true;
			window.clearInterval(interval);
		};
	}, [replayedRun.active, replayedRun.sessionId, replaySession]);
}

function useRefreshCurrentRun() {
	const { replaySession } = useClawSessionReplay();
	const runtimeRunning = useAuiState((s) => s.thread.isRunning);
	const sessionId = useCurrentThreadSessionId({
		includePending: true,
		includeActive: true,
	});
	// Per-session bookkeeping: switching sessions (sidebar history) and coming
	// back must NOT re-trigger replaySession on a session that has already been
	// applied — replaySession rebuilds the thread tree, which causes a layout
	// shift / scroll jitter in the chat UI. Single-value refs would get
	// overwritten when visiting another session and forget that this session
	// was already settled.
	const activeRunRefMap = useRef<Map<string, string>>(new Map());
	const appliedTerminalRefMap = useRef<Map<string, string>>(new Map());

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

		async function refreshIfSettled() {
			if (!sessionId) return;
			const snapshot = await fetchSessionReplaySnapshot<
				ClawStoredSession,
				ClawRunStatus
			>(sessionId);
			const runStatus = snapshot?.runStatus ?? null;
			if (cancelled) return;
			if (runStatus?.status === "queued" || runStatus?.status === "running") {
				const runKey = runStatus.run_id ?? "active";
				const previousRunKey = activeRunRefMap.current.get(sessionId) ?? null;
				activeRunRefMap.current.set(sessionId, runKey);
				// Watcher auto-resume: backend started a new run via _maybe_auto_resume
				// without a local SSE stream driving the UI. We need to replay once so
				// the active-run placeholder (pending prompt + 思考中 bubble) appears.
				// useRefreshReplayedRun will take over polling once replayedRun.active
				// becomes true.
				if (!runtimeRunning && previousRunKey !== runKey) {
					if (!snapshot || cancelled) return;
					replaySession(
						sessionId,
						toReplayRepository(snapshot.session, sessionId, runStatus),
					);
				}
				return;
			}
			const sessionActiveRun = activeRunRefMap.current.get(sessionId) ?? null;
			if (!runtimeRunning && !sessionActiveRun) return;
			const terminalKey = [
				runStatus?.run_id ?? sessionActiveRun ?? "unknown",
				runStatus?.status ?? "idle",
				runStatus?.finished_at ?? "",
				runStatus?.elapsed_ms ?? "",
			].join(":");
			if (appliedTerminalRefMap.current.get(sessionId) === terminalKey) return;
			if (!snapshot || cancelled) return;
			replaySession(
				sessionId,
				toReplayRepository(snapshot.session, sessionId, runStatus),
			);
			activeRunRefMap.current.delete(sessionId);
			appliedTerminalRefMap.current.set(sessionId, terminalKey);
			scheduleSessionListRefresh();
		}

		refreshIfSettled();
		const interval = window.setInterval(refreshIfSettled, 1500);
		return () => {
			cancelled = true;
			window.clearInterval(interval);
		};
	}, [runtimeRunning, sessionId, replaySession]);
}

function useReplayedRunState() {
	const active = useAuiState((s) =>
		s.thread.messages.some((message) => isReplayRunMessageId(message.id)),
	);
	const replaySessionId = useAuiState((s) => {
		const replayRunMessage = s.thread.messages.find((message) =>
			isReplayRunMessageId(message.id),
		);
		return normalizeSessionId(
			readMetadataValue(replayRunMessage?.metadata, "sessionId"),
		);
	});
	const latestSessionId = useAuiState((s) =>
		normalizeSessionId(
			findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
		),
	);
	const runId = useAuiState((s) => {
		const replayRunMessage = s.thread.messages.find((message) =>
			isReplayRunMessageId(message.id),
		);
		return normalizeSessionId(
			readMetadataValue(replayRunMessage?.metadata, "runId"),
		);
	});
	const sessionId = replaySessionId ?? latestSessionId;
	return useMemo(
		() => ({ active, sessionId, runId }),
		[active, sessionId, runId],
	);
}

function usePendingWorkspaceSessionId() {
	const [sessionId, setSessionId] = useState<string | null>(() =>
		typeof window !== "undefined"
			? normalizeSessionId(readPendingWorkspaceSessionId())
			: null,
	);

	useEffect(() => {
		const refresh = () => {
			setSessionId(normalizeSessionId(readPendingWorkspaceSessionId()));
		};
		window.addEventListener(PENDING_WORKSPACE_SESSION_CHANGED_EVENT, refresh);
		window.addEventListener("storage", refresh);
		window.addEventListener("focus", refresh);
		return () => {
			window.removeEventListener(
				PENDING_WORKSPACE_SESSION_CHANGED_EVENT,
				refresh,
			);
			window.removeEventListener("storage", refresh);
			window.removeEventListener("focus", refresh);
		};
	}, []);

	return sessionId;
}

function useActiveSessionId() {
	const [sessionId, setSessionId] = useState<string | null>(() =>
		typeof window !== "undefined"
			? normalizeSessionId(readSessionIdFromUrl() ?? readActiveSessionId())
			: null,
	);

	useEffect(() => {
		const refresh = () => {
			setSessionId(
				normalizeSessionId(readSessionIdFromUrl() ?? readActiveSessionId()),
			);
		};
		window.addEventListener(ACTIVE_SESSION_CHANGED_EVENT, refresh);
		window.addEventListener("storage", refresh);
		window.addEventListener("focus", refresh);
		window.addEventListener("popstate", refresh);
		return () => {
			window.removeEventListener(ACTIVE_SESSION_CHANGED_EVENT, refresh);
			window.removeEventListener("storage", refresh);
			window.removeEventListener("focus", refresh);
			window.removeEventListener("popstate", refresh);
		};
	}, []);

	return sessionId;
}

function useCurrentThreadSessionId({
	includePending = false,
	includeActive = true,
} = {}) {
	const currentRun = useReplayedRunState();
	const pendingSessionId = usePendingWorkspaceSessionId();
	const activeSessionId = useActiveSessionId();
	useEffect(() => {
		if (currentRun.sessionId && currentRun.sessionId === pendingSessionId) {
			clearPendingWorkspaceSessionId();
		}
	}, [currentRun.sessionId, pendingSessionId]);
	const replayRunSessionId =
		currentRun.active &&
		(!activeSessionId || currentRun.sessionId === activeSessionId)
			? currentRun.sessionId
			: null;
	return (
		(includeActive ? activeSessionId : null) ??
		(includePending ? pendingSessionId : null) ??
		replayRunSessionId ??
		currentRun.sessionId
	);
}

async function cancelLatestRun(sessionId: string, runId?: string | null) {
	await fetch("/api/claw/runs/cancel", {
		method: "POST",
		headers: { "content-type": "application/json" },
		body: JSON.stringify({
			session_id: sessionId,
			...(runId ? { run_id: runId } : {}),
		}),
	});
}

const ChatTopActions: FC = () => {
	const { openFiles } = useActivityPanel();
	const [workspaceOpen, setWorkspaceOpen] = useState(false);
	// "切换工作区" dialog 与 indicator 必须用同一个 sessionId，否则会出现
	// "绑给 A、查的是 B" 的错位（agent 的 bash 路由也是用 active session id，
	// 错位时表面 indicator 显示已连接，实际 agent 跑在本地）。
	// 新建空会话场景由 thread-list.tsx 的 clearActiveSessionId() 兜底，
	// 此处不再需要刻意排除 activeSessionId。
	const sessionId = useCurrentThreadSessionId({
		includePending: true,
		includeActive: true,
	});
	const workspaceStatus = useJupyterWorkspaceStatus(sessionId);

	return (
		<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
			<PopoverPrimitive.Root>
				<PopoverPrimitive.Trigger asChild>
					<Button
						type="button"
						variant="ghost"
						size="icon"
						className="pointer-events-auto size-8 rounded-full bg-background/90 text-muted-foreground shadow-sm backdrop-blur hover:bg-muted hover:text-foreground"
						aria-label="聊天选项"
					>
						<MoreHorizontalIcon className="size-4" />
					</Button>
				</PopoverPrimitive.Trigger>
				<PopoverPrimitive.Portal>
					<PopoverPrimitive.Content
						side="bottom"
						align="end"
						sideOffset={8}
						className="z-50 min-w-40 rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg outline-none"
					>
						<button
							type="button"
							className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
							onClick={() => {
								openFiles(sessionId);
							}}
						>
							<FileTextIcon className="size-4 text-muted-foreground" />
							聊天中的文件
						</button>
						<button
							type="button"
							className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
							onClick={() => setWorkspaceOpen(true)}
						>
							<WrenchIcon className="size-4 text-muted-foreground" />
							{workspaceStatus?.connected ? "Jupyter 工作区" : "切换工作区"}
						</button>
					</PopoverPrimitive.Content>
				</PopoverPrimitive.Portal>
			</PopoverPrimitive.Root>
			<WorkspaceSwitchDialog
				open={workspaceOpen}
				onOpenChange={setWorkspaceOpen}
				sessionId={sessionId}
			/>
		</div>
	);
};

type JupyterWorkspaceStatus = {
	connected?: boolean;
	session_id?: string;
	workspace_cwd?: string;
	jupyter_tree_url?: string;
	detail?: string;
	error?: string;
};

type WorkspaceSwitchProgress = {
	percent: number;
	label: string;
};

const WORKSPACE_SWITCH_STEPS = [
	"登录 Jupyter",
	"初始化目录",
	"创建 Python 环境",
	"配置 pip 源",
	"同步 Skill",
	"链接工作区",
];

function useJupyterWorkspaceStatus(sessionId: string | null) {
	const [status, setStatus] = useState<JupyterWorkspaceStatus | null>(null);

	useEffect(() => {
		if (!sessionId) {
			setStatus(null);
			return;
		}
		const sessionIdForRequest = sessionId;
		let cancelled = false;

		async function refresh() {
			try {
				const response = await fetch(
					`/api/claw/jupyter?session_id=${encodeURIComponent(sessionIdForRequest)}`,
					{ cache: "no-store" },
				);
				const payload = (await response.json().catch(() => ({}))) as
					| JupyterWorkspaceStatus
					| Record<string, never>;
				if (cancelled) return;
				if (!response.ok) {
					setStatus(null);
					return;
				}
				setStatus(payload as JupyterWorkspaceStatus);
			} catch {
				if (!cancelled) setStatus(null);
			}
		}

		void refresh();
		const onRefresh = () => void refresh();
		window.addEventListener("focus", onRefresh);
		window.addEventListener("claw-sessions-changed", onRefresh);
		const interval = window.setInterval(refresh, 8000);
		return () => {
			cancelled = true;
			window.removeEventListener("focus", onRefresh);
			window.removeEventListener("claw-sessions-changed", onRefresh);
			window.clearInterval(interval);
		};
	}, [sessionId]);

	return status;
}

function WorkspaceSwitchDialog({
	open,
	onOpenChange,
	sessionId,
}: {
	open: boolean;
	onOpenChange: (open: boolean) => void;
	sessionId: string | null;
}) {
	const [jupyterUrl, setJupyterUrl] = useState("");
	const [password, setPassword] = useState("");
	const [workspaceRoot, setWorkspaceRoot] = useState(
		"/root/zk_agent_workspaces",
	);
	const [status, setStatus] = useState<JupyterWorkspaceStatus | null>(null);
	const [submitting, setSubmitting] = useState(false);
	const [message, setMessage] = useState("");
	const [progress, setProgress] = useState<WorkspaceSwitchProgress | null>(
		null,
	);
	const fieldId = useId();
	const effectiveSessionId = sessionId;

	useEffect(() => {
		if (!open || !effectiveSessionId) return;
		const sessionIdForRequest = effectiveSessionId;
		let cancelled = false;
		async function refreshStatus() {
			const response = await fetch(
				`/api/claw/jupyter?session_id=${encodeURIComponent(sessionIdForRequest)}`,
				{ cache: "no-store" },
			);
			const payload = (await response.json().catch(() => ({}))) as
				| JupyterWorkspaceStatus
				| Record<string, never>;
			if (!cancelled) setStatus(payload as JupyterWorkspaceStatus);
		}
		void refreshStatus().catch(() => undefined);
		return () => {
			cancelled = true;
		};
	}, [open, effectiveSessionId]);

	async function submit() {
		setMessage("");
		const sessionIdForRequest = ensureWorkspaceSessionId(effectiveSessionId);
		if (!jupyterUrl.trim() || !password) {
			setMessage("请填写 Jupyter 地址和密码。");
			return;
		}
		setSubmitting(true);
		const progressTimer = startWorkspaceProgress(setProgress);
		try {
			const needsFirstMessageSession = !normalizeSessionId(sessionId);
			const response = await fetch("/api/claw/jupyter", {
				method: "POST",
				headers: { "content-type": "application/json" },
				body: JSON.stringify({
					session_id: sessionIdForRequest,
					base_url: jupyterUrl.trim(),
					password,
					workspace_root: workspaceRoot.trim() || "/root/zk_agent_workspaces",
				}),
			});
			const payload = (await response.json().catch(() => ({}))) as
				| JupyterWorkspaceStatus
				| { detail?: string; error?: string };
			if (!response.ok) {
				setMessage(payload.detail ?? payload.error ?? "切换工作区失败");
				return;
			}
			writeActiveSessionId(sessionIdForRequest);
			if (needsFirstMessageSession) {
				writePendingWorkspaceSessionId(sessionIdForRequest);
			}
			window.dispatchEvent(new Event("claw-workspaces-changed"));
			setStatus(payload as JupyterWorkspaceStatus);
			setProgress({ percent: 100, label: "切换完成" });
			setMessage("工作区切换成功，后续工具会在远端 Jupyter 工作区执行。");
			setPassword("");
		} finally {
			window.clearInterval(progressTimer);
			setSubmitting(false);
			window.setTimeout(() => setProgress(null), 1200);
		}
	}

	return (
		<Dialog open={open} onOpenChange={onOpenChange}>
			<DialogContent className="pointer-events-auto sm:max-w-xl">
				<DialogHeader>
					<DialogTitle>
						{status?.connected ? "Jupyter 工作区" : "切换工作区"}
					</DialogTitle>
					<DialogDescription>
						{status?.connected
							? "当前 session 已固定到远端 Jupyter 工作区。"
							: "把当前 session 的工具执行切换到远端 Jupyter 开发机。"}
					</DialogDescription>
				</DialogHeader>
				<div className="grid gap-4 text-sm">
					{status?.connected ? (
						<div className="rounded-md border bg-muted/40 p-3 text-muted-foreground">
							<div className="font-medium text-foreground">当前已连接</div>
							<div className="mt-1 break-all">
								工作目录：{status.workspace_cwd}
							</div>
							{status.jupyter_tree_url ? (
								<a
									className="mt-1 block break-all text-primary underline-offset-4 hover:underline"
									href={status.jupyter_tree_url}
									target="_blank"
									rel="noreferrer"
								>
									打开 Jupyter 工作区
								</a>
							) : null}
						</div>
					) : null}
					{!status?.connected ? (
						<>
							<label className="grid gap-1.5" htmlFor={`${fieldId}-url`}>
								<span className="text-muted-foreground">Jupyter 地址</span>
								<Input
									id={`${fieldId}-url`}
									value={jupyterUrl}
									onChange={(event) => setJupyterUrl(event.target.value)}
									placeholder="https://...-jupyter.../lab"
									disabled={submitting}
								/>
							</label>
							<label className="grid gap-1.5" htmlFor={`${fieldId}-password`}>
								<span className="text-muted-foreground">密码</span>
								<Input
									id={`${fieldId}-password`}
									type="password"
									value={password}
									onChange={(event) => setPassword(event.target.value)}
									placeholder="Jupyter 登录密码"
									disabled={submitting}
								/>
							</label>
							<label className="grid gap-1.5" htmlFor={`${fieldId}-root`}>
								<span className="text-muted-foreground">远端工作区根目录</span>
								<Input
									id={`${fieldId}-root`}
									value={workspaceRoot}
									onChange={(event) => setWorkspaceRoot(event.target.value)}
									disabled={submitting}
								/>
							</label>
						</>
					) : null}
					{progress ? (
						<div className="rounded-md border bg-muted/30 px-3 py-2">
							<div className="mb-2 flex items-center justify-between gap-3 text-xs">
								<span className="text-muted-foreground">{progress.label}</span>
								<span className="font-medium tabular-nums">
									{Math.round(progress.percent)}%
								</span>
							</div>
							<div className="h-1.5 overflow-hidden rounded-full bg-muted">
								<div
									className="h-full rounded-full bg-primary transition-all duration-500"
									style={{ width: `${progress.percent}%` }}
								/>
							</div>
						</div>
					) : null}
					{message ? (
						<div className="rounded-md bg-muted px-3 py-2 text-muted-foreground">
							{message}
						</div>
					) : null}
				</div>
				<DialogFooter>
					<Button
						type="button"
						variant="outline"
						onClick={() => onOpenChange(false)}
					>
						关闭
					</Button>
					{!status?.connected ? (
						<Button type="button" disabled={submitting} onClick={submit}>
							{submitting ? "连接中..." : "切换"}
						</Button>
					) : null}
				</DialogFooter>
			</DialogContent>
		</Dialog>
	);
}

function ensureWorkspaceSessionId(current: string | null) {
	if (current) return current;
	const generated =
		typeof crypto !== "undefined" && "randomUUID" in crypto
			? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
			: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
	markFreshLocalId(generated);
	writeActiveSessionId(generated);
	scheduleSessionListRefresh();
	return generated;
}

function startWorkspaceProgress(
	setProgress: Dispatch<SetStateAction<WorkspaceSwitchProgress | null>>,
) {
	setProgress({ percent: 6, label: WORKSPACE_SWITCH_STEPS[0] });
	return window.setInterval(() => {
		setProgress((current) => {
			const previous = current ?? {
				percent: 6,
				label: WORKSPACE_SWITCH_STEPS[0],
			};
			const nextPercent = Math.min(
				92,
				previous.percent + (previous.percent < 55 ? 9 : 4),
			);
			const stepIndex = Math.min(
				WORKSPACE_SWITCH_STEPS.length - 1,
				Math.floor((nextPercent / 100) * WORKSPACE_SWITCH_STEPS.length),
			);
			return {
				percent: nextPercent,
				label: WORKSPACE_SWITCH_STEPS[stepIndex],
			};
		});
	}, 1200);
}

const ThreadMessage: FC = () => {
	const role = useAuiState((s) => s.message.role);
	const isEditing = useAuiState((s) => s.message.composer.isEditing);

	if (isEditing) return <EditComposer />;
	if (role === "user") return <UserMessage />;
	return <AssistantMessage />;
};

const ThreadScrollToBottom: FC = () => {
	return (
		<ThreadPrimitive.ScrollToBottom asChild>
			<TooltipIconButton
				tooltip="Scroll to bottom"
				variant="outline"
				className="aui-thread-scroll-to-bottom absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible dark:border-border dark:bg-background dark:hover:bg-accent"
			>
				<ArrowDownIcon />
			</TooltipIconButton>
		</ThreadPrimitive.ScrollToBottom>
	);
};

const ThreadWelcome: FC = () => {
	return (
		<div className="aui-thread-welcome-root my-auto flex grow flex-col">
			<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
				<div className="aui-thread-welcome-message flex size-full flex-col items-center justify-center px-4 text-center">
					<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200">
						ZK Data Agent
					</h1>
					<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground text-xl delay-75 duration-200">
						中控数据开发平台
					</p>
				</div>
			</div>
			<ThreadSuggestions />
		</div>
	);
};

const ThreadSuggestions: FC = () => {
	return (
		<div className="aui-thread-welcome-suggestions grid w-full @md:grid-cols-2 gap-2 pb-4">
			<ThreadPrimitive.Suggestions>
				{() => <ThreadSuggestionItem />}
			</ThreadPrimitive.Suggestions>
		</div>
	);
};

const ThreadSuggestionItem: FC = () => {
	return (
		<div className="aui-thread-welcome-suggestion-display fade-in slide-in-from-bottom-2 @md:nth-[n+3]:block nth-[n+3]:hidden animate-in fill-mode-both duration-200">
			<SuggestionPrimitive.Trigger send asChild>
				<Button
					variant="ghost"
					className="aui-thread-welcome-suggestion h-auto w-full @md:flex-col flex-wrap items-start justify-start gap-1 rounded-3xl border bg-background px-4 py-3 text-left text-sm transition-colors hover:bg-muted"
				>
					<SuggestionPrimitive.Title className="aui-thread-welcome-suggestion-text-1 font-medium" />
					<SuggestionPrimitive.Description className="aui-thread-welcome-suggestion-text-2 text-muted-foreground empty:hidden" />
				</Button>
			</SuggestionPrimitive.Trigger>
		</div>
	);
};

const Composer: FC = () => {
	const workspaceSessionId = useCurrentThreadSessionId({
		includePending: true,
		includeActive: true,
	});
	return (
		<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
			<ComposerPrimitive.AttachmentDropzone asChild>
				<div
					data-slot="aui_composer-shell"
					className="flex w-full flex-col gap-2 rounded-(--composer-radius) border bg-background p-(--composer-padding) transition-shadow focus-within:border-ring/75 focus-within:ring-2 focus-within:ring-ring/20 data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50"
				>
					<ComposerAttachments />
					<ComposerWorkspaceStatus sessionId={workspaceSessionId} />
					<ComposerPendingInputQueue sessionId={workspaceSessionId} />
					<ImeComposerInput
						placeholder="Send a message..."
						className="aui-composer-input max-h-32 min-h-10 w-full resize-none bg-transparent px-1.75 py-1 text-sm outline-none placeholder:text-muted-foreground/80"
						rows={1}
						autoFocus
						aria-label="Message input"
					/>
					<ComposerAction />
				</div>
			</ComposerPrimitive.AttachmentDropzone>
		</ComposerPrimitive.Root>
	);
};

const ComposerPendingInputQueue: FC<{ sessionId: string | null }> = ({
	sessionId,
}) => {
	const backendRunStatus = useBackendActiveRunStatus(sessionId);
	const items = useSessionInputQueue(sessionId);
	if (!sessionId || items.length === 0) return null;

	return (
		<div className="flex flex-col gap-1 px-1">
			{items.map((item) => {
				const isGuidance = item.kind === "guidance";
				const canGuide = Boolean(backendRunStatus?.run_id);
				return (
					<div
						key={item.id}
						className="flex items-center gap-2 rounded-lg border bg-muted/40 px-2 py-1 text-xs text-muted-foreground"
					>
						<span className="min-w-0 flex-1 truncate">
							<span className="mr-1 font-medium text-foreground">
								{isGuidance ? "待引导" : "待发送"}
							</span>
							{item.content}
						</span>
						<Button
							type="button"
							variant="ghost"
							size="sm"
							className="h-6 px-2 text-xs"
							onClick={() => {
								void updateSessionInputQueueItem(sessionId, item.id, {
									status: "cancelled",
								})
									.catch(() => undefined)
									.finally(() => {
										window.dispatchEvent(
											new CustomEvent("claw-composer-restore-draft", {
												detail: { text: item.content },
											}),
										);
										dispatchInputQueueChanged();
									});
							}}
						>
							编辑
						</Button>
						<Button
							type="button"
							variant="ghost"
							size="sm"
							className="h-6 px-2 text-xs"
							disabled={!canGuide || isGuidance}
							onClick={() => {
								if (!backendRunStatus?.run_id) return;
								void updateSessionInputQueueItem(sessionId, item.id, {
									kind: "guidance",
									run_id: backendRunStatus.run_id,
									status: "pending",
								})
									.catch(() => undefined)
									.finally(() => {
										dispatchInputQueueChanged();
										window.dispatchEvent(new Event("claw-sessions-changed"));
									});
							}}
						>
							引导
						</Button>
						<Button
							type="button"
							variant="ghost"
							size="sm"
							className="h-6 px-2 text-xs"
							onClick={() => {
								void updateSessionInputQueueItem(sessionId, item.id, {
									status: "cancelled",
								})
									.catch(() => undefined)
									.finally(dispatchInputQueueChanged);
							}}
						>
							删除
						</Button>
					</div>
				);
			})}
		</div>
	);
};

const ComposerWorkspaceStatus: FC<{ sessionId: string | null }> = ({
	sessionId,
}) => {
	const status = useJupyterWorkspaceStatus(sessionId);
	if (!status?.connected) return null;

	return (
		<div className="flex items-center justify-center px-1 pt-0.5">
			<div className="flex min-w-0 items-center gap-2 rounded-full border bg-muted/40 px-3 py-1 text-xs text-muted-foreground">
				<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
				<span className="shrink-0 font-medium text-foreground">
					Jupyter 工作区
				</span>
				{status.workspace_cwd ? (
					<span className="hidden max-w-56 truncate sm:inline">
						{status.workspace_cwd}
					</span>
				) : null}
				{status.jupyter_tree_url ? (
					<a
						className="shrink-0 text-primary underline-offset-4 hover:underline"
						href={status.jupyter_tree_url}
						target="_blank"
						rel="noreferrer"
					>
						打开
					</a>
				) : null}
			</div>
		</div>
	);
};

const ComposerAction: FC = () => {
	const aui = useAui();
	const runtimeRunning = useAuiState((s) => s.thread.isRunning);
	const replayedRun = useReplayedRunState();
	const currentSessionId = useCurrentThreadSessionId({
		includePending: true,
		includeActive: true,
	});
	const [runtimeCancelling, setRuntimeCancelling] = useState(false);
	const replayedRunIsCurrent =
		replayedRun.active &&
		Boolean(currentSessionId) &&
		replayedRun.sessionId === currentSessionId;
	const cancelSessionId = replayedRunIsCurrent
		? replayedRun.sessionId
		: currentSessionId;
	const backendRunStatus = useBackendActiveRunStatus(cancelSessionId);
	const cancelRunId =
		(replayedRunIsCurrent ? replayedRun.runId : null) ??
		backendRunStatus?.run_id ??
		null;
	useEffect(() => {
		if (!runtimeRunning) setRuntimeCancelling(false);
	}, [runtimeRunning]);
	return (
		<div className="aui-composer-action-wrapper relative flex items-center justify-between">
			<div className="flex items-center gap-1">
				<ComposerAddAttachment />
				<ComposerAssistButtons />
			</div>
			<ComposerContextStatus />
			{!runtimeRunning && !replayedRunIsCurrent && !backendRunStatus ? (
				<ComposerPrimitive.Send asChild>
					<TooltipIconButton
						tooltip="Send message"
						side="bottom"
						type="button"
						variant="default"
						size="icon"
						className="aui-composer-send size-8 rounded-full"
						aria-label="Send message"
					>
						<ArrowUpIcon className="aui-composer-send-icon size-4" />
					</TooltipIconButton>
				</ComposerPrimitive.Send>
			) : null}
			{runtimeRunning ? (
				<Button
					type="button"
					variant="default"
					size="icon"
					className="aui-composer-cancel size-8 rounded-full"
					aria-label="Stop generating"
					disabled={!cancelSessionId || runtimeCancelling}
					onClick={() => {
						if (!cancelSessionId) return;
						setRuntimeCancelling(true);
						void cancelLatestRun(cancelSessionId, cancelRunId)
							.catch(() => undefined)
							.finally(() => {
								// 后端可能已经 idle；无论取消接口是否真的命中 run，都清理本地残留运行态。
								aui.thread().cancelRun();
								window.dispatchEvent(new Event("claw-sessions-changed"));
								window.setTimeout(() => setRuntimeCancelling(false), 1200);
							});
					}}
				>
					<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
				</Button>
			) : null}
			{!runtimeRunning && (replayedRunIsCurrent || backendRunStatus) ? (
				<ReplayedRunCancelButton
					sessionId={cancelSessionId}
					runId={cancelRunId}
				/>
			) : null}
		</div>
	);
};

function useBackendActiveRunStatus(sessionId: string | null) {
	const [status, setStatus] = useState<ClawRunStatus | null>(null);

	useEffect(() => {
		if (!sessionId) {
			setStatus(null);
			return;
		}
		const safeSessionId = sessionId;
		let cancelled = false;

		async function refresh() {
			const nextStatus = await fetchLatestRunStatus(safeSessionId);
			if (cancelled) return;
			setStatus(isActiveRunStatus(nextStatus) ? nextStatus : null);
		}

		void refresh();
		const interval = window.setInterval(refresh, 1000);
		return () => {
			cancelled = true;
			window.clearInterval(interval);
		};
	}, [sessionId]);

	return status;
}

function isActiveRunStatus(status?: ClawRunStatus | null) {
	return status?.status === "queued" || status?.status === "running";
}

function dispatchInputQueueChanged() {
	window.dispatchEvent(new Event(INPUT_QUEUE_CHANGED_EVENT));
}

function useSessionInputQueue(sessionId: string | null) {
	const [items, setItems] = useState<SessionInputQueueItem[]>([]);

	useEffect(() => {
		if (!sessionId) {
			setItems([]);
			return;
		}
		let cancelled = false;
		const refresh = async () => {
			const nextItems = await fetchSessionInputQueue(sessionId);
			if (!cancelled) setItems(nextItems);
		};
		const handleQueueChanged = () => {
			void refresh();
		};
		void refresh();
		const interval = window.setInterval(refresh, 1000);
		window.addEventListener(INPUT_QUEUE_CHANGED_EVENT, handleQueueChanged);
		return () => {
			cancelled = true;
			window.clearInterval(interval);
			window.removeEventListener(INPUT_QUEUE_CHANGED_EVENT, handleQueueChanged);
		};
	}, [sessionId]);

	return items;
}

async function fetchSessionInputQueue(sessionId: string) {
	try {
		const response = await fetch(
			`/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue`,
			{ cache: "no-store" },
		);
		if (!response.ok) return [];
		const payload = (await response.json()) as {
			items?: SessionInputQueueItem[];
		};
		return Array.isArray(payload.items) ? payload.items : [];
	} catch {
		return [];
	}
}

async function createSessionInputQueueItem(
	sessionId: string,
	payload: {
		content: string;
		kind?: "next_turn" | "guidance";
		run_id?: string;
	},
) {
	const response = await fetch(
		`/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue`,
		{
			method: "POST",
			cache: "no-store",
			headers: { "content-type": "application/json" },
			body: JSON.stringify(payload),
		},
	);
	if (!response.ok) {
		throw new Error(`Failed to queue input: ${response.status}`);
	}
	return (await response.json()) as { item?: SessionInputQueueItem };
}

async function updateSessionInputQueueItem(
	sessionId: string,
	itemId: number,
	payload: Partial<
		Pick<SessionInputQueueItem, "content" | "kind" | "status" | "run_id">
	>,
) {
	const response = await fetch(
		`/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue/${itemId}`,
		{
			method: "PATCH",
			cache: "no-store",
			headers: { "content-type": "application/json" },
			body: JSON.stringify(payload),
		},
	);
	if (!response.ok) {
		throw new Error(`Failed to update queued input: ${response.status}`);
	}
	return (await response.json()) as { item?: SessionInputQueueItem };
}

const ReplayedRunCancelButton: FC<{
	sessionId: string | null;
	runId: string | null;
}> = ({ sessionId, runId }) => {
	const { replaySession } = useClawSessionReplay();
	const [cancelling, setCancelling] = useState(false);
	return (
		<Button
			type="button"
			variant="default"
			size="icon"
			className="aui-composer-cancel size-8 rounded-full"
			aria-label="Stop generating"
			disabled={!sessionId || cancelling}
			onClick={async () => {
				if (!sessionId) return;
				setCancelling(true);
				try {
					await cancelLatestRun(sessionId, runId);
					const snapshot = await fetchSessionReplaySnapshot<
						ClawStoredSession,
						ClawRunStatus
					>(sessionId);
					if (snapshot) {
						replaySession(
							sessionId,
							toReplayRepository(
								snapshot.session,
								sessionId,
								snapshot.runStatus,
							),
						);
					}
					window.dispatchEvent(new Event("claw-sessions-changed"));
				} finally {
					setCancelling(false);
				}
			}}
		>
			<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
		</Button>
	);
};

const ComposerContextStatus: FC = () => {
	const status = useComposerContextStatus();
	const totalTokens = status.contextBudget?.projected_input_tokens ?? 0;
	const limit = status.contextBudget?.context_window_tokens ?? 0;
	const percent = limit
		? Math.min(100, Math.round((totalTokens / limit) * 100))
		: 0;

	return (
		<div className="mx-2 hidden min-w-0 flex-1 items-center justify-end gap-1.5 sm:flex">
			<ContextWindowButton
				percent={percent}
				totalTokens={totalTokens}
				limit={limit}
				contextBudget={status.contextBudget}
			/>
			<ModelPickerButton status={status} />
		</div>
	);
};

function ContextWindowButton({
	percent,
	totalTokens,
	limit,
	contextBudget,
}: {
	percent: number;
	totalTokens: number;
	limit: number;
	contextBudget?: ContextBudget;
}) {
	const ringStyle = {
		background: `conic-gradient(var(--primary) ${percent * 3.6}deg, var(--muted) 0deg)`,
	};

	return (
		<PopoverPrimitive.Root>
			<Tooltip>
				<TooltipTrigger asChild>
					<PopoverPrimitive.Trigger asChild>
						<button
							type="button"
							className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
							aria-label="上下文窗口"
						>
							<span
								className="flex size-4 shrink-0 items-center justify-center rounded-full"
								style={ringStyle}
							>
								<span className="size-2 rounded-full bg-background" />
							</span>
						</button>
					</PopoverPrimitive.Trigger>
				</TooltipTrigger>
				<TooltipContent side="top" className="max-w-64 text-center text-sm">
					<div className="text-muted-foreground">背景信息窗口：</div>
					<div>
						{percent}% 已用（剩余 {Math.max(0, 100 - percent)}%）
					</div>
					<div>
						已用 {formatCompactTokenCount(totalTokens)} 标记，共{" "}
						{formatCompactTokenCount(limit)}
					</div>
				</TooltipContent>
			</Tooltip>
			<PopoverPrimitive.Portal>
				<PopoverPrimitive.Content
					side="top"
					align="end"
					sideOffset={10}
					className="z-50 w-64 rounded-2xl border bg-popover px-4 py-5 text-center text-popover-foreground shadow-lg outline-none"
				>
					<div className="text-muted-foreground text-sm">背景信息窗口：</div>
					<div className="mt-1 font-medium text-2xl">
						{percent}% 已用（剩余 {Math.max(0, 100 - percent)}%）
					</div>
					<div className="mt-2 text-lg">
						已用 {formatCompactTokenCount(totalTokens)} 标记，共{" "}
						{formatCompactTokenCount(limit)}
					</div>
					<div className="mt-3 grid gap-1 text-muted-foreground text-xs">
						<div>
							软阈值：
							{formatCompactTokenCount(
								contextBudget?.soft_input_limit_tokens ?? 0,
							)}
						</div>
						<div>
							硬阈值：
							{formatCompactTokenCount(
								contextBudget?.hard_input_limit_tokens ?? 0,
							)}
						</div>
						<div>
							保留输出：
							{formatCompactTokenCount(
								contextBudget?.reserved_output_tokens ?? 0,
							)}
							；压缩缓冲：
							{formatCompactTokenCount(
								contextBudget?.reserved_compaction_buffer_tokens ?? 0,
							)}
						</div>
						<div>
							计数器：{contextBudget?.token_counter_backend ?? "unknown"}
							{contextBudget?.token_counter_accurate ? "（精确）" : "（估算）"}
						</div>
					</div>
				</PopoverPrimitive.Content>
			</PopoverPrimitive.Portal>
		</PopoverPrimitive.Root>
	);
}

function ModelPickerButton({
	status,
}: {
	status: {
		model?: string;
		models: ModelOption[];
		setModel: (model: string) => Promise<void>;
	};
}) {
	const [query, setQuery] = useState("");
	const [open, setOpen] = useState(false);
	const normalizedQuery = query.trim().toLowerCase();
	const filteredModels = useMemo(() => {
		if (!normalizedQuery) return status.models;
		return status.models.filter((model) => {
			const provider = model.provider ?? modelProvider(model.id);
			return (
				model.id.toLowerCase().includes(normalizedQuery) ||
				provider.toLowerCase().includes(normalizedQuery)
			);
		});
	}, [normalizedQuery, status.models]);

	return (
		<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
			<PopoverPrimitive.Trigger asChild>
				<button
					type="button"
					className="flex min-w-0 max-w-44 items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-foreground text-sm transition-colors hover:bg-muted/80"
					aria-label="切换模型"
				>
					<span className="min-w-0 truncate font-medium">
						{compactModelLabel(status.model)}
					</span>
					<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
				</button>
			</PopoverPrimitive.Trigger>
			<PopoverPrimitive.Portal>
				<PopoverPrimitive.Content
					side="top"
					align="end"
					sideOffset={10}
					className="z-50 w-[min(36rem,calc(100vw-2rem))] rounded-2xl border bg-popover p-2 text-popover-foreground shadow-lg outline-none"
				>
					<div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">
						切换模型
					</div>
					<div className="relative mb-2">
						<SearchIcon className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-2.5 size-3.5 text-muted-foreground" />
						<input
							type="search"
							value={query}
							onChange={(event) => setQuery(event.target.value)}
							placeholder="搜索模型或来源"
							className="h-8 w-full rounded-lg border bg-background pr-2.5 pl-8 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
						/>
					</div>
					{status.models.length ? (
						<div className="h-[min(24rem,calc(100vh-12rem))] overflow-y-auto pr-1">
							{filteredModels.length ? (
								filteredModels.map((model) => (
									<button
										key={model.id}
										type="button"
										className="flex h-8 w-full items-center justify-between gap-2 rounded-lg px-2 text-left text-sm hover:bg-muted"
										onClick={() => {
											setOpen(false);
											void status.setModel(model.id);
										}}
									>
										<span className="min-w-0 flex-1 truncate">{model.id}</span>
										<span className="shrink-0 text-muted-foreground text-xs">
											{model.provider ?? modelProvider(model.id)}
										</span>
										{model.id === status.model ? (
											<CheckIcon className="size-3.5 shrink-0" />
										) : null}
									</button>
								))
							) : (
								<div className="px-2 py-8 text-center text-muted-foreground text-sm">
									没有匹配的模型
								</div>
							)}
						</div>
					) : (
						<div className="px-2 py-2 text-muted-foreground text-sm">
							模型列表读取中
						</div>
					)}
				</PopoverPrimitive.Content>
			</PopoverPrimitive.Portal>
		</PopoverPrimitive.Root>
	);
}

function useComposerContextStatus() {
	const isRunning = useAuiState((s) => s.thread.isRunning);
	const latestSessionId = useAuiState((s) =>
		findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
	);
	const wasRunningRef = useRef(false);
	const [status, setStatus] = useState<{
		model?: string;
		contextBudget?: ContextBudget;
		sessionId?: string | null;
		models: ModelOption[];
		setModel: (model: string) => Promise<void>;
	}>({
		models: [],
		setModel: async () => {},
	});

	useEffect(() => {
		if (wasRunningRef.current && !isRunning) {
			window.dispatchEvent(new Event("claw-sessions-changed"));
			window.setTimeout(() => {
				window.dispatchEvent(new Event("claw-sessions-changed"));
			}, 1500);
		}
		wasRunningRef.current = isRunning;
	}, [isRunning]);

	useEffect(() => {
		let cancelled = false;

		async function refresh() {
			try {
				const stateResponse = await fetch("/api/claw/state", {
					cache: "no-store",
				});
				const statePayload = stateResponse.ok
					? ((await stateResponse.json()) as ClawState)
					: {};
				const activeSessionId = normalizeSessionId(
					readSessionIdFromUrl() ?? readActiveSessionId(),
				);
				const latestMessageSessionId = normalizeSessionId(latestSessionId);
				let sessionId = normalizeSessionId(
					activeSessionId || latestMessageSessionId,
				);
				let sessionPayload: ClawStoredSession | null = null;
				let contextBudget: ContextBudget | undefined;
				if (sessionId) {
					const sessionResponse = await fetch(
						`/api/claw/sessions/${sessionId}`,
						{
							cache: "no-store",
						},
					);
					if (sessionResponse.ok) {
						sessionPayload =
							(await sessionResponse.json()) as ClawStoredSession;
					} else if (sessionResponse.status === 404) {
						if (
							sessionId === normalizeSessionId(readPendingWorkspaceSessionId())
						) {
							sessionPayload = null;
						} else {
							clearActiveSessionId();
							sessionId = null;
						}
					}
				}
				const budgetUrl = new URL(
					"/api/claw/context-budget",
					window.location.origin,
				);
				if (sessionId) budgetUrl.searchParams.set("session_id", sessionId);
				const budgetResponse = await fetch(budgetUrl, { cache: "no-store" });
				contextBudget = budgetResponse.ok
					? ((await budgetResponse.json()) as ContextBudget)
					: undefined;
				if (cancelled) return;
				setStatus((current) => ({
					model:
						statePayload.model ?? contextBudget?.model ?? sessionPayload?.model,
					contextBudget,
					sessionId,
					models: current.models,
					setModel: current.setModel,
				}));
			} catch {
				if (!cancelled) {
					setStatus((current) => ({
						...current,
					}));
				}
			}
		}

		refresh();
		const onRefresh = () => refresh();
		window.addEventListener("focus", onRefresh);
		window.addEventListener("claw-active-session-cleared", onRefresh);
		const interval = window.setInterval(refresh, isRunning ? 2000 : 8000);
		return () => {
			cancelled = true;
			window.removeEventListener("focus", onRefresh);
			window.removeEventListener("claw-active-session-cleared", onRefresh);
			window.clearInterval(interval);
		};
	}, [isRunning, latestSessionId]);

	useEffect(() => {
		let cancelled = false;

		async function refreshModels() {
			try {
				const response = await fetch("/api/claw/models", {
					cache: "no-store",
				});
				const payload = (await response.json()) as { models?: ModelOption[] };
				if (!cancelled && response.ok) {
					setStatus((current) => ({
						...current,
						models: Array.isArray(payload.models) ? payload.models : [],
					}));
				}
			} catch {
				if (!cancelled) {
					setStatus((current) => ({ ...current, models: [] }));
				}
			}
		}

		refreshModels();
		return () => {
			cancelled = true;
		};
	}, []);

	const setModel = useCallback(async (model: string) => {
		const response = await fetch("/api/claw/state", {
			method: "POST",
			headers: { "content-type": "application/json" },
			body: JSON.stringify({ model }),
		});
		const payload = (await response.json()) as ClawState;
		if (!response.ok) return;
		setStatus((current) => ({
			...current,
			model: payload.model ?? model,
			contextBudget: undefined,
		}));
	}, []);

	useEffect(() => {
		setStatus((current) => ({ ...current, setModel }));
	}, [setModel]);

	return status;
}

function findLatestMessageMetadataValue(
	messages: readonly unknown[],
	key: string,
) {
	for (const message of [...messages].reverse()) {
		if (!message || typeof message !== "object") continue;
		const metadata = (message as { metadata?: unknown }).metadata;
		const value = readMetadataValue(metadata, key);
		if (value !== undefined) return value;
	}
	return undefined;
}

function readMetadataValue(metadata: unknown, key: string): unknown {
	if (!metadata || typeof metadata !== "object") return undefined;
	const record = metadata as Record<string, unknown>;
	if (record[key] !== undefined) return record[key];
	const custom = record.custom;
	if (custom && typeof custom === "object") {
		return (custom as Record<string, unknown>)[key];
	}
	return undefined;
}

function normalizeSessionId(value: unknown) {
	if (typeof value !== "string") return null;
	const trimmed = value.trim();
	return trimmed || null;
}

function formatCompactTokenCount(value: number) {
	if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
	if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
	return `${value}`;
}

function compactModelLabel(model?: string) {
	if (!model) return "模型";
	const value = model.includes("/") ? model.split("/").at(-1) || model : model;
	if (value.toLowerCase().startsWith("mimo-v2.5")) return "MiMo 2.5";
	if (value.toLowerCase().startsWith("mimo-v2")) return "MiMo 2";
	if (value.toLowerCase().startsWith("deepseek")) return "DeepSeek";
	if (value.toLowerCase().startsWith("qwen")) return "Qwen";
	if (value.toLowerCase().startsWith("minimax")) return "MiniMax";
	return value;
}

function modelProvider(model: string) {
	if (model.includes("/")) return model.split("/", 1)[0] || "unknown";
	return "default";
}

const ComposerAssistButtons: FC = () => {
	return (
		<div className="flex items-center gap-1">
			<SlashCommandDialog />
			<SkillInsertDialog />
			<PromptInsertDialog
				title="常用工具"
				triggerIcon={<WrenchIcon className="size-4" />}
				triggerLabel=""
				items={TOOL_PROMPTS}
			/>
		</div>
	);
};

const TOOL_PROMPTS = [
	{
		title: "data_agent_profile_router_sessions",
		description: "查看 parquet 线上 session 数据字段、规模和样例。",
		prompt:
			"请优先使用 data_agent_profile_router_sessions 了解我指定的 router_session_parquet 数据目录，再给出可用字段和初步挖掘建议。",
	},
	{
		title: "data_agent_search_router_sessions",
		description: "用关键词、正则、domain、device 等条件筛选候选。",
		prompt:
			"请优先使用 data_agent_search_router_sessions 按我的条件筛选线上候选，并说明筛选策略、命中数量和样例质量。",
	},
	{
		title: "data_agent_normalize_dataset_draft",
		description: "把自然语言 draft 转换为 canonical records。",
		prompt:
			"请在我确认数据内容后，使用 data_agent_normalize_dataset_draft 把 draft 转换成 canonical records。",
	},
	{
		title: "data_agent_validate_dataset_records",
		description: "校验 canonical records 是否符合结构约束。",
		prompt:
			"请使用 data_agent_validate_dataset_records 校验当前 canonical records，并解释每类错误应该如何修复。",
	},
];

function PromptInsertDialog({
	title,
	triggerIcon,
	triggerLabel,
	items,
}: {
	title: string;
	triggerIcon: ReactNode;
	triggerLabel: string;
	items: { title: string; description: string; prompt: string }[];
}) {
	const [open, setOpen] = useState(false);

	return (
		<Dialog open={open} onOpenChange={setOpen}>
			<DialogTrigger asChild>
				<Button
					type="button"
					variant="ghost"
					size="sm"
					className="h-8 gap-1 rounded-full px-2 text-muted-foreground"
					aria-label={title}
				>
					{triggerIcon}
					{triggerLabel ? (
						<span className="hidden text-xs md:inline">{triggerLabel}</span>
					) : null}
				</Button>
			</DialogTrigger>
			<DialogContent>
				<DialogHeader>
					<DialogTitle>{title}</DialogTitle>
				</DialogHeader>
				<div className="grid gap-2">
					{items.map((item) => (
						<button
							key={item.title}
							type="button"
							className="rounded-lg border px-3 py-2 text-left transition-colors hover:bg-muted"
							onClick={() => {
								insertComposerText(item.prompt);
								setOpen(false);
							}}
						>
							<div className="font-medium text-sm">{item.title}</div>
							<div className="mt-1 text-muted-foreground text-xs">
								{item.description}
							</div>
						</button>
					))}
				</div>
			</DialogContent>
		</Dialog>
	);
}

function SlashCommandDialog() {
	const [open, setOpen] = useState(false);
	const [commands, setCommands] = useState<SlashCommandSummary[]>([]);
	const [status, setStatus] = useState(
		"点击后读取当前后端 slash command 列表。",
	);

	async function loadCommands() {
		setStatus("正在读取 slash command 列表...");
		try {
			const response = await fetch("/api/claw/slash-commands", {
				cache: "no-store",
			});
			const payload = (await response.json()) as
				| SlashCommandSummary[]
				| { error?: string };
			if (!response.ok || !Array.isArray(payload)) {
				throw new Error(
					Array.isArray(payload) ? "读取失败" : (payload.error ?? "读取失败"),
				);
			}
			setCommands(payload);
			setStatus(payload.length ? "" : "当前没有可用 slash command。");
		} catch (err) {
			setStatus(err instanceof Error ? err.message : "读取 slash command 失败");
		}
	}

	return (
		<Dialog
			open={open}
			onOpenChange={(open) => {
				setOpen(open);
				if (open) loadCommands();
			}}
		>
			<DialogTrigger asChild>
				<Button
					type="button"
					variant="ghost"
					size="sm"
					className="h-8 rounded-full px-2 font-mono text-muted-foreground text-sm"
					aria-label="Slash Commands"
				>
					/
				</Button>
			</DialogTrigger>
			<DialogContent>
				<DialogHeader>
					<DialogTitle>快捷入口</DialogTitle>
				</DialogHeader>
				{status ? (
					<p className="text-muted-foreground text-sm">{status}</p>
				) : null}
				<div className="grid max-h-96 gap-2 overflow-y-auto">
					{commands.map((command) => {
						const commandText = `/${command.primary}`;
						const aliases = command.names
							.filter((name) => name !== command.primary)
							.map((name) => `/${name}`)
							.join(" ");
						return (
							<button
								key={command.primary}
								type="button"
								className="rounded-lg border px-3 py-2 text-left transition-colors hover:bg-muted"
								onClick={() => {
									insertComposerText(`${commandText}\n`);
									setOpen(false);
								}}
							>
								<div className="font-medium font-mono text-sm">
									{commandText}
								</div>
								<div className="mt-1 line-clamp-2 text-muted-foreground text-xs">
									{command.description || "暂无说明"}
								</div>
								{aliases ? (
									<div className="mt-1 text-muted-foreground/80 text-[11px]">
										别名：{aliases}
									</div>
								) : null}
							</button>
						);
					})}
				</div>
			</DialogContent>
		</Dialog>
	);
}

function SkillInsertDialog() {
	const [open, setOpen] = useState(false);
	const [skills, setSkills] = useState<SkillSummary[]>([]);
	const [status, setStatus] = useState("点击后读取当前后端 Skill 列表。");
	const [updatingSkill, setUpdatingSkill] = useState<string | null>(null);
	const [syncingSkills, setSyncingSkills] = useState(false);
	const [bulkUpdatingSkills, setBulkUpdatingSkills] = useState(false);
	const configurableSkills = skills.filter(
		(skill) => skill.configurable !== false,
	);
	const allSkillsEnabled =
		configurableSkills.length > 0 &&
		configurableSkills.every((skill) => skill.enabled !== false);

	async function loadSkills() {
		setStatus("正在读取 Skill 列表...");
		try {
			const response = await fetch("/api/claw/skills", { cache: "no-store" });
			const payload = (await response.json()) as
				| SkillSummary[]
				| { error?: string };
			if (!response.ok || !Array.isArray(payload)) {
				throw new Error(
					Array.isArray(payload) ? "读取失败" : (payload.error ?? "读取失败"),
				);
			}
			setSkills(payload);
			setStatus(payload.length ? "" : "当前没有可用 Skill。");
		} catch (err) {
			setStatus(err instanceof Error ? err.message : "读取 Skill 失败");
		}
	}

	async function syncSkills() {
		setSyncingSkills(true);
		setStatus("正在从 git 同步 Skill...");
		try {
			const response = await fetch("/api/claw/skills", {
				method: "POST",
				cache: "no-store",
			});
			const payload = (await response.json()) as SkillSyncResponse;
			if (!response.ok || !Array.isArray(payload.skills)) {
				throw new Error(payload.detail ?? payload.error ?? "同步 Skill 失败");
			}
			setSkills(payload.skills);
			const changedCount = payload.changed_files?.length ?? 0;
			if (payload.updated) {
				const shortHash = payload.after
					? payload.after.slice(0, 7)
					: "最新提交";
				setStatus(`Skill 已更新到 ${shortHash}，变更 ${changedCount} 个文件。`);
			} else {
				setStatus("Skill 已是最新。");
			}
		} catch (err) {
			setStatus(err instanceof Error ? err.message : "同步 Skill 失败");
		} finally {
			setSyncingSkills(false);
		}
	}

	async function setSkillEnabled(skill: SkillSummary, enabled: boolean) {
		if (skill.configurable === false) return;
		const previous = skills;
		setUpdatingSkill(skill.name);
		setSkills((items) =>
			items.map((item) =>
				item.name === skill.name ? { ...item, enabled } : item,
			),
		);
		try {
			const response = await fetch("/api/claw/skills", {
				method: "PATCH",
				headers: {
					"content-type": "application/json",
				},
				body: JSON.stringify({ skill: skill.name, enabled }),
			});
			const payload = (await response.json()) as
				| SkillSummary[]
				| { error?: string; detail?: string };
			if (!response.ok || !Array.isArray(payload)) {
				throw new Error(
					Array.isArray(payload)
						? "更新失败"
						: (payload.detail ?? payload.error ?? "更新失败"),
				);
			}
			setSkills(payload);
			dispatchSkillToggled({ skill: skill.name, enabled });
			setStatus(enabled ? `已启用 ${skill.name}` : `已停用 ${skill.name}`);
		} catch (err) {
			setSkills(previous);
			setStatus(err instanceof Error ? err.message : "更新 Skill 失败");
		} finally {
			setUpdatingSkill(null);
		}
	}

	async function setAllSkillsEnabled(enabled: boolean) {
		if (!configurableSkills.length) return;
		const previous = skills;
		setBulkUpdatingSkills(true);
		setSkills((items) =>
			items.map((item) =>
				item.configurable === false ? item : { ...item, enabled },
			),
		);
		try {
			const response = await fetch("/api/claw/skills", {
				method: "PATCH",
				headers: {
					"content-type": "application/json",
				},
				body: JSON.stringify({ apply_all: true, enabled }),
			});
			const payload = (await response.json()) as
				| SkillSummary[]
				| { error?: string; detail?: string };
			if (!response.ok || !Array.isArray(payload)) {
				throw new Error(
					Array.isArray(payload)
						? "更新失败"
						: (payload.detail ?? payload.error ?? "更新失败"),
				);
			}
			setSkills(payload);
			setStatus(enabled ? "已启用全部 Skill。" : "已停用全部可选 Skill。");
		} catch (err) {
			setSkills(previous);
			setStatus(err instanceof Error ? err.message : "批量更新 Skill 失败");
		} finally {
			setBulkUpdatingSkills(false);
		}
	}

	return (
		<Dialog
			open={open}
			onOpenChange={(open) => {
				setOpen(open);
				if (open) loadSkills();
			}}
		>
			<DialogTrigger asChild>
				<Button
					type="button"
					variant="ghost"
					size="sm"
					className="h-8 gap-1 rounded-full px-2 text-muted-foreground"
					aria-label="Skills"
				>
					<BookOpenIcon className="size-4" />
				</Button>
			</DialogTrigger>
			<DialogContent className="gap-3 p-5">
				<DialogHeader className="gap-1 pr-6">
					<DialogTitle>Skills</DialogTitle>
				</DialogHeader>
				<div className="flex flex-wrap items-center gap-2">
					<div className="flex items-center gap-2">
						<Button
							type="button"
							variant="ghost"
							size="sm"
							className="h-8 px-2.5"
							disabled={!configurableSkills.length || bulkUpdatingSkills}
							onClick={() => setAllSkillsEnabled(!allSkillsEnabled)}
						>
							{allSkillsEnabled ? "全取消" : "全选"}
						</Button>
						<Button
							type="button"
							variant="outline"
							size="sm"
							className="h-8 gap-2 px-2.5"
							disabled={syncingSkills}
							onClick={syncSkills}
						>
							<RefreshCwIcon
								className={cn("size-3.5", syncingSkills && "animate-spin")}
							/>
							更新 Skill
						</Button>
					</div>
					{status ? (
						<p className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
							{status}
						</p>
					) : null}
				</div>
				<div className="grid max-h-96 gap-2 overflow-y-auto pt-1">
					{skills.map((skill) => (
						<div
							key={skill.name}
							className="flex items-start gap-3 rounded-lg border px-3 py-2 transition-colors hover:bg-muted"
						>
							<input
								type="checkbox"
								checked={skill.enabled !== false}
								disabled={
									skill.configurable === false || updatingSkill === skill.name
								}
								aria-label={`启用 ${skill.name}`}
								className="mt-1 size-4 accent-primary"
								onChange={(event) =>
									setSkillEnabled(skill, event.currentTarget.checked)
								}
							/>
							<button
								type="button"
								className="min-w-0 flex-1 text-left"
								onClick={() => {
									insertComposerText(`Use the ${skill.name} skill.\n\n`);
									if (skill.name === "model-iteration") {
										dispatchSkillToggled({ skill: skill.name, enabled: true });
									}
									setOpen(false);
								}}
							>
								<div className="flex items-center gap-2 font-medium text-sm">
									<ListIcon className="size-3.5 shrink-0 text-muted-foreground" />
									<span className="truncate">{skill.name}</span>
								</div>
								<div className="mt-1 line-clamp-2 text-muted-foreground text-xs">
									{skill.description || skill.when_to_use || "暂无说明"}
								</div>
							</button>
						</div>
					))}
				</div>
			</DialogContent>
		</Dialog>
	);
}

function insertComposerText(text: string) {
	window.dispatchEvent(
		new CustomEvent("claw-composer-insert", {
			detail: { text },
		}),
	);
}

const MessageError: FC = () => {
	return (
		<MessagePrimitive.Error>
			<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
				<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
			</ErrorPrimitive.Root>
		</MessagePrimitive.Error>
	);
};

const AssistantMessage: FC = () => {
	const ACTION_BAR_PT = "pt-1.5";
	const ACTION_BAR_HEIGHT = `-mb-7.5 min-h-7.5 ${ACTION_BAR_PT}`;
	const messageId = useAuiState((s) => s.message.id);

	return (
		<MessagePrimitive.Root
			data-slot="aui_assistant-message-root"
			data-role="assistant"
			className="fade-in slide-in-from-bottom-1 relative animate-in duration-150"
		>
			<div
				data-slot="aui_assistant-message-content"
				className="wrap-break-word px-2 text-foreground leading-relaxed"
			>
				<MessagePrimitive.GroupedParts
					groupBy={(part) => {
						if (part.type === "reasoning" || part.type === "tool-call")
							return ["group-chain"];
						return null;
					}}
				>
					{({ part }) => {
						switch (part.type) {
							case "group-chain":
								return (
									<ActivityChainSummary
										messageId={messageId}
										partIndex={part.indices[0]}
									/>
								);
							case "text":
								return (
									<MarkdownText
										text={part.text}
										isRunning={part.status?.type === "running"}
									/>
								);
							case "reasoning":
								return <Reasoning {...part} />;
							case "tool-call":
								return part.toolUI ?? <ToolFallback {...part} />;
							default:
								return null;
						}
					}}
				</MessagePrimitive.GroupedParts>
				<MessageError />
			</div>

			<div
				data-slot="aui_assistant-message-footer"
				className={cn("ml-2 flex items-center", ACTION_BAR_HEIGHT)}
			>
				<BranchPicker />
				<AssistantActionBar />
			</div>
		</MessagePrimitive.Root>
	);
};

const ActivityChainSummary: FC<{
	messageId: string;
	partIndex: number | undefined;
}> = ({ messageId, partIndex }) => {
	const { openItem } = useActivityPanel();
	const metadataRunStartedAtMs = useAuiState((s) => {
		const value = readMetadataValue(s.message.metadata, "runStartedAtMs");
		return typeof value === "number" && Number.isFinite(value) ? value : null;
	});
	const contentRunStartedAtMs = useAuiState((s) =>
		findNumberInActivityParts(s.message.content, "runStartedAtMs"),
	);
	const messageCreatedAtMs = useAuiState((s) =>
		normalizeMessageCreatedAtMs(s.message.createdAt),
	);
	const storedElapsedMs = useAuiState((s) => {
		const value = readMetadataValue(s.message.metadata, "elapsedMs");
		const contentValue = findElapsedMsInActivityParts(s.message.content);
		if (typeof value !== "number" || !Number.isFinite(value)) {
			return contentValue;
		}
		return typeof value === "number" && Number.isFinite(value)
			? Math.max(0, Math.round(value))
			: null;
	});
	const initialStartedAtMs =
		metadataRunStartedAtMs ?? contentRunStartedAtMs ?? messageCreatedAtMs;
	const liveStartedAtRef = useRef<number | null>(initialStartedAtMs);
	const [elapsedMs, setElapsedMs] = useState<number | null>(() => {
		if (initialStartedAtMs !== null) {
			return Math.max(0, Date.now() - initialStartedAtMs);
		}
		if (storedElapsedMs !== null) return storedElapsedMs;
		return null;
	});
	const label = useAuiState((s) => {
		const message = s.message;
		const status =
			message.status?.type === "running" || isReplayRunMessageId(messageId)
				? "running"
				: message.status?.type;
		return summarizeActivityChainLabel(message.content, status, elapsedMs);
	});
	const active = useAuiState((s) => {
		const message = s.message;
		return (
			message.status?.type === "running" || isReplayRunMessageId(messageId)
		);
	});

	useEffect(() => {
		if (!active) {
			setElapsedMs(storedElapsedMs);
			return;
		}
		const authoritativeStartedAt =
			metadataRunStartedAtMs ?? contentRunStartedAtMs;
		if (authoritativeStartedAt !== null) {
			liveStartedAtRef.current = authoritativeStartedAt;
		} else if (liveStartedAtRef.current === null && storedElapsedMs !== null) {
			liveStartedAtRef.current = Date.now() - storedElapsedMs;
		} else if (liveStartedAtRef.current !== null && storedElapsedMs !== null) {
			liveStartedAtRef.current = Math.min(
				liveStartedAtRef.current,
				Date.now() - storedElapsedMs,
			);
		} else if (
			liveStartedAtRef.current === null &&
			messageCreatedAtMs !== null
		) {
			liveStartedAtRef.current = messageCreatedAtMs;
		}
		const startedAt = liveStartedAtRef.current;
		if (startedAt === null) {
			setElapsedMs(storedElapsedMs);
			return;
		}
		setElapsedMs(Math.max(0, Date.now() - startedAt));
		const timer = window.setInterval(() => {
			setElapsedMs(Math.max(0, Date.now() - startedAt));
		}, 1000);
		return () => window.clearInterval(timer);
	}, [
		active,
		metadataRunStartedAtMs,
		contentRunStartedAtMs,
		messageCreatedAtMs,
		storedElapsedMs,
	]);

	return (
		<button
			type="button"
			className="mb-3 flex items-center gap-2 rounded-md px-1 py-1 text-muted-foreground text-sm transition-colors hover:text-foreground"
			onClick={() => {
				if (partIndex !== undefined) openItem(`${messageId}:${partIndex}`);
			}}
		>
			<BrainIcon className={cn("size-4", active && "animate-pulse")} />
			<span>{label}</span>
			<span aria-hidden>›</span>
		</button>
	);
};

function isReplayRunMessageId(messageId: string) {
	return messageId.includes("-run-");
}

type ActivitySummaryPart = {
	type: string;
	text?: string;
	result?: unknown;
	args?: unknown;
	toolName?: string;
	runStartedAtMs?: unknown;
	elapsedMs?: unknown;
};

function findNumberInActivityParts(
	content: readonly ActivitySummaryPart[],
	key: keyof ActivitySummaryPart,
) {
	for (const part of content) {
		const value = part[key];
		if (typeof value === "number" && Number.isFinite(value)) return value;
	}
	return null;
}

function normalizeMessageCreatedAtMs(value: unknown) {
	if (value instanceof Date) {
		const time = value.getTime();
		return Number.isFinite(time) ? time : null;
	}
	if (typeof value === "number" && Number.isFinite(value)) {
		return value < 10_000_000_000
			? Math.round(value * 1000)
			: Math.round(value);
	}
	if (typeof value === "string") {
		const time = Date.parse(value);
		return Number.isFinite(time) ? time : null;
	}
	return null;
}

function findElapsedMsInActivityParts(content: readonly ActivitySummaryPart[]) {
	for (const part of content) {
		if (typeof part.elapsedMs === "number" && Number.isFinite(part.elapsedMs)) {
			return Math.max(0, Math.round(part.elapsedMs));
		}
		const args = part.args;
		if (!args || typeof args !== "object" || Array.isArray(args)) continue;
		const value = (args as Record<string, unknown>).__claw_elapsed_ms;
		if (typeof value === "number" && Number.isFinite(value)) {
			return Math.max(0, Math.round(value));
		}
	}
	return null;
}

function summarizeActivityChainLabel(
	content: readonly ActivitySummaryPart[],
	status: string | undefined,
	elapsedMs: number | null = null,
) {
	const toolParts = content.filter((part) => part.type === "tool-call");
	const runningTool = toolParts.findLast((part) => part.result === undefined);
	const activeTool = runningTool ?? toolParts.at(-1);
	const reasoningText =
		content.find((part) => part.type === "reasoning")?.text ?? "";
	const lastReasoningLine = reasoningText
		.trim()
		.split("\n")
		.map((line) => line.trim())
		.filter(Boolean)
		.at(-1);
	const stageNote = activeTool ? getToolStageNote(activeTool.args) : "";
	const activeDetail =
		stageNote ||
		(activeTool?.toolName ? `调用 ${activeTool.toolName}` : "") ||
		(isGenericActivityLine(lastReasoningLine) ? "" : lastReasoningLine);

	if (status === "running") {
		const elapsed =
			elapsedMs === null ? "" : `，用时 ${formatActivityDuration(elapsedMs)}`;
		if (activeDetail) {
			return `${compactActivityLabel(activeDetail, 15)}${elapsed}`;
		}
		return elapsed ? `思考中${elapsed}` : "思考中";
	}

	const label = compactActivityLabel(
		activeDetail || lastReasoningLine || "思考完成",
		15,
	);
	const finalDuration = extractDurationLabel(lastReasoningLine);
	const elapsed =
		finalDuration ||
		(elapsedMs === null ? "" : formatActivityDuration(elapsedMs));
	return elapsed ? `${label}，用时 ${elapsed}` : label;
}

function getToolStageNote(value: unknown) {
	if (!value || typeof value !== "object" || Array.isArray(value)) return "";
	const note = (value as Record<string, unknown>).__claw_stage_note;
	return typeof note === "string" ? note.trim() : "";
}

function isGenericActivityLine(value?: string) {
	if (!value) return true;
	return (
		value.startsWith("思考中") ||
		value.startsWith("思考并执行完成") ||
		value.endsWith("输出中...")
	);
}

function extractDurationLabel(value?: string) {
	if (!value) return "";
	const match = value.match(/用时\s*([^。,.，\s]+)/);
	return match?.[1] ?? "";
}

function formatActivityDuration(ms: number) {
	const safeMs = Math.max(0, Math.round(ms));
	if (safeMs < 1000) return `${safeMs}ms`;
	const totalSeconds = Math.round(safeMs / 1000);
	if (totalSeconds < 60) return `${totalSeconds}s`;
	const minutes = Math.floor(totalSeconds / 60);
	const seconds = totalSeconds % 60;
	return seconds > 0 ? `${minutes}min${seconds}s` : `${minutes}min`;
}

function compactActivityLabel(label: string, maxLength = 15) {
	const normalized = label.replace(/\s+/g, " ").trim();
	const progress = normalized.match(
		/^进度[:：]\s*批\s*(\d+)\s*\/\s*(\d+)(?:[，,]\s*已生成\s*(\d+)\s*\/\s*(\d+))?/,
	);
	if (progress) {
		const batch = `批${progress[1]}/${progress[2]}`;
		const count =
			progress[3] && progress[4] ? ` 已${progress[3]}/${progress[4]}` : "";
		const compactProgress = `${batch}${count}`;
		if (compactProgress.length <= maxLength) return compactProgress;
		return batch;
	}
	if (normalized.length <= maxLength) return normalized;
	return `${normalized.slice(0, Math.max(1, maxLength - 1))}…`;
}

const AssistantActionBar: FC = () => {
	return (
		<ActionBarPrimitive.Root
			hideWhenRunning
			autohide="not-last"
			className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground"
		>
			<ActionBarPrimitive.Copy asChild>
				<TooltipIconButton tooltip="Copy">
					<AuiIf condition={(s) => s.message.isCopied}>
						<CheckIcon />
					</AuiIf>
					<AuiIf condition={(s) => !s.message.isCopied}>
						<CopyIcon />
					</AuiIf>
				</TooltipIconButton>
			</ActionBarPrimitive.Copy>
			<ActionBarPrimitive.Reload asChild>
				<TooltipIconButton tooltip="Refresh">
					<RefreshCwIcon />
				</TooltipIconButton>
			</ActionBarPrimitive.Reload>
			<ActionBarMorePrimitive.Root>
				<ActionBarMorePrimitive.Trigger asChild>
					<TooltipIconButton
						tooltip="More"
						className="data-[state=open]:bg-accent"
					>
						<MoreHorizontalIcon />
					</TooltipIconButton>
				</ActionBarMorePrimitive.Trigger>
				<ActionBarMorePrimitive.Content
					side="bottom"
					align="start"
					className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
				>
					<ActionBarPrimitive.ExportMarkdown asChild>
						<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
							<DownloadIcon className="size-4" />
							Export as Markdown
						</ActionBarMorePrimitive.Item>
					</ActionBarPrimitive.ExportMarkdown>
				</ActionBarMorePrimitive.Content>
			</ActionBarMorePrimitive.Root>
		</ActionBarPrimitive.Root>
	);
};

const UserMessage: FC = () => {
	return (
		<MessagePrimitive.Root
			data-slot="aui_user-message-root"
			className="fade-in slide-in-from-bottom-1 grid animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 duration-150 [&:where(>*)]:col-start-2"
			data-role="user"
		>
			<UserMessageAttachments />

			<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
				<div className="aui-user-message-content wrap-break-word peer rounded-2xl bg-muted px-4 py-2.5 text-foreground empty:hidden">
					<MessagePrimitive.Parts />
				</div>
				<div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2 peer-empty:hidden">
					<UserActionBar />
				</div>
			</div>

			<BranchPicker
				data-slot="aui_user-branch-picker"
				className="col-span-full col-start-1 row-start-3 -mr-1 justify-end"
			/>
		</MessagePrimitive.Root>
	);
};

const UserActionBar: FC = () => {
	return (
		<ActionBarPrimitive.Root
			hideWhenRunning
			autohide="not-last"
			className="aui-user-action-bar-root flex flex-col items-end"
		>
			<ActionBarPrimitive.Edit asChild>
				<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
					<PencilIcon />
				</TooltipIconButton>
			</ActionBarPrimitive.Edit>
		</ActionBarPrimitive.Root>
	);
};

const EditComposer: FC = () => {
	return (
		<MessagePrimitive.Root
			data-slot="aui_edit-composer-wrapper"
			className="flex flex-col px-2"
		>
			<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
				<ImeComposerInput
					className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
					autoFocus
				/>
				<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
					<ComposerPrimitive.Cancel asChild>
						<Button variant="ghost" size="sm">
							Cancel
						</Button>
					</ComposerPrimitive.Cancel>
					<ComposerPrimitive.Send asChild>
						<Button size="sm">Update</Button>
					</ComposerPrimitive.Send>
				</div>
			</ComposerPrimitive.Root>
		</MessagePrimitive.Root>
	);
};

const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
	autoFocus,
	onChange,
	onCompositionEnd,
	onCompositionStart,
	onKeyDown,
	disabled,
	...props
}) => {
	const aui = useAui();
	const storeText = useAuiState((s) =>
		s.composer.isEditing ? s.composer.text : "",
	);
	const isEditingComposer = useAuiState((s) => s.composer.isEditing);
	const runtimeRunning = useAuiState((s) => s.thread.isRunning);
	const runtimeDisabled = useAuiState(
		(s) => s.thread.isDisabled || s.composer.dictation?.inputDisabled,
	);
	const activeSessionId = useCurrentThreadSessionId({
		includePending: true,
		includeActive: true,
	});
	const backendRunStatus = useBackendActiveRunStatus(activeSessionId);
	const [localText, setLocalText] = useState(storeText);
	const textareaRef = useRef<HTMLTextAreaElement>(null);
	const isComposingRef = useRef(false);
	const justSubmittedRef = useRef(false);
	const canQueueWhileRunning =
		!isEditingComposer &&
		Boolean(activeSessionId) &&
		(runtimeRunning || isActiveRunStatus(backendRunStatus));
	const isDisabled = (runtimeDisabled && !canQueueWhileRunning) || disabled;

	// Pull store→local on external store changes (session switch, paste-from-attachment,
	// submit-clear, etc). The reverse direction (local→store) is pushed eagerly by
	// onChange/handleInsert/handleKeyDown/onCompositionEnd via syncComposerText, so we
	// don't need a symmetric effect — and adding one creates a feedback loop that
	// re-renders the composer at ~1ms intervals (visible as shake).
	useEffect(() => {
		if (!isComposingRef.current) {
			if (justSubmittedRef.current && storeText) return;
			if (!storeText) justSubmittedRef.current = false;
			setLocalText(storeText);
		}
	}, [storeText]);

	useEffect(() => {
		if (!autoFocus || isDisabled) return;
		const textarea = textareaRef.current;
		if (!textarea) return;
		textarea.focus({ preventScroll: true });
		textarea.setSelectionRange(textarea.value.length, textarea.value.length);
	}, [autoFocus, isDisabled]);

	useEffect(() => {
		const clearAfterSubmit = () => {
			justSubmittedRef.current = true;
			setLocalText("");
			scheduleSessionListRefresh();
		};
		const unsubscribeComposer = aui.on("composer.send", clearAfterSubmit);
		const unsubscribeRun = aui.on("thread.runStart", clearAfterSubmit);
		return () => {
			unsubscribeComposer();
			unsubscribeRun();
		};
	}, [aui]);

	// AssistantWorkspace 切 session 时通过事件通知我们恢复 draft——直接
	// 控制 localText，绕过 store→local 路径上的 justSubmittedRef 屏蔽和
	// switchToNewThread 引起的异步覆盖。
	useEffect(() => {
		const handler = (event: Event) => {
			const detail = (event as CustomEvent<{ text?: string }>).detail;
			const text = detail?.text ?? "";
			justSubmittedRef.current = false;
			setLocalText(text);
			requestAnimationFrame(() => {
				const textarea = textareaRef.current;
				if (!textarea) return;
				textarea.focus({ preventScroll: true });
				textarea.setSelectionRange(text.length, text.length);
			});
		};
		window.addEventListener("claw-composer-restore-draft", handler);
		return () =>
			window.removeEventListener("claw-composer-restore-draft", handler);
	}, []);

	const syncComposerText = useCallback(
		(value: string) => {
			if (!aui.composer().getState().isEditing) return;
			aui.composer().setText(value);
		},
		[aui],
	);

	useEffect(() => {
		const textarea = textareaRef.current;
		if (!textarea) return;
		const handleInsert = (event: Event) => {
			const insertText = (event as ComposerInsertEvent).detail?.text;
			if (!insertText) return;
			const nextText = [localText.trimEnd(), insertText.trim()]
				.filter(Boolean)
				.join("\n\n");
			setLocalText(nextText);
			syncComposerText(nextText);
			requestAnimationFrame(() => {
				textarea.focus({ preventScroll: true });
				textarea.setSelectionRange(nextText.length, nextText.length);
			});
		};
		window.addEventListener("claw-composer-insert", handleInsert);
		return () =>
			window.removeEventListener("claw-composer-insert", handleInsert);
	}, [localText, syncComposerText]);

	const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
		onKeyDown?.(event);
		if (event.defaultPrevented || isDisabled) return;
		if (
			event.nativeEvent.isComposing ||
			isComposingRef.current ||
			event.key !== "Enter" ||
			event.shiftKey
		) {
			return;
		}

		const threadState = aui.thread().getState();
		const content = localText.trim();
		if (
			(threadState.isRunning || isActiveRunStatus(backendRunStatus)) &&
			activeSessionId
		) {
			event.preventDefault();
			if (!content) return;
			justSubmittedRef.current = true;
			setLocalText("");
			syncComposerText("");
			void createSessionInputQueueItem(activeSessionId, {
				content,
				kind: "next_turn",
			})
				.catch(() => {
					justSubmittedRef.current = false;
					setLocalText(content);
				})
				.finally(dispatchInputQueueChanged);
			return;
		}
		if (threadState.isRunning && !threadState.capabilities.queue) return;

		event.preventDefault();
		syncComposerText(localText);
		aui.composer().send();
		justSubmittedRef.current = true;
		setLocalText("");
	};

	return (
		<TextareaAutosize
			{...props}
			ref={textareaRef}
			value={localText}
			disabled={isDisabled}
			onChange={(event) => {
				onChange?.(event);
				const nextText = event.currentTarget.value;
				justSubmittedRef.current = false;
				setLocalText(nextText);
				const isNativeComposing =
					(event.nativeEvent as { isComposing?: boolean }).isComposing === true;
				if (!isNativeComposing && !isComposingRef.current) {
					syncComposerText(nextText);
				}
			}}
			onCompositionStart={(event) => {
				onCompositionStart?.(event);
				isComposingRef.current = true;
			}}
			onCompositionEnd={(event) => {
				onCompositionEnd?.(event);
				isComposingRef.current = false;
				const target = event.currentTarget;
				queueMicrotask(() => {
					const nextText = target.value;
					setLocalText(nextText);
					syncComposerText(nextText);
				});
			}}
			onKeyDown={handleKeyDown}
		/>
	);
};

const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
	className,
	...rest
}) => {
	return (
		<BranchPickerPrimitive.Root
			hideWhenSingleBranch
			className={cn(
				"aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs",
				className,
			)}
			{...rest}
		>
			<BranchPickerPrimitive.Previous asChild>
				<TooltipIconButton tooltip="Previous">
					<ChevronLeftIcon />
				</TooltipIconButton>
			</BranchPickerPrimitive.Previous>
			<span className="aui-branch-picker-state font-medium">
				<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
			</span>
			<BranchPickerPrimitive.Next asChild>
				<TooltipIconButton tooltip="Next">
					<ChevronRightIcon />
				</TooltipIconButton>
			</BranchPickerPrimitive.Next>
		</BranchPickerPrimitive.Root>
	);
};
