perf(home): 拆分 ChatComposer 修复输入框打字卡顿
All checks were successful
CI / build (push) Successful in 3m7s
All checks were successful
CI / build (push) Successful in 3m7s
将输入草稿状态从 HomePage 抽离为独立 memo 组件,避免每次按键重渲染消息列表与侧栏;并微调输入区与滚动条样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user