perf(home): 拆分 ChatComposer 修复输入框打字卡顿
All checks were successful
CI / build (push) Successful in 3m7s

将输入草稿状态从 HomePage 抽离为独立 memo 组件,避免每次按键重渲染消息列表与侧栏;并微调输入区与滚动条样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 17:08:24 +08:00
parent 0095291eb0
commit 894eeb8c4b

View File

@@ -1,6 +1,15 @@
import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import {
Atom,
ArrowUp,
ChevronDown,
Copy,
@@ -13,7 +22,7 @@ import {
Pencil,
Pin,
RotateCcw,
Search,
Globe,
Settings,
Share2,
Smartphone,
@@ -120,6 +129,113 @@ function replaceSessionUrl(sessionId: string) {
window.history.replaceState(window.history.state, "", nextPath);
}
type ChatComposerProps = {
onSend: (text: string, clearDraft: () => void) => Promise<void>;
isSending: boolean;
/** 正在创建首条会话时锁定,避免重复点击 */
creationLocked: boolean;
deepThink: boolean;
onDeepThinkToggle: () => void;
smartSearch: boolean;
onSmartSearchToggle: () => void;
showScrollToBottom: boolean;
onScrollToBottomClick: () => void;
};
/**
* 独立维护输入草稿:按键时只重渲染本区域,避免整页(消息列表、侧栏)随输入重绘导致卡顿。
*/
const ChatComposer = memo(function ChatComposer({
onSend,
isSending,
creationLocked,
deepThink,
onDeepThinkToggle,
smartSearch,
onSmartSearchToggle,
showScrollToBottom,
onScrollToBottomClick,
}: ChatComposerProps) {
const [draft, setDraft] = useState("");
const clearDraft = useCallback(() => setDraft(""), []);
const handleSend = useCallback(async () => {
const trimmed = draft.trim();
if (!trimmed || isSending || creationLocked) return;
await onSend(trimmed, clearDraft);
}, [creationLocked, draft, clearDraft, isSending, onSend]);
return (
<div className="relative">
{showScrollToBottom && (
<button
type="button"
aria-label="滚动到底部"
className="absolute -top-14 right-3 z-10 flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-neutral-200/90 bg-white text-neutral-900 shadow-md transition-colors hover:bg-neutral-50"
onClick={onScrollToBottomClick}
>
<ChevronDown color="#666" size={18} strokeWidth={2} aria-hidden />
</button>
)}
<div className="rounded-4xl border border-[var(--ds-border)] p-3 shadow-xs">
<Input.TextArea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPressEnter={(e) => {
if (e.shiftKey) return;
e.preventDefault();
void handleSend();
}}
placeholder="向 ChatOne 提问"
variant="borderless"
autoSize={{ minRows: 2, maxRows: 8 }}
className="!px-1 !text-[15px] placeholder:text-neutral-400"
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2">
<Button
color={deepThink ? "primary" : "default"}
variant={deepThink ? "filled" : undefined}
shape="round"
icon={<Atom size={14} />}
onClick={onDeepThinkToggle}
>
</Button>
<Button
color={smartSearch ? "primary" : "default"}
variant={smartSearch ? "filled" : undefined}
shape="round"
icon={<Globe size={14} />}
onClick={onSmartSearchToggle}
>
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="text"
shape="circle"
icon={<Paperclip size={16} className="text-neutral-400" />}
className="text-neutral-400"
/>
<Button
type="primary"
shape="circle"
icon={<ArrowUp size={18} />}
loading={isSending}
disabled={isSending || creationLocked || !draft.trim()}
onClick={() => {
void handleSend();
}}
/>
</div>
</div>
</div>
</div>
);
});
export default function HomePage() {
const [modal, modalContextHolder] = Modal.useModal();
const navigate = useNavigate();
@@ -127,7 +243,6 @@ export default function HomePage() {
const [collapsed, setCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<UiMessage[]>(INITIAL_MESSAGES);
const [isSending, setIsSending] = useState(false);
const [deepThink, setDeepThink] = useState(() => {
@@ -164,6 +279,11 @@ export default function HomePage() {
const [sessionActionBusyId, setSessionActionBusyId] = useState<string | null>(null);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const activeSessionIdRef = useRef<string | null>(null);
const messagesRef = useRef<UiMessage[]>(INITIAL_MESSAGES);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
const userJsonSnapshot = useSyncExternalStore(
subscribeUserProfile,
@@ -287,6 +407,15 @@ export default function HomePage() {
};
}, [isMessageListAtBottom]);
const toggleDeepThink = useCallback(() => setDeepThink((v) => !v), []);
const toggleSmartSearch = useCallback(() => setSmartSearch((v) => !v), []);
const handleScrollToBottomButton = useCallback(() => {
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
scrollMessageListToBottom("smooth");
}, [scrollMessageListToBottom]);
useEffect(() => {
if (shouldStickToBottomRef.current) {
scrollMessageListToBottom("auto");
@@ -464,8 +593,9 @@ export default function HomePage() {
}
}, []);
const sendMessage = async () => {
const trimmed = inputValue.trim();
const sendMessage = useCallback(
async (trimmedInput: string, clearDraft: () => void) => {
const trimmed = trimmedInput.trim();
if (!trimmed || isSending) return;
let targetSessionId = activeSessionId;
if (!targetSessionId) {
@@ -490,7 +620,7 @@ export default function HomePage() {
}
}
setInputValue("");
clearDraft();
setIsSending(true);
const userMessage: UiMessage = {
@@ -506,7 +636,7 @@ export default function HomePage() {
thinking: deepThink ? "正在思考并生成答案…" : undefined,
};
const nextMessages = [...messages, userMessage, assistantPlaceholder];
const nextMessages = [...messagesRef.current, userMessage, assistantPlaceholder];
setMessages(nextMessages);
const payloadMessages: ChatMessagePayload[] = nextMessages.map((item) => ({
@@ -599,7 +729,17 @@ export default function HomePage() {
item.id === assistantMessageId ? { ...item, thinking: undefined } : item,
),
);
};
},
[
activeSessionId,
deepThink,
isSending,
refreshSessionList,
selectedModel,
sessionMutationBusy,
smartSearch,
],
);
const sidebarContent = (
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col bg-[var(--ds-bg-sider)]">
@@ -618,7 +758,7 @@ export default function HomePage() {
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto px-2 py-2">
<div className="min-h-0 flex-1 overflow-auto px-2 py-2 scrollbar-thumb-gray-200 scrollbar-gutter-stable">
{sessionsLoading ? (
<Typography.Text className="block px-2 text-[12px]" type="secondary">
@@ -808,12 +948,9 @@ export default function HomePage() {
aria-label="选择对话模型"
/>
</div>
<span className="rounded-full border border-[var(--ds-border)] bg-neutral-50 px-3 py-1 text-xs text-neutral-500">
</span>
</Header>
<Content className="flex min-h-0 flex-1 flex-col overflow-hidden !bg-[var(--ds-bg-main)]">
<Content className="flex min-h-0 flex-1 flex-col overflow-hidden !bg-[var(--ds-bg-main)] scrollbar-thumb-gray-200 scrollbar-gutter-stable">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div
ref={messageListRef}
@@ -954,86 +1091,17 @@ export default function HomePage() {
<div className="shrink-0 bg-[var(--ds-bg-main)] px-4 pt-0 md:px-8 md:pt-0">
<div className="mx-auto max-w-4xl">
<div className="relative">
{showScrollToBottom && (
<button
type="button"
aria-label="滚动到底部"
className="absolute -top-14 right-3 z-10 flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-neutral-200/90 bg-white text-neutral-900 shadow-md transition-colors hover:bg-neutral-50"
onClick={() => {
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
scrollMessageListToBottom("smooth");
}}
>
<ChevronDown color="#666" size={18} strokeWidth={2} aria-hidden />
</button>
)}
<div className="rounded-3xl border border-[var(--ds-border)] bg-neutral-50/50 p-3 shadow-sm">
<Input.TextArea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={(e) => {
if (e.shiftKey) return;
e.preventDefault();
void sendMessage();
}}
placeholder="给 ChatOne 发送消息"
variant="borderless"
autoSize={{ minRows: 2, maxRows: 8 }}
className="!px-1 !text-[15px] placeholder:text-neutral-400"
<ChatComposer
onSend={sendMessage}
isSending={isSending}
creationLocked={!activeSessionId && sessionMutationBusy}
deepThink={deepThink}
onDeepThinkToggle={toggleDeepThink}
smartSearch={smartSearch}
onSmartSearchToggle={toggleSmartSearch}
showScrollToBottom={showScrollToBottom}
onScrollToBottomClick={handleScrollToBottomButton}
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setDeepThink((v) => !v)}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
deepThink
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300"
}`}
>
<Zap size={14} />
</button>
<button
type="button"
onClick={() => setSmartSearch((v) => !v)}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
smartSearch
? "border-sky-300 bg-sky-50 text-sky-700"
: "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300"
}`}
>
<Search size={14} />
</button>
</div>
<div className="flex items-center gap-2">
<Button
type="text"
icon={<Paperclip size={16} className="text-neutral-400" />}
className="text-neutral-400"
/>
<Button
type="primary"
shape="circle"
icon={<ArrowUp size={16} />}
loading={isSending}
disabled={isSending || !inputValue.trim()}
onClick={() => {
void sendMessage();
}}
className="!flex !h-9 !w-9 !items-center !justify-center !border-0 !shadow-none"
style={{
background: "var(--ds-send)",
}}
/>
</div>
</div>
</div>
</div>
<p className="m-0 py-2 text-center text-[11px] text-neutral-500">
AI
</p>
@@ -1049,7 +1117,7 @@ export default function HomePage() {
closable
onClose={() => setMobileSidebarOpen(false)}
open={isMobile && mobileSidebarOpen}
width={280}
size={280}
styles={{
body: {
padding: 0,