import { useState, Fragment, memo } from "react";
import { IconLoader3, IconGitFork } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Content } from "@/lib/content";
import { Markdown } from "./Markdown";
import { ToolCallCard } from "./ToolRenderers";
import { CopyButton } from "./CopyButton";
import { ThinkingBlock } from "./ThinkingBlock";
import { CompactionCard } from "./CompactionCard";
import { MediaThumbnail } from "./MediaThumbnail";
import { MediaPreviewModal } from "./MediaPreviewModal";
import { InlineError } from "./InlineError";
import { PlanCard } from "./PlanCard";
import { StreamingConfirmDialog } from "./StreamingConfirmDialog";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/sonner";
import { useChatStore } from "@/stores";
import { bridge } from "@/services";
import type { ChatMessage as ChatMessageType, UIStep, UIStepItem } from "@/stores/chat.store";
import type { ContentPart } from "shared/legacy-sdk";
interface ChatMessageProps {
message: ChatMessageType;
/** 0-indexed turn number for this message */
turnIndex?: number;
isStreaming?: boolean;
}
function ThinkingIndicator() {
return (
Processing…
);
}
function SteerBubble({ content }: { content: string | ContentPart[] }) {
const text = typeof content === "string" ? content : Content.getText(content);
return (
);
}
function StepItemRenderer({ item }: { item: UIStepItem }) {
switch (item.type) {
case "thinking":
return ;
case "text":
return ;
case "tool_use":
return ;
case "compaction":
return ;
case "steer":
return ;
default:
return null;
}
}
function StepContent({ step, showConnector }: { step: UIStep; showConnector?: boolean }) {
const hasItems = step.items.length > 0;
const hasToolOrThinking = step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction");
const showIndicator = hasToolOrThinking;
const hasActiveItem = step.items.some((item) => (item.type === "text" || item.type === "thinking") && !item.finished);
if (!hasItems) {
return null;
}
return (
{showIndicator ? (
) : (
)}
{step.items.map((item, idx) => (
))}
);
}
function MessageMedia({ images, videos, onPreview }: { images: string[]; videos: string[]; onPreview: (src: string) => void }) {
if (images.length === 0 && videos.length === 0) {
return null;
}
return (
{images.map((src, idx) => (
onPreview(src)} />
))}
{videos.map((src, idx) => (
onPreview(src)} />
))}
);
}
interface StepGroup {
planMode: boolean;
steps: UIStep[];
startIndex: number;
}
function groupStepsByPlanMode(steps: UIStep[]): StepGroup[] {
const groups: StepGroup[] = [];
for (let i = 0; i < steps.length; i++) {
const isPlan = steps[i].planMode === true;
const last = groups.at(-1);
if (last && last.planMode === isPlan) {
last.steps.push(steps[i]);
} else {
groups.push({ planMode: isPlan, steps: [steps[i]], startIndex: i });
}
}
return groups;
}
interface ForkButtonProps {
turnIndex: number;
className?: string;
}
function ForkButton({ turnIndex, className }: ForkButtonProps) {
const [showConfirm, setShowConfirm] = useState(false);
const [isForking, setIsForking] = useState(false);
const sessionId = useChatStore((s) => s.sessionId);
const isStreaming = useChatStore((s) => s.isStreaming);
const loadSession = useChatStore((s) => s.loadSession);
const handleFork = () => {
if (!sessionId || turnIndex < 0) return;
setShowConfirm(true);
};
const doFork = async () => {
if (!sessionId) return;
setIsForking(true);
try {
const result = await bridge.forkSession(sessionId, turnIndex);
if (result) {
// Load the forked session
const events = await bridge.loadSessionHistory(result.sessionId);
await loadSession(result.sessionId, events);
}
} catch (error) {
toast.error(`Failed to fork conversation: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsForking(false);
setShowConfirm(false);
}
};
return (
<>
{ void doFork(); }}
confirmLoading={isForking}
confirmDisabled={isForking}
cancelDisabled={isForking}
/>
>
);
}
function UserMessage({ message }: { message: ChatMessageType }) {
const [previewMedia, setPreviewMedia] = useState(null);
const displayContent = Content.getText(message.content);
const images = Content.getImages(message.content);
const videos = Content.getVideos(message.content);
return (
{displayContent && (
// FIX: removed whitespace-pre-wrap — it conflicted with ReactMarkdown's
// block-level elements (
,
, - ), doubling vertical spacing.
// ReactMarkdown already handles paragraph breaks from \n\n.
)}
setPreviewMedia(null)} />
);
}
function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMessageType; turnIndex?: number; isStreaming?: boolean }) {
const [previewMedia, setPreviewMedia] = useState(null);
const isCompacting = useChatStore((s) => s.isCompacting);
const steps = message.steps || [];
const hasSteps = steps.length > 0;
const images = Content.getImages(message.content);
const videos = Content.getVideos(message.content);
const stepHasIndicator = steps.map((step) => step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction"));
const contentToCopy = (() => {
if (!hasSteps) {
return typeof message.content === "string" ? message.content : "";
}
const lastStep = steps[steps.length - 1];
const textItems = lastStep.items.filter((item) => item.type === "text");
if (textItems.length > 0) {
return textItems.map((item) => (item as { type: "text"; content: string }).content).join("\n");
}
return typeof message.content === "string" ? message.content : "";
})();
if (!isStreaming && !hasMessageContent(message) && !message.inlineError) {
return null;
}
const displayContent = typeof message.content === "string" ? message.content : "";
const isShowingInlineError = message.inlineError && !isStreaming;
return (
{hasSteps &&
groupStepsByPlanMode(steps).map((group, gi) => {
const totalSteps = steps.length;
const stepsContent = group.steps.map((step, i) => {
const globalIndex = group.startIndex + i;
const isLastInGroup = i === group.steps.length - 1;
const isLastOverall = globalIndex === totalSteps - 1;
const hasIndicator = stepHasIndicator[globalIndex];
const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean);
const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall;
return
;
});
if (group.planMode) {
return
{stepsContent};
}
return
{stepsContent};
})}
{!hasSteps && displayContent &&
}
{(images.length > 0 || videos.length > 0) && (
)}
{/* 内嵌错误显示 */}
{isShowingInlineError && message.inlineError && (
)}
{isStreaming && !isShowingInlineError && !isCompacting && }
{!isStreaming && contentToCopy.trim().length > 0 && (
{message.forkable !== false && turnIndex !== undefined && turnIndex >= 0 && }
)}
setPreviewMedia(null)} />
);
}
function hasMessageContent(message: ChatMessageType): boolean {
if (!Content.isEmpty(message.content)) {
return true;
}
return message.steps?.some((s) => s.items.length > 0) ?? false;
}
export const ChatMessage = memo(function ChatMessage({ message, turnIndex, isStreaming }: ChatMessageProps) {
if (message.role === "user") {
return ;
}
return ;
});