Improve chat composer typing performance
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
140
web/src/components/chat/chat-composer.tsx
Normal file
140
web/src/components/chat/chat-composer.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user