import { useEffect, useRef, useState } from "react"; import type { ChatMessage } from "./mapTrace"; export function useAutoFollow(dep: unknown) { const ref = useRef(null); const [following, setFollowing] = useState(true); const followingRef = useRef(true); const onScroll = () => { const el = ref.current; if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; followingRef.current = nearBottom; setFollowing(nearBottom); }; useEffect(() => { if (followingRef.current && ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [dep]); return { ref, following, onScroll }; } // GitHub-style comment box, not a chat bubble: full-width white box // with a subtle header row (role label only — no timestamp or sender // is invented; the backend provides neither) and the body below. // Status entries are plain muted timeline text on the timeline line. export function ChatBubble({ msg }: { msg: ChatMessage }) { if (msg.role === "status") { return
{msg.text}
; } const author = msg.label || (msg.role === "user" ? "用户" : msg.role === "assistant" ? "助手" : "工具"); return (
{author}
{msg.text}
); } export function ChatList({ messages, empty }: { messages: ChatMessage[]; empty?: string }) { const { ref, following, onScroll } = useAutoFollow(messages.length); return (
{messages.length === 0 ?
{empty || "(暂无消息)"}
: messages.map((m) => )} {!following && messages.length > 0 ? ( ) : null}
); } export function ChatInput({ onSend, disabled, placeholder }: { onSend: (text: string) => void; disabled?: boolean; placeholder?: string; }) { const [text, setText] = useState(""); const send = () => { const v = text.trim(); if (!v || disabled) return; onSend(v); setText(""); }; // GitHub comment box: textarea on top, a subtle footer row with the // green primary send button. No mic or attachment button here: the // continue chat has no voice or attachment API (attachments live in // Dispatch), so a mic/+ icon would be a dead button. return (