| 56 | | { kind: "ref-validation"; broken: string[]; unread: string[]; downgraded?: string[] }; |
| 57 | |
| 58 | export function ChatPanel({ |
| 59 | provider, |
| 60 | model, |
| 61 | system, |
| 62 | toolBudget, |
| 63 | mode, |
| 64 | workspace, |
| 65 | onOpenRef, |
| 66 | onOpenNode, |
| 67 | }: Props) { |
| 68 | const t = useT(); |
| 69 | const [messages, setMessages] = useState<UIMessage[]>([]); |
| 70 | const [input, setInput] = useState(""); |
| 71 | const [busy, setBusy] = useState(false); |
| 72 | const [view, setView] = useState<"chat" | "flow">("chat"); |
| 73 | const scrollRef = useRef<HTMLDivElement>(null); |
| 74 | const abortRef = useRef<AbortController | null>(null); |
| 75 | // 「黏底」状态:仅当用户已经在底部时,流式增量才自动滚到底; |
| 76 | // 一旦用户往上滚查看历史,就停止自动滚动,避免被流式输出一直往下拽。 |
| 77 | const stickToBottomRef = useRef(true); |
| 78 | const [showJumpToBottom, setShowJumpToBottom] = useState(false); |
| 79 | |
| 80 | const latestAssistant = |
| 81 | [...messages].reverse().find((m) => m.role === "assistant") || null; |
| 82 | const hasToolCalls = |
| 83 | !!latestAssistant?.parts.some((p) => p.kind === "tool-call"); |
| 84 | const latestUser = |
| 85 | [...messages].reverse().find((m) => m.role === "user") || null; |
| 86 | const latestUserText = |
| 87 | latestUser?.parts |
| 88 | .filter((p) => p.kind === "text") |
| 89 | .map((p) => (p as { text: string }).text) |
| 90 | .join("") || null; |
| 91 | |
| 92 | // 流式增量到来时:只有「黏底」时才自动滚到底,否则保持用户当前阅读位置不动。 |
| 93 | useEffect(() => { |
| 94 | if (!stickToBottomRef.current) return; |
| 95 | const el = scrollRef.current; |
| 96 | if (el) el.scrollTo({ top: el.scrollHeight }); |
| 97 | }, [messages]); |
| 98 | |
| 99 | // 监听用户滚动:距底 < 80px 视为「在底部」→ 继续黏底;往上滚则脱离黏底并显示「回到最新」。 |
| 100 | const handleScroll = useCallback(() => { |
| 101 | const el = scrollRef.current; |
| 102 | if (!el) return; |
| 103 | const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; |
| 104 | const atBottom = distanceFromBottom < 80; |
| 105 | stickToBottomRef.current = atBottom; |
| 106 | setShowJumpToBottom(!atBottom); |
| 107 | }, []); |
| 108 | |
| 109 | const jumpToBottom = useCallback(() => { |
| 110 | const el = scrollRef.current; |
| 111 | if (!el) return; |
| 112 | // 瞬时滚到底(非 smooth)——避免滚动动画途中 onScroll 把按钮短暂闪回。 |
| 113 | el.scrollTo({ top: el.scrollHeight }); |
| 114 | stickToBottomRef.current = true; |
| 115 | setShowJumpToBottom(false); |