输入框状态剥离,避免受长信息列表渲染影响;覆写滚动条状态动作,不再强制拉到最底

This commit is contained in:
2026-06-03 15:01:24 +08:00
parent 888132a60f
commit fa3e6b6e84
3 changed files with 90 additions and 47 deletions
+41 -17
View File
@@ -28,44 +28,65 @@ import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded"; import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import type { AgentModel } from "@/lib/chatStream"; import type { AgentModel } from "@/lib/chatStream";
export type AgentComposerHandle = {
focus: () => void;
clear: () => void;
append: (text: string) => void;
setValue: (value: string) => void;
getValue: () => string;
};
type AgentComposerProps = { type AgentComposerProps = {
input: string;
inputRef: React.RefObject<HTMLInputElement | null>;
isHydrating?: boolean; isHydrating?: boolean;
isStreaming: boolean; isStreaming: boolean;
isListening: boolean; isListening: boolean;
isSttSupported: boolean; isSttSupported: boolean;
presets: string[]; presets: string[];
onInputChange: (value: string) => void; onSend: (prompt: string) => void;
onSend: () => void;
onAbort: () => void; onAbort: () => void;
onStartListening: () => void; onStartListening: () => void;
onStopListening: () => void; onStopListening: () => void;
onPresetSelect: (prompt: string) => void;
selectedModel: AgentModel; selectedModel: AgentModel;
onModelChange: (model: AgentModel) => void; onModelChange: (model: AgentModel) => void;
}; };
export const AgentComposer = ({ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
input,
inputRef,
isHydrating = false, isHydrating = false,
isStreaming, isStreaming,
isListening, isListening,
isSttSupported, isSttSupported,
presets, presets,
onInputChange,
onSend, onSend,
onAbort, onAbort,
onStartListening, onStartListening,
onStopListening, onStopListening,
onPresetSelect,
selectedModel, selectedModel,
onModelChange, onModelChange,
}: AgentComposerProps) => { }, ref) {
const theme = useTheme(); const theme = useTheme();
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const [input, setInput] = React.useState("");
const [isPresetOpen, setIsPresetOpen] = React.useState(false); const [isPresetOpen, setIsPresetOpen] = React.useState(false);
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
React.useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => setInput(""),
append: (text: string) => setInput((prev) => prev + text),
setValue: (value: string) => setInput(value),
getValue: () => input,
}),
[input],
);
const handleSend = React.useCallback(() => {
const prompt = input.trim();
if (!prompt || isStreaming || isHydrating) return;
setInput("");
onSend(prompt);
}, [input, isHydrating, isStreaming, onSend]);
return ( return (
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}> <Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
@@ -121,8 +142,11 @@ export const AgentComposer = ({
size="medium" size="medium"
clickable clickable
onClick={() => { onClick={() => {
onPresetSelect(prompt); setInput(prompt);
setIsPresetOpen(false); setIsPresetOpen(false);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}} }}
sx={{ sx={{
height: 32, height: 32,
@@ -165,11 +189,11 @@ export const AgentComposer = ({
<TextField <TextField
inputRef={inputRef} inputRef={inputRef}
value={input} value={input}
onChange={(event) => onInputChange(event.target.value)} onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
onSend(); handleSend();
} }
}} }}
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."} placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
@@ -362,7 +386,7 @@ export const AgentComposer = ({
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> <motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton <IconButton
disabled={!canSend} disabled={!canSend}
onClick={onSend} onClick={handleSend}
aria-label="发送" aria-label="发送"
size="small" size="small"
sx={{ sx={{
@@ -397,4 +421,4 @@ export const AgentComposer = ({
</Box> </Box>
</Box> </Box>
); );
}; });
+6
View File
@@ -23,6 +23,8 @@ type AgentWorkspaceProps = {
branchGroups: BranchGroup[]; branchGroups: BranchGroup[];
branchTransition: BranchTransition | null; branchTransition: BranchTransition | null;
isStreaming: boolean; isStreaming: boolean;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
onScroll: React.UIEventHandler<HTMLDivElement>;
bottomRef: React.RefObject<HTMLDivElement | null>; bottomRef: React.RefObject<HTMLDivElement | null>;
speakingMessageId: string | null; speakingMessageId: string | null;
speechState: SpeechState; speechState: SpeechState;
@@ -155,6 +157,8 @@ export const AgentWorkspace = ({
branchGroups, branchGroups,
branchTransition, branchTransition,
isStreaming, isStreaming,
scrollContainerRef,
onScroll,
bottomRef, bottomRef,
speakingMessageId, speakingMessageId,
speechState, speechState,
@@ -216,6 +220,8 @@ export const AgentWorkspace = ({
return ( return (
<Box <Box
ref={scrollContainerRef}
onScroll={onScroll}
sx={{ sx={{
flex: 1, flex: 1,
overflowY: "auto", overflowY: "auto",
+43 -30
View File
@@ -10,7 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
import type { AgentModel } from "@/lib/chatStream"; import type { AgentModel } from "@/lib/chatStream";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
import { AgentComposer } from "./AgentComposer"; import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
import { AgentHeader } from "./AgentHeader"; import { AgentHeader } from "./AgentHeader";
import { AgentHistoryPanel } from "./AgentHistoryPanel"; import { AgentHistoryPanel } from "./AgentHistoryPanel";
import { AgentWorkspace } from "./AgentWorkspace"; import { AgentWorkspace } from "./AgentWorkspace";
@@ -22,7 +22,6 @@ import { useAgentChatSession } from "./hooks/useAgentChatSession";
import { useAgentToolActions } from "./hooks/useAgentToolActions"; import { useAgentToolActions } from "./hooks/useAgentToolActions";
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => { export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const [input, setInput] = useState("");
const [width, setWidth] = useState(520); const [width, setWidth] = useState(520);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false);
@@ -31,8 +30,10 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
); );
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const workspaceRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<AgentComposerHandle | null>(null);
const hasResetForOpenRef = useRef(false); const hasResetForOpenRef = useRef(false);
const shouldAutoScrollRef = useRef(true);
const theme = useTheme(); const theme = useTheme();
const currentProjectId = useProjectStore((state) => state.currentProjectId); const currentProjectId = useProjectStore((state) => state.currentProjectId);
@@ -47,7 +48,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
} = useSpeechSynthesis(); } = useSpeechSynthesis();
const handleSpeechResult = useCallback((text: string) => { const handleSpeechResult = useCallback((text: string) => {
setInput((prev) => prev + text); composerRef.current?.append(text);
}, []); }, []);
const { const {
@@ -83,9 +84,22 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
getModel: () => selectedModel, getModel: () => selectedModel,
}); });
const syncAutoScrollState = useCallback(() => {
const container = workspaceRef.current;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
shouldAutoScrollRef.current = distanceFromBottom <= 120;
}, []);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
bottomRef.current?.scrollIntoView({ behavior });
}, []);
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); if (!shouldAutoScrollRef.current) return;
}, [messages, isStreaming]); scrollToBottom(isStreaming ? "auto" : "smooth");
}, [isStreaming, messages, scrollToBottom]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -96,38 +110,33 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
hasResetForOpenRef.current = true; hasResetForOpenRef.current = true;
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
shouldAutoScrollRef.current = true;
createSession(); createSession();
setInput(""); composerRef.current?.clear();
setIsHistoryOpen(false); setIsHistoryOpen(false);
inputRef.current?.focus(); composerRef.current?.focus();
bottomRef.current?.scrollIntoView({ behavior: "auto" }); scrollToBottom("auto");
}, 0); }, 0);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [createSession, isHydrating, open]); }, [createSession, isHydrating, open, scrollToBottom]);
const handleSend = useCallback(() => { const handleSend = useCallback((prompt: string) => {
const prompt = input.trim(); if (isStreaming) return;
if (!prompt || isStreaming) return; shouldAutoScrollRef.current = true;
setInput("");
void sendPrompt(prompt); void sendPrompt(prompt);
}, [input, isStreaming, sendPrompt]); }, [isStreaming, sendPrompt]);
const handlePresetPromptSelect = useCallback((prompt: string) => {
setInput(prompt);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}, []);
const handleNewConversation = useCallback(() => { const handleNewConversation = useCallback(() => {
handleStopSpeech(); handleStopSpeech();
stopListening(); stopListening();
shouldAutoScrollRef.current = true;
createSession(); createSession();
setInput(""); composerRef.current?.clear();
window.setTimeout(() => { window.setTimeout(() => {
inputRef.current?.focus(); composerRef.current?.focus();
scrollToBottom("auto");
}, 0); }, 0);
}, [createSession, handleStopSpeech, stopListening]); }, [createSession, handleStopSpeech, scrollToBottom, stopListening]);
const handleHistoryToggle = useCallback(() => { const handleHistoryToggle = useCallback(() => {
setIsHistoryOpen((prev) => !prev); setIsHistoryOpen((prev) => !prev);
@@ -135,12 +144,17 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
const handleSelectSession = useCallback( const handleSelectSession = useCallback(
(storageSessionId: string) => { (storageSessionId: string) => {
setInput(""); shouldAutoScrollRef.current = true;
composerRef.current?.clear();
void switchSession(storageSessionId); void switchSession(storageSessionId);
}, },
[switchSession], [switchSession],
); );
const handleWorkspaceScroll = useCallback(() => {
syncAutoScrollState();
}, [syncAutoScrollState]);
const handleDeleteSession = useCallback( const handleDeleteSession = useCallback(
(storageSessionId: string) => { (storageSessionId: string) => {
void removeSession(storageSessionId); void removeSession(storageSessionId);
@@ -320,6 +334,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
branchGroups={branchGroups} branchGroups={branchGroups}
branchTransition={branchTransition} branchTransition={branchTransition}
isStreaming={isStreaming} isStreaming={isStreaming}
scrollContainerRef={workspaceRef}
onScroll={handleWorkspaceScroll}
bottomRef={bottomRef} bottomRef={bottomRef}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
@@ -334,19 +350,16 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
/> />
<AgentComposer <AgentComposer
input={input} ref={composerRef}
inputRef={inputRef}
isHydrating={isHydrating} isHydrating={isHydrating}
isStreaming={isStreaming} isStreaming={isStreaming}
isListening={isListening} isListening={isListening}
isSttSupported={isSttSupported} isSttSupported={isSttSupported}
presets={PRESET_PROMPTS} presets={PRESET_PROMPTS}
onInputChange={setInput}
onSend={handleSend} onSend={handleSend}
onAbort={abort} onAbort={abort}
onStartListening={startListening} onStartListening={startListening}
onStopListening={stopListening} onStopListening={stopListening}
onPresetSelect={handlePresetPromptSelect}
selectedModel={selectedModel} selectedModel={selectedModel}
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
/> />