Improve chat composer typing performance

This commit is contained in:
2026-08-13 00:08:15 -07:00
parent 1ef491f39a
commit 42022bf055
3 changed files with 167 additions and 59 deletions

View File

@@ -7,7 +7,6 @@ import {
LoaderCircle, LoaderCircle,
Menu, Menu,
MessageSquare, MessageSquare,
Paperclip,
Pencil, Pencil,
Plus, Plus,
Rabbit, Rabbit,
@@ -23,6 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { AuthScreen } from "@/components/auth/auth-screen"; import { AuthScreen } from "@/components/auth/auth-screen";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list"; import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel"; import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { SearchResultsPanel } from "@/components/search/search-results-panel"; import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { SybilCharacter } from "@/components/sybil-character"; import { SybilCharacter } from "@/components/sybil-character";
@@ -925,7 +925,7 @@ export default function App() {
const [pendingChatStates, setPendingChatStates] = useState<Record<string, PendingChatState>>({}); const [pendingChatStates, setPendingChatStates] = useState<Record<string, PendingChatState>>({});
const [runningSearchStates, setRunningSearchStates] = useState<Record<string, SearchDetail>>({}); const [runningSearchStates, setRunningSearchStates] = useState<Record<string, SearchDetail>>({});
const [activeRuns, setActiveRuns] = useState<ActiveRunsState>(EMPTY_ACTIVE_RUNS); const [activeRuns, setActiveRuns] = useState<ActiveRunsState>(EMPTY_ACTIVE_RUNS);
const [composer, setComposer] = useState(""); const [composerDraftRevision, setComposerDraftRevision] = useState(0);
const [pendingAttachments, setPendingAttachments] = useState<ChatAttachment[]>([]); const [pendingAttachments, setPendingAttachments] = useState<ChatAttachment[]>([]);
const [isComposerDropActive, setIsComposerDropActive] = useState(false); const [isComposerDropActive, setIsComposerDropActive] = useState(false);
const [provider, setProvider] = useState<Provider>("openai"); const [provider, setProvider] = useState<Provider>("openai");
@@ -977,6 +977,7 @@ export default function App() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]); const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
const composerDraftRef = useRef("");
const selectedItemRef = useRef<SidebarSelection | null>(null); const selectedItemRef = useRef<SidebarSelection | null>(null);
const pendingTitleGenerationRef = useRef<Set<string>>(new Set()); const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
const chatStreamAbortRefs = useRef<Map<string, AbortController>>(new Map()); const chatStreamAbortRefs = useRef<Map<string, AbortController>>(new Map());
@@ -1050,13 +1051,10 @@ export default function App() {
}); });
}; };
useEffect(() => { const replaceComposerDraft = (value: string) => {
if (typeof document === "undefined") return; composerDraftRef.current = value;
const textarea = document.getElementById("composer-input") as HTMLTextAreaElement | null; setComposerDraftRevision((current) => current + 1);
if (!textarea) return; };
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
}, [composer]);
useEffect(() => { useEffect(() => {
pendingAttachmentsRef.current = pendingAttachments; pendingAttachmentsRef.current = pendingAttachments;
@@ -1096,7 +1094,7 @@ export default function App() {
} }
searchRunAbortRefs.current.clear(); searchRunAbortRefs.current.clear();
searchRunCountersRef.current.clear(); searchRunCountersRef.current.clear();
setComposer(""); replaceComposerDraft("");
setPendingAttachments([]); setPendingAttachments([]);
setIsChatSettingsOpen(false); setIsChatSettingsOpen(false);
setIsSavingChatSettings(false); setIsSavingChatSettings(false);
@@ -2967,7 +2965,7 @@ export default function App() {
try { try {
const chat = await createChatFromSearch(sourceSearch.id); const chat = await createChatFromSearch(sourceSearch.id);
setDraftKind(null); setDraftKind(null);
setComposer(""); replaceComposerDraft("");
setPendingAttachments([]); setPendingAttachments([]);
setChats((current) => { setChats((current) => {
const withoutExisting = current.filter((existing) => existing.id !== chat.id); const withoutExisting = current.filter((existing) => existing.id !== chat.id);
@@ -3126,7 +3124,7 @@ export default function App() {
}); });
setDraftKind(null); setDraftKind(null);
setComposer(""); replaceComposerDraft("");
setPendingAttachments([]); setPendingAttachments([]);
setIsQuickQuestionOpen(false); setIsQuickQuestionOpen(false);
setProvider(selection.provider); setProvider(selection.provider);
@@ -3167,8 +3165,8 @@ export default function App() {
} }
}; };
const handleSend = async () => { const handleSend = async (draft: string) => {
const content = composer.trim(); const content = draft.trim();
const attachments = pendingAttachments; const attachments = pendingAttachments;
if ((!content && !attachments.length) || isActiveSelectionSending) return; if ((!content && !attachments.length) || isActiveSelectionSending) return;
if (isSearchMode && attachments.length) { if (isSearchMode && attachments.length) {
@@ -3176,7 +3174,7 @@ export default function App() {
return; return;
} }
setComposer(""); replaceComposerDraft("");
setPendingAttachments([]); setPendingAttachments([]);
setError(null); setError(null);
@@ -3201,7 +3199,7 @@ export default function App() {
} }
if (!sentAsSearch && (!sentTarget || isCurrentSelection(sentTarget))) { if (!sentAsSearch && (!sentTarget || isCurrentSelection(sentTarget))) {
setComposer(content); replaceComposerDraft(content);
setPendingAttachments(attachments); setPendingAttachments(attachments);
} }
@@ -3521,52 +3519,22 @@ export default function App() {
</div> </div>
</div> </div>
) : null} ) : null}
<Textarea <ChatComposer
id="composer-input" draftRef={composerDraftRef}
rows={1} draftRevision={composerDraftRevision}
value={composer} error={error}
onInput={(event) => { isSearchMode={isSearchMode}
const textarea = event.currentTarget; isSending={isActiveSelectionSending}
textarea.style.height = "0px"; pendingAttachmentCount={pendingAttachments.length}
textarea.style.height = `${textarea.scrollHeight}px`; attachmentButtonDisabled={
setComposer(textarea.value); isActiveSelectionSending || pendingAttachments.length >= MAX_CHAT_ATTACHMENTS
}} }
onOpenAttachmentPicker={handleOpenAttachmentPicker}
onPaste={(event) => { onPaste={(event) => {
void handleComposerPaste(event); void handleComposerPaste(event);
}} }}
onKeyDown={(event) => { onSend={handleSend}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
disabled={isActiveSelectionSending}
/> />
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
{!isSearchMode ? (
<Button
className="h-10 w-10 rounded-lg"
onClick={handleOpenAttachmentPicker}
size="icon"
variant="secondary"
disabled={isActiveSelectionSending || pendingAttachments.length >= MAX_CHAT_ATTACHMENTS}
aria-label="Attach files"
>
<Paperclip className="h-4 w-4" />
</Button>
) : null}
<Button
className="h-10 w-10 rounded-lg"
onClick={() => void handleSend()}
size="icon"
disabled={isActiveSelectionSending || (!composer.trim() && !pendingAttachments.length)}
>
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
</Button>
</div>
</div> </div>
</footer> </footer>
</main> </main>

View File

@@ -0,0 +1,140 @@
import { useLayoutEffect, useRef, useState } from "preact/hooks";
import { Paperclip, Search, SendHorizontal } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
type MutableValueRef = {
current: string;
};
type Props = {
draftRef: MutableValueRef;
draftRevision: number;
error: string | null;
isSearchMode: boolean;
isSending: boolean;
pendingAttachmentCount: number;
attachmentButtonDisabled: boolean;
onOpenAttachmentPicker: () => void;
onPaste: (event: ClipboardEvent) => void;
onSend: (draft: string) => void | Promise<void>;
};
const MIRROR_SENTINEL = "\u200b";
const HAS_NON_WHITESPACE = /\S/;
export function ChatComposer({
draftRef,
draftRevision,
error,
isSearchMode,
isSending,
pendingAttachmentCount,
attachmentButtonDisabled,
onOpenAttachmentPicker,
onPaste,
onSend,
}: Props) {
// The draft stays in the DOM/ref so typing never schedules a render of the workspace transcript.
const textareaContainerRef = useRef<HTMLDivElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
const [hasDraft, setHasDraft] = useState(() => HAS_NON_WHITESPACE.test(draftRef.current));
const hasDraftRef = useRef(hasDraft);
const getTextarea = () => textareaContainerRef.current?.querySelector("textarea") ?? null;
const updateMirror = (value: string) => {
// The overlapping mirror lets normal layout size the textarea without synchronous scrollHeight reads.
if (mirrorRef.current) mirrorRef.current.textContent = `${value}${MIRROR_SENTINEL}`;
};
const updateHasDraft = (value: string) => {
const nextHasDraft = HAS_NON_WHITESPACE.test(value);
if (nextHasDraft !== hasDraftRef.current) {
hasDraftRef.current = nextHasDraft;
setHasDraft(nextHasDraft);
}
};
useLayoutEffect(() => {
const value = draftRef.current;
const textarea = getTextarea();
if (textarea && textarea.value !== value) {
textarea.value = value;
}
updateMirror(value);
updateHasDraft(value);
}, [draftRevision]);
const submit = () => {
const textarea = getTextarea();
const draft = textarea?.value ?? draftRef.current;
const canSend = HAS_NON_WHITESPACE.test(draft) || (!isSearchMode && pendingAttachmentCount > 0);
if (isSending || !canSend) return;
draftRef.current = "";
if (textarea) textarea.value = "";
updateMirror("");
updateHasDraft("");
void onSend(draft);
};
return (
<>
<div ref={textareaContainerRef} className="grid max-h-40 min-h-0 overflow-hidden">
<div
ref={mirrorRef}
className="pointer-events-none invisible col-start-1 row-start-1 max-h-40 min-h-0 overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words px-3 py-3 text-base"
aria-hidden="true"
/>
<Textarea
id="composer-input"
rows={1}
onInput={(event) => {
const value = event.currentTarget.value;
draftRef.current = value;
updateMirror(value);
updateHasDraft(value);
}}
onPaste={(event) => {
onPaste(event);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit();
}
}}
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
className="col-start-1 row-start-1 h-full max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
disabled={isSending}
/>
</div>
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
{!isSearchMode ? (
<Button
className="h-10 w-10 rounded-lg"
onClick={onOpenAttachmentPicker}
size="icon"
variant="secondary"
disabled={attachmentButtonDisabled}
aria-label="Attach files"
>
<Paperclip className="h-4 w-4" />
</Button>
) : null}
<Button
className="h-10 w-10 rounded-lg"
onClick={submit}
size="icon"
disabled={isSending || (!hasDraft && (isSearchMode || pendingAttachmentCount === 0))}
aria-label={isSearchMode ? "Search" : "Send message"}
>
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
</Button>
</div>
</>
);
}

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}