From c0f1cfcaba12cc79ca64d5865cb4c368110333bf Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 30 Oct 2025 22:14:38 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A1=20feat:=20Improve=20Reasoning=20Co?= =?UTF-8?q?ntent=20UI,=20copy-to-clipboard,=20and=20error=20handling=20(#1?= =?UTF-8?q?0278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Refactor error handling and improve loading states in MessageContent component * ✨ feat: Enhance Thinking and ContentParts components with improved hover functionality and clipboard support * fix: Adjust padding in Thinking and ContentParts components for consistent layout * ✨ feat: Add response label and improve message editing UI with contextual indicators * ✨ feat: Add isEditing prop to Feedback and Fork components for improved editing state handling * refactor: Remove isEditing prop from Feedback and Fork components for cleaner state management * refactor: Migrate state management from Recoil to Jotai for font size and show thinking features * refactor: Separate ToggleSwitch into RecoilToggle and JotaiToggle components for improved clarity and state management * refactor: Remove unnecessary comments in ToggleSwitch and MessageContent components for cleaner code * chore: reorder import statements in Thinking.tsx * chore: reorder import statement in EditTextPart.tsx * chore: reorder import statement * chore: Reorganize imports in ToggleSwitch.tsx --------- Co-authored-by: Danny Avila --- client/src/components/Artifacts/Thinking.tsx | 171 ++++++++++++++---- .../Chat/Messages/Content/ContentParts.tsx | 111 +++++++++--- .../Chat/Messages/Content/EditMessage.tsx | 2 +- .../Chat/Messages/Content/MessageContent.tsx | 148 ++++++++------- .../Messages/Content/Parts/EditTextPart.tsx | 17 ++ .../Chat/Messages/Content/Parts/Reasoning.tsx | 30 ++- .../components/Nav/SettingsTabs/Chat/Chat.tsx | 3 +- .../Nav/SettingsTabs/Chat/ShowThinking.tsx | 8 +- .../Nav/SettingsTabs/ToggleSwitch.tsx | 74 +++++++- client/src/locales/en/translation.json | 3 + client/src/store/fontSize.ts | 51 +----- client/src/store/jotai-utils.ts | 88 +++++++++ client/src/store/showThinking.ts | 8 + 13 files changed, 528 insertions(+), 186 deletions(-) create mode 100644 client/src/store/jotai-utils.ts create mode 100644 client/src/store/showThinking.ts diff --git a/client/src/components/Artifacts/Thinking.tsx b/client/src/components/Artifacts/Thinking.tsx index 08e241c6e8..25d5810e16 100644 --- a/client/src/components/Artifacts/Thinking.tsx +++ b/client/src/components/Artifacts/Thinking.tsx @@ -1,72 +1,171 @@ import { useState, useMemo, memo, useCallback } from 'react'; -import { useRecoilValue } from 'recoil'; -import { Atom, ChevronDown } from 'lucide-react'; +import { useAtomValue } from 'jotai'; +import { Lightbulb, ChevronDown } from 'lucide-react'; +import { Clipboard, CheckMark } from '@librechat/client'; import type { MouseEvent, FC } from 'react'; +import { showThinkingAtom } from '~/store/showThinking'; +import { fontSizeAtom } from '~/store/fontSize'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; -import store from '~/store'; -const BUTTON_STYLES = { - base: 'group mt-3 flex w-fit items-center justify-center rounded-xl bg-surface-tertiary px-3 py-2 text-xs leading-[18px] animate-thinking-appear', - icon: 'icon-sm ml-1.5 transform-gpu text-text-primary transition-transform duration-200', -} as const; +/** + * ThinkingContent - Displays the actual thinking/reasoning content + * Used by both legacy text-based messages and modern content parts + */ +export const ThinkingContent: FC<{ + children: React.ReactNode; +}> = memo(({ children }) => { + const fontSize = useAtomValue(fontSizeAtom); -const CONTENT_STYLES = { - wrapper: 'relative pl-3 text-text-secondary', - border: - 'absolute left-0 h-[calc(100%-10px)] border-l-2 border-border-medium dark:border-border-heavy', - partBorder: - 'absolute left-0 h-[calc(100%)] border-l-2 border-border-medium dark:border-border-heavy', - text: 'whitespace-pre-wrap leading-[26px]', -} as const; - -export const ThinkingContent: FC<{ children: React.ReactNode; isPart?: boolean }> = memo( - ({ isPart, children }) => ( -
-
-

{children}

+ return ( +
+

{children}

- ), -); + ); +}); +/** + * ThinkingButton - Toggle button for expanding/collapsing thinking content + * Shows lightbulb icon by default, chevron on hover + * Shared between legacy Thinking component and modern ContentParts + */ export const ThinkingButton = memo( ({ isExpanded, onClick, label, + content, + isContentHovered = false, }: { isExpanded: boolean; onClick: (e: MouseEvent) => void; label: string; - }) => ( - - ), + content?: string; + isContentHovered?: boolean; + }) => { + const localize = useLocalize(); + const fontSize = useAtomValue(fontSizeAtom); + + const [isButtonHovered, setIsButtonHovered] = useState(false); + const [isCopied, setIsCopied] = useState(false); + + const isHovered = useMemo( + () => isButtonHovered || isContentHovered, + [isButtonHovered, isContentHovered], + ); + + const handleCopy = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + if (content) { + navigator.clipboard.writeText(content); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } + }, + [content], + ); + + return ( +
+ + {content && ( + + )} +
+ ); + }, ); +/** + * Thinking Component (LEGACY SYSTEM) + * + * Used for simple text-based messages with `:::thinking:::` markers. + * This handles the old message format where text contains embedded thinking blocks. + * + * Pattern: `:::thinking\n{content}\n:::\n{response}` + * + * Used by: + * - MessageContent.tsx for plain text messages + * - Legacy message format compatibility + * - User messages when manually adding thinking content + * + * For modern structured content (agents/assistants), see Reasoning.tsx component. + */ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactNode }) => { const localize = useLocalize(); - const showThinking = useRecoilValue(store.showThinking); + const showThinking = useAtomValue(showThinkingAtom); const [isExpanded, setIsExpanded] = useState(showThinking); + const [isContentHovered, setIsContentHovered] = useState(false); const handleClick = useCallback((e: MouseEvent) => { e.preventDefault(); setIsExpanded((prev) => !prev); }, []); + const handleContentEnter = useCallback(() => setIsContentHovered(true), []); + const handleContentLeave = useCallback(() => setIsContentHovered(false), []); + const label = useMemo(() => localize('com_ui_thoughts'), [localize]); + // Extract text content for copy functionality + const textContent = useMemo(() => { + if (typeof children === 'string') { + return children; + } + return ''; + }, [children]); + if (children == null) { return null; } return ( - <> -
- +
+
+
- {children} + {children}
- +
); }); diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index d9efa34cc4..157e57aa4a 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -1,5 +1,5 @@ -import { memo, useMemo, useState } from 'react'; -import { useRecoilState } from 'recoil'; +import { memo, useMemo, useState, useCallback } from 'react'; +import { useAtom } from 'jotai'; import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts, @@ -9,12 +9,12 @@ import type { } from 'librechat-data-provider'; import { ThinkingButton } from '~/components/Artifacts/Thinking'; import { MessageContext, SearchContext } from '~/Providers'; +import { showThinkingAtom } from '~/store/showThinking'; import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; import { mapAttachments } from '~/utils/map'; import { EditTextPart } from './Parts'; import { useLocalize } from '~/hooks'; -import store from '~/store'; import Part from './Part'; type ContentPartsProps = { @@ -53,12 +53,16 @@ const ContentParts = memo( setSiblingIdx, }: ContentPartsProps) => { const localize = useLocalize(); - const [showThinking, setShowThinking] = useRecoilState(store.showThinking); + const [showThinking, setShowThinking] = useAtom(showThinkingAtom); const [isExpanded, setIsExpanded] = useState(showThinking); + const [isContentHovered, setIsContentHovered] = useState(false); const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; + const handleContentEnter = useCallback(() => setIsContentHovered(true), []); + const handleContentLeave = useCallback(() => setIsContentHovered(false), []); + const hasReasoningParts = useMemo(() => { const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false; const allThinkPartsHaveContent = @@ -78,6 +82,23 @@ const ContentParts = memo( return hasThinkPart && allThinkPartsHaveContent; }, [content]); + // Extract all reasoning text for copy functionality + const reasoningContent = useMemo(() => { + if (!content) { + return ''; + } + return content + .filter((part) => part?.type === ContentTypes.THINK) + .map((part) => { + if (typeof part?.think === 'string') { + return part.think.replace(/<\/?think>/g, '').trim(); + } + return ''; + }) + .filter(Boolean) + .join('\n\n'); + }, [content]); + if (!content) { return null; } @@ -127,40 +148,74 @@ const ContentParts = memo( {hasReasoningParts && ( -
- - setIsExpanded((prev) => { - const val = !prev; - setShowThinking(val); - return val; - }) - } - label={ - effectiveIsSubmitting && isLast - ? localize('com_ui_thinking') - : localize('com_ui_thoughts') - } - /> +
+
+ + setIsExpanded((prev) => { + const val = !prev; + setShowThinking(val); + return val; + }) + } + label={ + effectiveIsSubmitting && isLast + ? localize('com_ui_thinking') + : localize('com_ui_thoughts') + } + content={reasoningContent} + isContentHovered={isContentHovered} + /> +
+ {content + .filter((part) => part?.type === ContentTypes.THINK) + .map((part) => { + const originalIdx = content.indexOf(part); + return ( + + + + ); + })}
)} {content - .filter((part) => part) - .map((part, idx) => { + .filter((part) => part && part.type !== ContentTypes.THINK) + .map((part) => { + const originalIdx = content.indexOf(part); const toolCallId = (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? ''; const attachments = attachmentMap[toolCallId]; return ( ); diff --git a/client/src/components/Chat/Messages/Content/EditMessage.tsx b/client/src/components/Chat/Messages/Content/EditMessage.tsx index b24d67cf3d..e578c2a56c 100644 --- a/client/src/components/Chat/Messages/Content/EditMessage.tsx +++ b/client/src/components/Chat/Messages/Content/EditMessage.tsx @@ -151,7 +151,7 @@ const EditMessage = ({ return ( -
+
{ diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index f36375234c..9204d3738d 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -14,57 +14,79 @@ import Markdown from './Markdown'; import { cn } from '~/utils'; import store from '~/store'; +const ERROR_CONNECTION_TEXT = 'Error connecting to server, try refreshing the page.'; +const DELAYED_ERROR_TIMEOUT = 5500; +const UNFINISHED_DELAY = 250; + +const parseThinkingContent = (text: string) => { + const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/); + return { + thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '', + regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text, + }; +}; + +const LoadingFallback = () => ( +
+
+
+

+ +

+
+
+
+); + +const ErrorBox = ({ + children, + className = '', +}: { + children: React.ReactNode; + className?: string; +}) => ( +
+ {children} +
+); + +const ConnectionError = ({ message }: { message?: TMessage }) => { + const localize = useLocalize(); + + return ( + }> + + +
+ {localize('com_ui_error_connection')} +
+
+
+
+ ); +}; + export const ErrorMessage = ({ text, message, className = '', -}: Pick & { - message?: TMessage; -}) => { - const localize = useLocalize(); - if (text === 'Error connecting to server, try refreshing the page.') { - console.log('error message', message); - return ( - -
-
-

- -

-
-
-
- } - > - - -
- {localize('com_ui_error_connection')} -
-
-
- - ); +}: Pick & { message?: TMessage }) => { + if (text === ERROR_CONNECTION_TEXT) { + return ; } + return ( -
+ -
+
); }; @@ -72,27 +94,29 @@ export const ErrorMessage = ({ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => { const { isSubmitting = false, isLatestMessage = false } = useMessageContext(); const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); + const showCursorState = useMemo( () => showCursor === true && isSubmitting, [showCursor, isSubmitting], ); - let content: React.ReactElement; - if (!isCreatedByUser) { - content = ; - } else if (enableUserMsgMarkdown) { - content = ; - } else { - content = <>{text}; - } + const content = useMemo(() => { + if (!isCreatedByUser) { + return ; + } + if (enableUserMsgMarkdown) { + return ; + } + return <>{text}; + }, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]); return (
0 && 'result-streaming', isCreatedByUser && !enableUserMsgMarkdown && 'whitespace-pre-wrap', isCreatedByUser ? 'dark:text-gray-20' : 'dark:text-gray-100', )} @@ -103,7 +127,6 @@ const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplay ); }; -// Unfinished Message Component export const UnfinishedMessage = ({ message }: { message: TMessage }) => ( { - const thinkingMatch = text.match(/:::thinking([\s\S]*?):::/); - return { - thinkingContent: thinkingMatch ? thinkingMatch[1].trim() : '', - regularContent: thinkingMatch ? text.replace(/:::thinking[\s\S]*?:::/, '').trim() : text, - }; - }, [text]); - + const { thinkingContent, regularContent } = useMemo(() => parseThinkingContent(text), [text]); const showRegularCursor = useMemo(() => isLast && isSubmitting, [isLast, isSubmitting]); const unfinishedMessage = useMemo( () => !isSubmitting && unfinished ? ( - + @@ -146,8 +162,10 @@ const MessageContent = ({ ); if (error) { - return ; - } else if (edit) { + return ; + } + + if (edit) { return ; } diff --git a/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx b/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx index 5422d9733d..10f61fd8af 100644 --- a/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx @@ -3,6 +3,7 @@ import { useForm } from 'react-hook-form'; import { TextareaAutosize } from '@librechat/client'; import { ContentTypes } from 'librechat-data-provider'; import { useRecoilState, useRecoilValue } from 'recoil'; +import { Lightbulb, MessageSquare } from 'lucide-react'; import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query'; import type { Agents } from 'librechat-data-provider'; import type { TEditProps } from '~/common'; @@ -153,6 +154,22 @@ const EditTextPart = ({ return ( + {part.type === ContentTypes.THINK && ( +
+ + + {localize('com_ui_thoughts')} + +
+ )} + {part.type !== ContentTypes.THINK && ( +
+ + + {localize('com_ui_response')} + +
+ )}
content" }, ...] }` + * + * Used by: + * - ContentParts.tsx → Part.tsx for structured messages + * - Agent/Assistant responses (OpenAI Assistants, custom agents) + * - O-series models (o1, o3) with reasoning capabilities + * - Modern Claude responses with thinking blocks + * + * Key differences from legacy Thinking.tsx: + * - Works with content parts array instead of plain text + * - Strips `` tags instead of `:::thinking:::` markers + * - Uses shared ThinkingButton via ContentParts.tsx + * - Controlled by MessageContext isExpanded state + * + * For legacy text-based messages, see Thinking.tsx component. + */ const Reasoning = memo(({ reasoning }: ReasoningProps) => { const { isExpanded, nextType } = useMessageContext(); + + // Strip tags from the reasoning content (modern format) const reasoningText = useMemo(() => { return reasoning .replace(/^\s*/, '') @@ -21,18 +45,20 @@ const Reasoning = memo(({ reasoning }: ReasoningProps) => { return null; } + // Note: The toggle button is rendered separately in ContentParts.tsx + // This component only handles the collapsible content area return (
- {reasoningText} + {reasoningText}
); diff --git a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx index 70858a9b72..5c703922fc 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/Chat.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; +import { showThinkingAtom } from '~/store/showThinking'; import FontSizeSelector from './FontSizeSelector'; import { ForkSettings } from './ForkSettings'; import ChatDirection from './ChatDirection'; @@ -28,7 +29,7 @@ const toggleSwitchConfigs = [ key: 'centerFormOnLanding', }, { - stateAtom: store.showThinking, + stateAtom: showThinkingAtom, localizationKey: 'com_nav_show_thinking', switchId: 'showThinking', hoverCardText: undefined, diff --git a/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx b/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx index 949453cb5c..905efcc98c 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx @@ -1,18 +1,18 @@ -import { useRecoilState } from 'recoil'; +import { useAtom } from 'jotai'; import { Switch, InfoHoverCard, ESide } from '@librechat/client'; +import { showThinkingAtom } from '~/store/showThinking'; import { useLocalize } from '~/hooks'; -import store from '~/store'; export default function SaveDraft({ onCheckedChange, }: { onCheckedChange?: (value: boolean) => void; }) { - const [showThinking, setSaveDrafts] = useRecoilState(store.showThinking); + const [showThinking, setShowThinking] = useAtom(showThinkingAtom); const localize = useLocalize(); const handleCheckedChange = (value: boolean) => { - setSaveDrafts(value); + setShowThinking(value); if (onCheckedChange) { onCheckedChange(value); } diff --git a/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx b/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx index 391ab0a494..2bbe0d941f 100644 --- a/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx @@ -1,3 +1,4 @@ +import { WritableAtom, useAtom } from 'jotai'; import { RecoilState, useRecoilState } from 'recoil'; import { Switch, InfoHoverCard, ESide } from '@librechat/client'; import { useLocalize } from '~/hooks'; @@ -6,7 +7,7 @@ type LocalizeFn = ReturnType; type LocalizeKey = Parameters[0]; interface ToggleSwitchProps { - stateAtom: RecoilState; + stateAtom: RecoilState | WritableAtom; localizationKey: LocalizeKey; hoverCardText?: LocalizeKey; switchId: string; @@ -16,13 +17,18 @@ interface ToggleSwitchProps { strongLabel?: boolean; } -const ToggleSwitch: React.FC = ({ +function isRecoilState(atom: unknown): atom is RecoilState { + return atom != null && typeof atom === 'object' && 'key' in atom; +} + +const RecoilToggle: React.FC< + Omit & { stateAtom: RecoilState } +> = ({ stateAtom, localizationKey, hoverCardText, switchId, onCheckedChange, - showSwitch = true, disabled = false, strongLabel = false, }) => { @@ -36,9 +42,47 @@ const ToggleSwitch: React.FC = ({ const labelId = `${switchId}-label`; - if (!showSwitch) { - return null; - } + return ( +
+
+
+ {strongLabel ? {localize(localizationKey)} : localize(localizationKey)} +
+ {hoverCardText && } +
+ +
+ ); +}; + +const JotaiToggle: React.FC< + Omit & { stateAtom: WritableAtom } +> = ({ + stateAtom, + localizationKey, + hoverCardText, + switchId, + onCheckedChange, + disabled = false, + strongLabel = false, +}) => { + const [switchState, setSwitchState] = useAtom(stateAtom); + const localize = useLocalize(); + + const handleCheckedChange = (value: boolean) => { + setSwitchState(value); + onCheckedChange?.(value); + }; + + const labelId = `${switchId}-label`; return (
@@ -52,13 +96,29 @@ const ToggleSwitch: React.FC = ({ id={switchId} checked={switchState} onCheckedChange={handleCheckedChange} + disabled={disabled} className="ml-4" data-testid={switchId} aria-labelledby={labelId} - disabled={disabled} />
); }; +const ToggleSwitch: React.FC = (props) => { + const { stateAtom, showSwitch = true } = props; + + if (!showSwitch) { + return null; + } + + const isRecoil = isRecoilState(stateAtom); + + if (isRecoil) { + return } />; + } + + return } />; +}; + export default ToggleSwitch; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 5becd0ce93..acbba5527b 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -789,6 +789,8 @@ "com_ui_copy_stack_trace": "Copy stack trace", "com_ui_copy_to_clipboard": "Copy to clipboard", "com_ui_copy_url_to_clipboard": "Copy URL to clipboard", + "com_ui_copy_stack_trace": "Copy stack trace", + "com_ui_copy_thoughts_to_clipboard": "Copy thoughts to clipboard", "com_ui_create": "Create", "com_ui_create_link": "Create link", "com_ui_create_memory": "Create Memory", @@ -1222,6 +1224,7 @@ "com_ui_terms_of_service": "Terms of service", "com_ui_thinking": "Thinking...", "com_ui_thoughts": "Thoughts", + "com_ui_response": "Response", "com_ui_token": "token", "com_ui_token_exchange_method": "Token Exchange Method", "com_ui_token_url": "Token URL", diff --git a/client/src/store/fontSize.ts b/client/src/store/fontSize.ts index 4b1a0666f3..19ec56e815 100644 --- a/client/src/store/fontSize.ts +++ b/client/src/store/fontSize.ts @@ -1,54 +1,21 @@ -import { atom } from 'jotai'; -import { atomWithStorage } from 'jotai/utils'; import { applyFontSize } from '@librechat/client'; +import { createStorageAtomWithEffect, initializeFromStorage } from './jotai-utils'; const DEFAULT_FONT_SIZE = 'text-base'; /** - * Base storage atom for font size + * This atom stores the user's font size preference */ -const fontSizeStorageAtom = atomWithStorage('fontSize', DEFAULT_FONT_SIZE, undefined, { - getOnInit: true, -}); - -/** - * Derived atom that applies font size changes to the DOM - * Read: returns the current font size - * Write: updates storage and applies the font size to the DOM - */ -export const fontSizeAtom = atom( - (get) => get(fontSizeStorageAtom), - (get, set, newValue: string) => { - set(fontSizeStorageAtom, newValue); - if (typeof window !== 'undefined' && typeof document !== 'undefined') { - applyFontSize(newValue); - } - }, +export const fontSizeAtom = createStorageAtomWithEffect( + 'fontSize', + DEFAULT_FONT_SIZE, + applyFontSize, ); /** * Initialize font size on app load + * This function applies the saved font size from localStorage to the DOM */ -export const initializeFontSize = () => { - if (typeof window === 'undefined' || typeof document === 'undefined') { - return; - } - - const savedValue = localStorage.getItem('fontSize'); - - if (savedValue !== null) { - try { - const parsedValue = JSON.parse(savedValue); - applyFontSize(parsedValue); - } catch (error) { - console.error( - 'Error parsing localStorage key "fontSize", resetting to default. Error:', - error, - ); - localStorage.setItem('fontSize', JSON.stringify(DEFAULT_FONT_SIZE)); - applyFontSize(DEFAULT_FONT_SIZE); - } - } else { - applyFontSize(DEFAULT_FONT_SIZE); - } +export const initializeFontSize = (): void => { + initializeFromStorage('fontSize', DEFAULT_FONT_SIZE, applyFontSize); }; diff --git a/client/src/store/jotai-utils.ts b/client/src/store/jotai-utils.ts new file mode 100644 index 0000000000..d3ca9d817c --- /dev/null +++ b/client/src/store/jotai-utils.ts @@ -0,0 +1,88 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; + +/** + * Create a simple atom with localStorage persistence + * Uses Jotai's atomWithStorage with getOnInit for proper SSR support + * + * @param key - localStorage key + * @param defaultValue - default value if no saved value exists + * @returns Jotai atom with localStorage persistence + */ +export function createStorageAtom(key: string, defaultValue: T) { + return atomWithStorage(key, defaultValue, undefined, { + getOnInit: true, + }); +} + +/** + * Create an atom with localStorage persistence and side effects + * Useful when you need to apply changes to the DOM or trigger other actions + * + * @param key - localStorage key + * @param defaultValue - default value if no saved value exists + * @param onWrite - callback function to run when the value changes + * @returns Jotai atom with localStorage persistence and side effects + */ +export function createStorageAtomWithEffect( + key: string, + defaultValue: T, + onWrite: (value: T) => void, +) { + const baseAtom = createStorageAtom(key, defaultValue); + + return atom( + (get) => get(baseAtom), + (get, set, newValue: T) => { + set(baseAtom, newValue); + if (typeof window !== 'undefined') { + onWrite(newValue); + } + }, + ); +} + +/** + * Initialize a value from localStorage and optionally apply it + * Useful for applying saved values on app startup (e.g., theme, fontSize) + * + * @param key - localStorage key + * @param defaultValue - default value if no saved value exists + * @param onInit - optional callback to run with the loaded value + * @returns The loaded value (or default if none exists) + */ +export function initializeFromStorage( + key: string, + defaultValue: T, + onInit?: (value: T) => void, +): T { + if (typeof window === 'undefined' || typeof localStorage === 'undefined') { + return defaultValue; + } + + try { + const savedValue = localStorage.getItem(key); + const value = savedValue ? (JSON.parse(savedValue) as T) : defaultValue; + + if (onInit) { + onInit(value); + } + + return value; + } catch (error) { + console.error(`Error initializing ${key} from localStorage, using default. Error:`, error); + + // Reset corrupted value + try { + localStorage.setItem(key, JSON.stringify(defaultValue)); + } catch (setError) { + console.error(`Error resetting corrupted ${key} in localStorage:`, setError); + } + + if (onInit) { + onInit(defaultValue); + } + + return defaultValue; + } +} diff --git a/client/src/store/showThinking.ts b/client/src/store/showThinking.ts new file mode 100644 index 0000000000..313d6d14c2 --- /dev/null +++ b/client/src/store/showThinking.ts @@ -0,0 +1,8 @@ +import { createStorageAtom } from './jotai-utils'; + +const DEFAULT_SHOW_THINKING = false; + +/** + * This atom controls whether AI reasoning/thinking content is expanded by default. + */ +export const showThinkingAtom = createStorageAtom('showThinking', DEFAULT_SHOW_THINKING);