Compare commits

...

17 Commits

Author SHA1 Message Date
Wenxi Onyx
9f591fdc7a move text area re-size to parent 2025-11-03 10:30:50 -08:00
Wenxi Onyx
53c3294ff5 resize text area helper 2025-11-03 10:30:50 -08:00
Wenxi Onyx
76742802a2 fix new chat session draft + input bar resizing 2025-11-03 10:30:50 -08:00
Wenxi Onyx
412835ad46 refactor: extract SSR-safe storage helpers
Created getDraft(), saveDraft(), and removeDraft() helpers to
eliminate repeated typeof window !== "undefined" checks.

Benefits:
- DRY: Single source of truth for storage operations (4 call sites)
- Consistency: All storage operations follow same pattern
- Maintainability: Change error handling in one place
- SSR-safe: Handles server-side rendering automatically
2025-11-03 10:30:50 -08:00
Wenxi Onyx
9e389eb509 fix: resize textarea when restoring draft on chat switch
When switching chats, restored drafts would have incorrect textarea
height (clipped or excess whitespace) until user typed again.

Bug: setLocalMessage updates text but doesn't resize textarea
Fix: Trigger resize routine after loading saved draft
2025-11-03 10:30:50 -08:00
Wenxi Onyx
b3f6ccaac8 refactor: use hasMountedRef instead of prevDraftKeyRef
Replaced indirect ref comparison with explicit mount tracking flag.
This is the idiomatic React pattern for "skip on mount, run on updates".

Benefits:
- Clearer intent: hasMountedRef explicitly tracks first render
- Simpler logic: boolean check vs ref comparison
- More maintainable: standard pattern developers recognize
2025-11-03 10:30:50 -08:00
Wenxi Onyx
cf5bb4ea99 fix: prioritize URL params over saved drafts
URL parameters represent user intent (templates, shared links,
bookmarks) and should always take precedence over saved drafts.

Bug: Saved drafts would override ?user-prompt= URL parameter
Fix: Check initialMessage first, only load draft if empty
2025-11-03 10:30:50 -08:00
Wenxi Onyx
f2ba2b6f27 fix: remove draftKey from clear effect dependencies
Use draftKeyRef to prevent effect from firing on chat switches.
2025-11-03 10:30:50 -08:00
Wenxi Onyx
edc9900cb1 fix: restore textarea height after regenerate
Use requestAnimationFrame to recalculate height after clearing.
2025-11-03 10:30:50 -08:00
Wenxi Onyx
d487e8511c fix: prevent useEffect from clearing URL initialMessage
Use prevDraftKeyRef to skip draft-loading effect on initial mount.
2025-11-03 10:30:50 -08:00
Wenxi Onyx
631a0e62a0 fix: add error handling and chat switch detection
- Wrap sessionStorage operations in try/catch
- Add useEffect to reload draft when switching chats
- Skip effect on initial mount to preserve URL initialMessage
2025-11-03 10:30:50 -08:00
Wenxi Onyx
876d816001 feat: add draft persistence for chat input
Save/restore draft text in sessionStorage keyed by chat session ID.
Drafts persist across navigation within the same browser session.
2025-11-03 10:30:50 -08:00
Wenxi Onyx
1bd9756554 prettier 2025-11-03 10:30:32 -08:00
Wenxi Onyx
a6eeaca828 fix: properly type textAreaRef as MutableRefObject
Replace type cast workaround with correct typing. The parent creates
the ref with useRef() which returns MutableRefObject at runtime, but
TypeScript infers it as the readonly RefObject type. By typing the
prop as MutableRefObject, we fix the type error properly without casts.

Changes:
- Changed prop type from RefObject to MutableRefObject
- Removed type cast in ref callback (no longer needed)
- Added textAreaRef to dependency arrays for correctness

This supersedes commit 96b2766c8 which used a type cast.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 10:22:13 -08:00
Wenxi Onyx
93052e1044 fix: resolve TypeScript error in ref callback assignment
Fix readonly ref assignment error by using type cast to MutableRefObject.
The textAreaRef is passed as a RefObject (readonly) but needs to be
assigned in the callback. This is safe because the parent creates it
with useRef() which is mutable at runtime.

Also add textAreaRef to the callback's dependency array for correctness.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 10:22:13 -08:00
Wenxi Onyx
fdeb79e374 perf: localize chat input state to prevent re-render cascade
Moves message state from ChatPage to ChatInputBar to eliminate
unnecessary re-renders. Replaces useEffect-based resize with
imperative DOM manipulation and ResizeObserver. Adds effect to
clear input after successful submit.
2025-11-03 10:22:13 -08:00
Wenxi Onyx
76ce444b86 add react dev tools 2025-11-03 10:21:33 -08:00
2 changed files with 185 additions and 59 deletions

View File

@@ -277,10 +277,6 @@ export function ChatPage({
}
}, [lastFailedFiles, setPopup, clearLastFailedFiles]);
const [message, setMessage] = useState(
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) || ""
);
const [projectPanelVisible, setProjectPanelVisible] = useState(true);
const filterManager = useFilters();
@@ -310,7 +306,9 @@ export function ChatPage({
setAboveHorizon(false);
}, [existingChatSessionId]);
function handleInputResize() {
const autoScrollEnabled = user?.preferences?.auto_scroll ?? false;
const handleInputResize = useCallback(() => {
setTimeout(() => {
if (
inputRef.current &&
@@ -344,23 +342,39 @@ export function ChatPage({
previousHeight.current = newHeight;
}
}, 100);
}
const resetInputBar = useCallback(() => {
setMessage("");
setCurrentMessageFiles([]);
if (endPaddingRef.current) {
endPaddingRef.current.style.height = `95px`;
}
}, [setMessage, setCurrentMessageFiles]);
}, [autoScrollEnabled]);
const debounceNumber = 100; // time for debouncing
// handle re-sizing of the text area
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const resetInputBar = useCallback(() => {
setCurrentMessageFiles([]);
if (endPaddingRef.current) {
endPaddingRef.current.style.height = `95px`;
}
// Reset textarea height
if (textAreaRef.current) {
textAreaRef.current.value = "";
textAreaRef.current.style.height = "0px";
textAreaRef.current.style.height = `${Math.min(
textAreaRef.current.scrollHeight,
200 // MAX_INPUT_HEIGHT from ChatInputBar
)}px`;
}
}, [setCurrentMessageFiles]);
// ResizeObserver watches inputRef for height changes
useEffect(() => {
handleInputResize();
}, [message]);
if (!inputRef.current) return;
const resizeObserver = new ResizeObserver(() => {
// RAF guard prevents "ResizeObserver loop limit exceeded"
requestAnimationFrame(() => handleInputResize());
});
resizeObserver.observe(inputRef.current);
return () => resizeObserver.disconnect();
}, [handleInputResize]);
// Add refs needed by useChatSessionController
const chatSessionIdRef = useRef<string | null>(existingChatSessionId);
@@ -515,8 +529,6 @@ export function ChatPage({
onSubmit,
});
const autoScrollEnabled = user?.preferences?.auto_scroll ?? false;
useScrollonStream({
chatState: currentChatState,
scrollableDivRef,
@@ -626,14 +638,17 @@ export function ChatPage({
);
}, []);
const handleChatInputSubmit = useCallback(() => {
onSubmit({
message: message,
currentMessageFiles: currentMessageFiles,
useAgentSearch: deepResearchEnabled,
});
setShowOnboarding(false);
}, [message, onSubmit, currentMessageFiles, deepResearchEnabled]);
const handleChatInputSubmit = useCallback(
(message: string) => {
onSubmit({
message: message,
currentMessageFiles: currentMessageFiles,
useAgentSearch: deepResearchEnabled,
});
setShowOnboarding(false);
},
[onSubmit, currentMessageFiles, deepResearchEnabled]
);
// Memoized callbacks for DocumentResults
const handleMobileDocumentSidebarClose = useCallback(() => {
@@ -919,11 +934,14 @@ export function ChatPage({
removeDocs={() => setSelectedDocuments([])}
retrievalEnabled={retrievalEnabled}
selectedDocuments={selectedDocuments}
message={message}
setMessage={setMessage}
initialMessage={
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) ||
""
}
stopGenerating={stopGenerating}
onSubmit={handleChatInputSubmit}
chatState={currentChatState}
chatSessionId={existingChatSessionId}
currentSessionFileTokenCount={
existingChatSessionId
? currentSessionFileTokenCount
@@ -934,6 +952,7 @@ export function ChatPage({
handleFileUpload={handleMessageSpecificFileUpload}
textAreaRef={textAreaRef}
setPresentingDocument={setPresentingDocument}
resetInputBar={resetInputBar}
disabled={
llmProviders.length === 0 ||
(llmProviders.length === 0 &&

View File

@@ -41,6 +41,39 @@ import {
const MAX_INPUT_HEIGHT = 200;
// Draft storage helpers - SSR-safe sessionStorage operations
function getDraft(key: string): string | null {
if (typeof window === "undefined") return null;
try {
return sessionStorage.getItem(key);
} catch (e) {
console.warn("Failed to load draft from sessionStorage:", e);
return null;
}
}
function saveDraft(key: string, value: string): void {
if (typeof window === "undefined") return;
try {
if (value.trim()) {
sessionStorage.setItem(key, value);
} else {
sessionStorage.removeItem(key);
}
} catch (e) {
console.warn("Failed to save draft to sessionStorage:", e);
}
}
function removeDraft(key: string): void {
if (typeof window === "undefined") return;
try {
sessionStorage.removeItem(key);
} catch (e) {
console.warn("Failed to remove draft from sessionStorage:", e);
}
}
export interface SourceChipProps {
icon?: React.ReactNode;
title: string;
@@ -83,12 +116,12 @@ export function SourceChip({
export interface ChatInputBarProps {
removeDocs: () => void;
selectedDocuments: OnyxDocument[];
message: string;
setMessage: (message: string) => void;
initialMessage: string;
stopGenerating: () => void;
onSubmit: () => void;
onSubmit: (message: string) => void;
llmManager: LlmManager;
chatState: ChatState;
chatSessionId: string | null;
currentSessionFileTokenCount: number;
availableContextTokens: number;
@@ -97,12 +130,13 @@ export interface ChatInputBarProps {
toggleDocumentSidebar: () => void;
handleFileUpload: (files: File[]) => void;
textAreaRef: React.RefObject<HTMLTextAreaElement>;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
filterManager: FilterManager;
retrievalEnabled: boolean;
deepResearchEnabled: boolean;
setPresentingDocument?: (document: MinimalOnyxDocument) => void;
toggleDeepResearch: () => void;
resetInputBar: () => void;
disabled: boolean;
}
@@ -112,11 +146,11 @@ function ChatInputBarInner({
toggleDocumentSidebar,
filterManager,
selectedDocuments,
message,
setMessage,
initialMessage,
stopGenerating,
onSubmit,
chatState,
chatSessionId,
currentSessionFileTokenCount,
availableContextTokens,
// assistants
@@ -128,13 +162,32 @@ function ChatInputBarInner({
deepResearchEnabled,
toggleDeepResearch,
setPresentingDocument,
resetInputBar,
disabled,
}: ChatInputBarProps) {
const { user } = useUser();
const { forcedToolIds, setForcedToolIds } = useAgentsContext();
const { currentMessageFiles, setCurrentMessageFiles } = useProjectsContext();
// Draft persistence: compute key based on chatSessionId
const draftKey = useMemo(
() => `chat-draft-${chatSessionId || "new"}`,
[chatSessionId]
);
// Local message state: initialize from URL param or saved draft
const [localMessage, setLocalMessage] = useState(() => {
if (initialMessage) {
return initialMessage;
}
return getDraft(draftKey) || "";
});
// Refs for tracking previous values across renders
const isInitialMount = React.useRef(true);
const prevChatState = React.useRef(chatState);
const prevDraftKey = React.useRef<string>("");
const currentIndexingFiles = useMemo(() => {
return currentMessageFiles.filter(
(file) => file.status === UserFileStatus.PROCESSING
@@ -167,16 +220,6 @@ function ChatInputBarInner({
);
const combinedSettings = useContext(SettingsContext);
useEffect(() => {
const textarea = textAreaRef.current;
if (textarea) {
textarea.style.height = "0px"; // this is necessary in order to "reset" the scrollHeight
textarea.style.height = `${Math.min(
textarea.scrollHeight,
MAX_INPUT_HEIGHT
)}px`;
}
}, [message, textAreaRef]);
const handlePaste = (event: React.ClipboardEvent) => {
const items = event.clipboardData?.items;
@@ -225,7 +268,7 @@ function ChatInputBarInner({
const updateInputPrompt = (prompt: InputPrompt) => {
hidePrompts();
setMessage(`${prompt.content}`);
setLocalMessage(`${prompt.content}`);
};
const handlePromptInput = useCallback(
@@ -244,26 +287,90 @@ function ChatInputBarInner({
[hidePrompts]
);
// Helper to resize textarea based on content
const resizeTextarea = useCallback((textarea: HTMLTextAreaElement) => {
textarea.style.height = "0px";
textarea.style.height = `${Math.min(
textarea.scrollHeight,
MAX_INPUT_HEIGHT
)}px`;
}, []);
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = event.target.value;
setMessage(text);
setLocalMessage(text);
handlePromptInput(text);
saveDraft(draftKey, text);
if (textAreaRef.current) {
resizeTextarea(textAreaRef.current);
}
},
[setMessage, handlePromptInput]
[handlePromptInput, textAreaRef, draftKey, resizeTextarea]
);
// Callback ref to set initial textarea height on mount
const handleTextAreaRef = useCallback(
(element: HTMLTextAreaElement | null) => {
textAreaRef.current = element;
if (element) {
resizeTextarea(element);
}
},
[textAreaRef, resizeTextarea]
);
// Wrap onSubmit to clear draft immediately before submission
const handleSubmit = useCallback(
(message: string) => {
// Clear draft synchronously before onSubmit to prevent race condition
// (chatSessionId may update during submission, changing draftKey)
removeDraft(draftKey);
onSubmit(message);
},
[onSubmit, draftKey]
);
// Load draft when switching between chats
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
prevDraftKey.current = draftKey;
return;
}
const loadedDraft = getDraft(draftKey) || "";
setLocalMessage(loadedDraft);
prevDraftKey.current = draftKey;
// Resize textarea to match loaded content
if (textAreaRef.current) {
resizeTextarea(textAreaRef.current);
}
}, [draftKey, textAreaRef, resizeTextarea]);
// Effect: Clear input when transitioning away from input state
useEffect(() => {
if (prevChatState.current === "input" && chatState !== "input") {
setLocalMessage("");
resetInputBar(); // Handles both parent padding and textarea height
}
prevChatState.current = chatState;
}, [chatState, resetInputBar]);
const startFilterSlash = useMemo(() => {
if (message !== undefined) {
const message_segments = message
.slice(message.lastIndexOf("/") + 1)
if (localMessage !== undefined) {
const message_segments = localMessage
.slice(localMessage.lastIndexOf("/") + 1)
.split(/\s/);
if (message_segments[0]) {
return message_segments[0].toLowerCase();
}
}
return "";
}, [message]);
}, [localMessage]);
const [tabbingIconIndex, setTabbingIconIndex] = useState(0);
@@ -422,7 +529,7 @@ function ChatInputBarInner({
onPaste={handlePaste}
onKeyDownCapture={handleKeyDown}
onChange={handleInputChange}
ref={textAreaRef}
ref={handleTextAreaRef}
id="onyx-chat-input-textarea"
className={cn(
"w-full",
@@ -450,7 +557,7 @@ function ChatInputBarInner({
} help you today`
: `How can ${selectedAssistant.name} help you today`
}
value={message}
value={localMessage}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
@@ -459,8 +566,8 @@ function ChatInputBarInner({
!(event.nativeEvent as any).isComposing
) {
event.preventDefault();
if (message) {
onSubmit();
if (localMessage) {
handleSubmit(localMessage);
}
}
}}
@@ -611,12 +718,12 @@ function ChatInputBarInner({
<IconButton
id="onyx-chat-input-send-button"
icon={chatState === "input" ? SvgArrowUp : SvgStop}
disabled={chatState === "input" && !message}
disabled={chatState === "input" && !localMessage}
onClick={() => {
if (chatState == "streaming") {
stopGenerating();
} else if (message) {
onSubmit();
} else if (localMessage) {
handleSubmit(localMessage);
}
}}
/>