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 (
+
- ),
-);
+ );
+});
+/**
+ * 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);